feat: 表维护添加 VACUUM FULL 并支持多表批量操作

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
耗子
2026-08-20 00:08:50 +08:00
parent a364076e73
commit bf9eb06955
6 changed files with 158 additions and 41 deletions
+11 -3
View File
@@ -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 {
+8 -3
View File
@@ -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 清理请求
+20 -10
View File
@@ -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 {
+9 -4
View File
@@ -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 复制槽信息
@@ -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<string[]>([])
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) => {
<n-button type="primary" @click="() => refreshTables()">
{{ $gettext('Refresh') }}
</n-button>
<n-button
type="warning"
:disabled="!checkedTables.length"
@click="handleBatchMaintenance('optimize')"
>
OPTIMIZE
</n-button>
<n-button :disabled="!checkedTables.length" @click="handleBatchMaintenance('analyze')">
ANALYZE
</n-button>
</n-flex>
<n-data-table
v-model:checked-row-keys="checkedTables"
striped
:columns="tableColumns"
:data="tables"
:scroll-x="990"
:row-key="tableRowKey"
:scroll-x="1030"
max-height="60vh"
/>
</n-flex>
@@ -35,9 +35,12 @@ const { data: bloat, send: sendBloat } = useRequest(
initialData: { repack_installed: false, items: [] },
},
)
const checkedTables = ref<string[]>([])
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) => {
<n-button type="primary" @click="refreshBloat">
{{ $gettext('Refresh') }}
</n-button>
<n-button
type="info"
:disabled="!checkedTables.length"
@click="handleBatchMaintenance('vacuum')"
>
VACUUM
</n-button>
<n-button
type="error"
:disabled="!checkedTables.length"
@click="handleBatchMaintenance('vacuum_full')"
>
VACUUM FULL
</n-button>
<n-button :disabled="!checkedTables.length" @click="handleBatchMaintenance('analyze')">
ANALYZE
</n-button>
<n-button
v-if="bloat.repack_installed"
type="warning"
:disabled="!checkedTables.length"
@click="handleBatchMaintenance('repack')"
>
REPACK
</n-button>
</n-flex>
<n-data-table
v-model:checked-row-keys="checkedTables"
striped
:columns="bloatColumns"
:data="bloat.items"
:scroll-x="1370"
:row-key="bloatRowKey"
:scroll-x="1530"
max-height="60vh"
/>
</n-flex>