mirror of
https://github.com/tnb-labs/panel.git
synced 2026-08-29 02:10:58 +08:00
feat: 表维护添加 VACUUM FULL 并支持多表批量操作
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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 清理请求
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user