From bf9eb069556ee91ec4965417d728f76d714f55e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=80=97=E5=AD=90?= Date: Thu, 20 Aug 2026 00:08:50 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=A1=A8=E7=BB=B4=E6=8A=A4=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0=20VACUUM=20FULL=20=E5=B9=B6=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E5=A4=9A=E8=A1=A8=E6=89=B9=E9=87=8F=E6=93=8D=E4=BD=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- internal/apps/mysql/app.go | 14 +++- internal/apps/mysql/request.go | 11 ++- internal/apps/postgresql/app.go | 30 ++++--- internal/apps/postgresql/request.go | 13 ++- .../views/apps/mysql/MysqlMaintenanceView.vue | 52 +++++++++--- .../postgresql/PostgresqlMaintenanceView.vue | 79 ++++++++++++++++--- 6 files changed, 158 insertions(+), 41 deletions(-) diff --git a/internal/apps/mysql/app.go b/internal/apps/mysql/app.go index 4313d3bf..5fb798d3 100644 --- a/internal/apps/mysql/app.go +++ b/internal/apps/mysql/app.go @@ -630,6 +630,10 @@ func (s *App) RunMaintenance(w http.ResponseWriter, r *http.Request) { return } + if len(req.Tables) == 0 { + service.Error(w, http.StatusUnprocessableEntity, s.t.Get("no tables selected")) + return + } if !slices.Contains([]string{"optimize", "analyze"}, req.Operation) { service.Error(w, http.StatusUnprocessableEntity, s.t.Get("invalid operation")) return @@ -641,12 +645,16 @@ func (s *App) RunMaintenance(w http.ResponseWriter, r *http.Request) { return } + tables := make([]string, 0, len(req.Tables)) + for _, table := range req.Tables { + tables = append(tables, fmt.Sprintf("`%s`.`%s`", table.Database, table.Table)) + } escaped := strings.ReplaceAll(rootPassword, `'`, `'\''`) - cmd := fmt.Sprintf("MYSQL_PWD='%s' mysql -u root -e '%s TABLE `%s`.`%s`'", escaped, strings.ToUpper(req.Operation), req.Database, req.Table) + cmd := fmt.Sprintf("MYSQL_PWD='%s' mysql -u root -e '%s TABLE %s'", escaped, strings.ToUpper(req.Operation), strings.Join(tables, ", ")) task := new(biz.Task) - task.Key = fmt.Sprintf("mysql:maintenance:%s.%s", req.Database, req.Table) - task.Name = s.t.Get("Run %s on table %s.%s", req.Operation, req.Database, req.Table) + task.Key = "mysql:maintenance" + task.Name = s.t.Get("Run %s on %d tables", req.Operation, len(req.Tables)) task.Status = biz.TaskStatusWaiting task.Shell = cmd if err = s.taskRepo.Push(task); err != nil { diff --git a/internal/apps/mysql/request.go b/internal/apps/mysql/request.go index a9180bd7..5a2bb1dd 100644 --- a/internal/apps/mysql/request.go +++ b/internal/apps/mysql/request.go @@ -42,11 +42,16 @@ type ConfigTune struct { LongQueryTime string `form:"long_query_time" json:"long_query_time"` } +// MaintenanceTable 维护操作的目标表 +type MaintenanceTable struct { + Database string `form:"database" json:"database" validate:"required"` + Table string `form:"table" json:"table" validate:"required"` +} + // MaintenanceRun 表维护操作请求 type MaintenanceRun struct { - Database string `form:"database" json:"database" validate:"required"` - Table string `form:"table" json:"table" validate:"required"` - Operation string `form:"operation" json:"operation" validate:"required"` + Tables []MaintenanceTable `form:"tables" json:"tables"` + Operation string `form:"operation" json:"operation" validate:"required"` } // BinlogPurge binlog 清理请求 diff --git a/internal/apps/postgresql/app.go b/internal/apps/postgresql/app.go index 31f14bbe..eb870d9a 100644 --- a/internal/apps/postgresql/app.go +++ b/internal/apps/postgresql/app.go @@ -721,28 +721,38 @@ func (s *App) RunMaintenance(w http.ResponseWriter, r *http.Request) { return } - if !slices.Contains([]string{"vacuum", "analyze", "repack"}, req.Operation) { + if len(req.Tables) == 0 { + service.Error(w, http.StatusUnprocessableEntity, s.t.Get("no tables selected")) + return + } + if !slices.Contains([]string{"vacuum", "vacuum_full", "analyze", "repack"}, req.Operation) { service.Error(w, http.StatusUnprocessableEntity, s.t.Get("invalid operation")) return } var cmd string - switch req.Operation { - case "vacuum": - cmd = fmt.Sprintf(`su - postgres -c 'psql -d "%s" -c "VACUUM \"%s\".\"%s\""'`, req.Database, req.Schema, req.Table) - case "analyze": - cmd = fmt.Sprintf(`su - postgres -c 'psql -d "%s" -c "ANALYZE \"%s\".\"%s\""'`, req.Database, req.Schema, req.Table) - case "repack": + if req.Operation == "repack" { if !io.Exists(app.Root + "/server/postgresql/share/extension/pg_repack.control") { service.Error(w, http.StatusUnprocessableEntity, s.t.Get("pg_repack is not installed, please install it in the extensions tab first")) return } - cmd = fmt.Sprintf(`su - postgres -c 'pg_repack -d "%s" -t "%s.%s"'`, req.Database, req.Schema, req.Table) + var args strings.Builder + for _, table := range req.Tables { + fmt.Fprintf(&args, ` -t "%s.%s"`, table.Schema, table.Table) + } + cmd = fmt.Sprintf(`su - postgres -c 'pg_repack -d "%s"%s'`, req.Database, args.String()) + } else { + statement := map[string]string{"vacuum": "VACUUM", "vacuum_full": "VACUUM FULL", "analyze": "ANALYZE"}[req.Operation] + var args strings.Builder + for _, table := range req.Tables { + fmt.Fprintf(&args, ` -c "%s \"%s\".\"%s\""`, statement, table.Schema, table.Table) + } + cmd = fmt.Sprintf(`su - postgres -c 'psql -d "%s"%s'`, req.Database, args.String()) } task := new(biz.Task) - task.Key = fmt.Sprintf("postgresql:maintenance:%s:%s.%s", req.Database, req.Schema, req.Table) - task.Name = s.t.Get("Run %s on table %s.%s of database %s", req.Operation, req.Schema, req.Table, req.Database) + task.Key = "postgresql:maintenance:" + req.Database + task.Name = s.t.Get("Run %s on %d tables in database %s", req.Operation, len(req.Tables), req.Database) task.Status = biz.TaskStatusWaiting task.Shell = cmd if err = s.taskRepo.Push(task); err != nil { diff --git a/internal/apps/postgresql/request.go b/internal/apps/postgresql/request.go index 73b11fb1..e7a8b173 100644 --- a/internal/apps/postgresql/request.go +++ b/internal/apps/postgresql/request.go @@ -87,12 +87,17 @@ type Bloat struct { Items []BloatItem `json:"items"` } +// MaintenanceTable 维护操作的目标表 +type MaintenanceTable struct { + Schema string `form:"schema" json:"schema" validate:"required"` + Table string `form:"table" json:"table" validate:"required"` +} + // MaintenanceRun 表维护操作请求 type MaintenanceRun struct { - Database string `form:"database" json:"database" validate:"required"` - Schema string `form:"schema" json:"schema" validate:"required"` - Table string `form:"table" json:"table" validate:"required"` - Operation string `form:"operation" json:"operation" validate:"required"` + Database string `form:"database" json:"database" validate:"required"` + Tables []MaintenanceTable `form:"tables" json:"tables"` + Operation string `form:"operation" json:"operation" validate:"required"` } // ReplicationSlot 复制槽信息 diff --git a/web/src/views/apps/mysql/MysqlMaintenanceView.vue b/web/src/views/apps/mysql/MysqlMaintenanceView.vue index cfc1a18c..ec4d6c8e 100644 --- a/web/src/views/apps/mysql/MysqlMaintenanceView.vue +++ b/web/src/views/apps/mysql/MysqlMaintenanceView.vue @@ -12,9 +12,15 @@ const props = defineProps<{ const { $gettext } = useGettext() const { confirmDelete, confirmAction } = useConfirm() -const { data: tables, send: refreshTables } = useRequest(props.api.tables, { +const { data: tables, send: sendTables } = useRequest(props.api.tables, { initialData: [], }) +const checkedTables = ref([]) +const refreshTables = () => { + checkedTables.value = [] + sendTables() +} +const tableRowKey = (row: any) => `${row.database}|${row.table}` const { data: binlog, send: refreshBinlogs } = useRequest(props.api.binlogs, { initialData: { enabled: true, total_size: '-', items: [] }, }) @@ -22,16 +28,23 @@ const { data: replication, send: refreshReplication } = useRequest(props.api.rep initialData: { enabled: false }, }) -const handleMaintenance = async (row: any, operation: string) => { +const handleMaintenance = async ( + batch: { database: string; table: string }[], + operation: string, +) => { + const target = + batch.length === 1 + ? `${batch[0]!.database}.${batch[0]!.table}` + : $gettext('%{ count } selected tables', { count: String(batch.length) }) const content = operation === 'optimize' ? $gettext( 'OPTIMIZE will rebuild the InnoDB table, which may take a long time for large tables. Are you sure you want to run it on %{ table }?', - { table: `${row.database}.${row.table}` }, + { table: target }, ) : $gettext('Are you sure you want to run %{ op } on %{ table }?', { op: operation.toUpperCase(), - table: `${row.database}.${row.table}`, + table: target, }) const ok = await confirmAction({ type: 'warning', @@ -41,8 +54,7 @@ const handleMaintenance = async (row: any, operation: string) => { if (!ok) return useRequest( props.api.runMaintenance({ - database: row.database, - table: row.table, + tables: batch, operation, }), ).onSuccess(() => { @@ -50,7 +62,16 @@ const handleMaintenance = async (row: any, operation: string) => { }) } +const handleBatchMaintenance = (operation: string) => { + const batch = checkedTables.value.map((key) => { + const [database, table] = key.split('|') + return { database: database!, table: table! } + }) + handleMaintenance(batch, operation) +} + const tableColumns: any = [ + { type: 'selection' }, { title: $gettext('Database'), key: 'database', width: 130, ellipsis: { tooltip: true } }, { title: $gettext('Table'), key: 'table', minWidth: 150, ellipsis: { tooltip: true } }, { title: $gettext('Engine'), key: 'engine', width: 100 }, @@ -71,16 +92,17 @@ const tableColumns: any = [ key: 'actions', width: 230, render(row: any) { + const rowTables = [{ database: row.database, table: row.table }] return h(NSpace, { size: 'small', wrap: false }, { default: () => [ h( NButton, - { size: 'small', type: 'warning', onClick: () => handleMaintenance(row, 'optimize') }, + { size: 'small', type: 'warning', onClick: () => handleMaintenance(rowTables, 'optimize') }, { default: () => 'OPTIMIZE' }, ), h( NButton, - { size: 'small', onClick: () => handleMaintenance(row, 'analyze') }, + { size: 'small', onClick: () => handleMaintenance(rowTables, 'analyze') }, { default: () => 'ANALYZE' }, ), ], @@ -143,12 +165,24 @@ const replicationRunning = (value: string) => { {{ $gettext('Refresh') }} + + OPTIMIZE + + + ANALYZE + diff --git a/web/src/views/apps/postgresql/PostgresqlMaintenanceView.vue b/web/src/views/apps/postgresql/PostgresqlMaintenanceView.vue index acfeebd5..b55e94a5 100644 --- a/web/src/views/apps/postgresql/PostgresqlMaintenanceView.vue +++ b/web/src/views/apps/postgresql/PostgresqlMaintenanceView.vue @@ -35,9 +35,12 @@ const { data: bloat, send: sendBloat } = useRequest( initialData: { repack_installed: false, items: [] }, }, ) +const checkedTables = ref([]) const refreshBloat = () => { + checkedTables.value = [] if (selectedDatabase.value) sendBloat() } +const bloatRowKey = (row: any) => `${row.schema}|${row.table}` const { data: wal, send: refreshWal } = useRequest(postgresql.wal, { initialData: { @@ -48,21 +51,31 @@ const { data: wal, send: refreshWal } = useRequest(postgresql.wal, { }, }) -const handleMaintenance = async (row: any, operation: string) => { +const handleMaintenance = async (tables: { schema: string; table: string }[], operation: string) => { + const target = + tables.length === 1 + ? `${tables[0]!.schema}.${tables[0]!.table}` + : $gettext('%{ count } selected tables', { count: String(tables.length) }) + const content = + operation === 'vacuum_full' + ? $gettext( + 'VACUUM FULL rewrites the entire table and holds an exclusive lock, blocking all reads and writes until it finishes. It may take a long time for large tables. Are you sure you want to run it on %{ table }?', + { table: target }, + ) + : $gettext('Are you sure you want to run %{ op } on %{ table }?', { + op: operation.replace('_', ' ').toUpperCase(), + table: target, + }) const ok = await confirmAction({ type: 'warning', title: $gettext('Confirm Operation'), - content: $gettext('Are you sure you want to run %{ op } on %{ table }?', { - op: operation.toUpperCase(), - table: `${row.schema}.${row.table}`, - }), + content, }) if (!ok) return useRequest( postgresql.runMaintenance({ database: selectedDatabase.value, - schema: row.schema, - table: row.table, + tables, operation, }), ).onSuccess(() => { @@ -70,7 +83,16 @@ const handleMaintenance = async (row: any, operation: string) => { }) } +const handleBatchMaintenance = (operation: string) => { + const tables = checkedTables.value.map((key) => { + const [schema, table] = key.split('|') + return { schema: schema!, table: table! } + }) + handleMaintenance(tables, operation) +} + const bloatColumns: any = [ + { type: 'selection' }, { title: $gettext('Schema'), key: 'schema', width: 110, ellipsis: { tooltip: true } }, { title: $gettext('Table'), key: 'table', minWidth: 150, ellipsis: { tooltip: true } }, { title: $gettext('Size'), key: 'size', width: 100 }, @@ -100,17 +122,23 @@ const bloatColumns: any = [ { title: $gettext('Actions'), key: 'actions', - width: 310, + width: 430, render(row: any) { + const rowTables = [{ schema: row.schema, table: row.table }] const buttons = [ h( NButton, - { size: 'small', type: 'info', onClick: () => handleMaintenance(row, 'vacuum') }, + { size: 'small', type: 'info', onClick: () => handleMaintenance(rowTables, 'vacuum') }, { default: () => 'VACUUM' }, ), h( NButton, - { size: 'small', onClick: () => handleMaintenance(row, 'analyze') }, + { size: 'small', type: 'error', onClick: () => handleMaintenance(rowTables, 'vacuum_full') }, + { default: () => 'VACUUM FULL' }, + ), + h( + NButton, + { size: 'small', onClick: () => handleMaintenance(rowTables, 'analyze') }, { default: () => 'ANALYZE' }, ), ] @@ -118,7 +146,7 @@ const bloatColumns: any = [ buttons.push( h( NButton, - { size: 'small', type: 'warning', onClick: () => handleMaintenance(row, 'repack') }, + { size: 'small', type: 'warning', onClick: () => handleMaintenance(rowTables, 'repack') }, { default: () => 'REPACK' }, ), ) @@ -214,12 +242,39 @@ const handleDropSlot = (name: string) => { {{ $gettext('Refresh') }} + + VACUUM + + + VACUUM FULL + + + ANALYZE + + + REPACK +