feat(delivery): restore active MySQL tasks
This commit is contained in:
@@ -16,15 +16,32 @@ export interface CreateMySQLDeliveryPayload {
|
|||||||
namespace: string
|
namespace: string
|
||||||
instance_name: string
|
instance_name: string
|
||||||
mysql_version: string
|
mysql_version: string
|
||||||
cpu_milli: number
|
topology: string
|
||||||
memory_mi: number
|
mysql_port?: number
|
||||||
storage_gi: number
|
data_disk: string
|
||||||
|
cpu_cores: number
|
||||||
|
memory_gb: number
|
||||||
|
storage_gb: number
|
||||||
|
param_template: string
|
||||||
|
timezone: string
|
||||||
|
lower_case_table_names: number
|
||||||
|
character_set: string
|
||||||
|
collation: string
|
||||||
|
max_connections: string | number
|
||||||
|
innodb_redo_log_capacity: string
|
||||||
|
innodb_flush_log_at_trx_commit: number
|
||||||
|
sync_binlog: number
|
||||||
|
innodb_io_capacity: number
|
||||||
|
long_query_time: number
|
||||||
|
binlog_expire_logs_seconds: number
|
||||||
|
max_binlog_size: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DeliveryTask {
|
export interface DeliveryTask {
|
||||||
id: string
|
id: string
|
||||||
business_line_id: number
|
business_line_id: number
|
||||||
requested_by: number
|
requested_by: number
|
||||||
|
component: string
|
||||||
target_type: string
|
target_type: string
|
||||||
target_id: number
|
target_id: number
|
||||||
namespace: string
|
namespace: string
|
||||||
@@ -45,6 +62,8 @@ export interface TaskEvent {
|
|||||||
task_id: string
|
task_id: string
|
||||||
from_state: string
|
from_state: string
|
||||||
to_state: string
|
to_state: string
|
||||||
|
stage?: string
|
||||||
|
event_status?: string
|
||||||
message: string
|
message: string
|
||||||
created_at: string
|
created_at: string
|
||||||
}
|
}
|
||||||
@@ -66,6 +85,16 @@ export const deliveryApi = {
|
|||||||
return data.task
|
return data.task
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async listTasks(params: { businessLineId?: number; component?: string; active?: boolean } = {}): Promise<DeliveryTask[]> {
|
||||||
|
const search = new URLSearchParams()
|
||||||
|
if (params.businessLineId) search.set('business_line_id', String(params.businessLineId))
|
||||||
|
if (params.component) search.set('component', params.component)
|
||||||
|
if (params.active) search.set('active', 'true')
|
||||||
|
const query = search.toString()
|
||||||
|
const data = await authRequest(`/auth/api/v1/delivery/tasks${query ? `?${query}` : ''}`)
|
||||||
|
return Array.isArray(data.items) ? data.items : []
|
||||||
|
},
|
||||||
|
|
||||||
async getTask(taskId: string): Promise<{ task: DeliveryTask; events: TaskEvent[] }> {
|
async getTask(taskId: string): Promise<{ task: DeliveryTask; events: TaskEvent[] }> {
|
||||||
const data = await authRequest(`/auth/api/v1/delivery/tasks/${encodeURIComponent(taskId)}`)
|
const data = await authRequest(`/auth/api/v1/delivery/tasks/${encodeURIComponent(taskId)}`)
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -55,10 +55,12 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="head-actions">
|
<div class="head-actions">
|
||||||
<span class="tag" :class="deliveryStateClass">{{ deliveryStateText }}</span>
|
<span class="tag" :class="deliveryStateClass">{{ deliveryStateText }}</span>
|
||||||
|
<el-button v-if="deliveryDone || deliveryFailed" type="primary" :icon="Promotion" @click="startNewTask">新建任务</el-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="panel-body padded">
|
<div class="panel-body padded">
|
||||||
|
<div v-if="taskRestoring" class="restore-state">正在检查未完成交付任务...</div>
|
||||||
<div class="flow-tabs">
|
<div class="flow-tabs">
|
||||||
<button
|
<button
|
||||||
v-for="tab in flowTabs"
|
v-for="tab in flowTabs"
|
||||||
@@ -119,19 +121,32 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="config-row">
|
<div class="config-row">
|
||||||
<div class="config-label">安装路径</div>
|
<div class="config-label">数据盘</div>
|
||||||
<div class="subform-grid">
|
<div class="subform-grid">
|
||||||
<label class="form-field">
|
<label class="form-field">
|
||||||
程序目录
|
挂载点
|
||||||
<el-input v-model="deliveryForm.installPath" />
|
<el-select v-model="deliveryForm.dataDisk">
|
||||||
|
<el-option label="/data" value="/data" />
|
||||||
|
<el-option label="/disk1" value="/disk1" />
|
||||||
|
<el-option label="/mnt/vol-1" value="/mnt/vol-1" />
|
||||||
|
</el-select>
|
||||||
</label>
|
</label>
|
||||||
<label class="form-field">
|
<label class="form-field">
|
||||||
数据目录
|
参数模板
|
||||||
<el-input v-model="deliveryForm.dataPath" />
|
<el-select v-model="deliveryForm.paramTemplate">
|
||||||
|
<el-option label="default" value="default" />
|
||||||
|
<el-option label="high_performance" value="high_performance" />
|
||||||
|
<el-option label="high_safety" value="high_safety" />
|
||||||
|
</el-select>
|
||||||
</label>
|
</label>
|
||||||
<label class="form-field">
|
<label class="form-field">
|
||||||
日志目录
|
时区
|
||||||
<el-input v-model="deliveryForm.logPath" />
|
<el-select v-model="deliveryForm.timezone">
|
||||||
|
<el-option label="+08:00" value="+08:00" />
|
||||||
|
<el-option label="+00:00" value="+00:00" />
|
||||||
|
<el-option label="SYSTEM" value="SYSTEM" />
|
||||||
|
<el-option label="Asia/Shanghai" value="Asia/Shanghai" />
|
||||||
|
</el-select>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -170,11 +185,11 @@
|
|||||||
<div class="subform-grid">
|
<div class="subform-grid">
|
||||||
<label class="form-field">
|
<label class="form-field">
|
||||||
监听端口
|
监听端口
|
||||||
<el-input-number v-model="deliveryForm.port" :min="1024" :max="65535" controls-position="right" />
|
<el-input-number v-model="deliveryForm.port" :min="13306" :max="13999" controls-position="right" />
|
||||||
</label>
|
</label>
|
||||||
<label class="form-field">
|
<label class="form-field">
|
||||||
字符集 / 协议
|
字符集 / 协议
|
||||||
<el-select v-model="deliveryForm.charset">
|
<el-select v-model="deliveryForm.characterSet" @change="deliveryForm.collation = defaultCollation(deliveryForm.characterSet)">
|
||||||
<el-option v-for="item in activeService.charsets" :key="item" :label="item" :value="item" />
|
<el-option v-for="item in activeService.charsets" :key="item" :label="item" :value="item" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</label>
|
</label>
|
||||||
@@ -229,6 +244,7 @@
|
|||||||
<p>{{ deploymentId || '创建任务后生成 task_id' }} · {{ activeService.runner }} · {{ activeService.template }}</p>
|
<p>{{ deploymentId || '创建任务后生成 task_id' }} · {{ activeService.runner }} · {{ activeService.template }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="head-actions">
|
<div class="head-actions">
|
||||||
|
<el-button :disabled="!deploymentId" :loading="taskRefreshing" :icon="Refresh" @click="refreshTaskSnapshot">刷新状态</el-button>
|
||||||
<el-button :disabled="!canCancelDeployment" :loading="canceling" @click="cancelDeployment">取消任务</el-button>
|
<el-button :disabled="!canCancelDeployment" :loading="canceling" @click="cancelDeployment">取消任务</el-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -316,11 +332,11 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
|
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { Back, CircleCheck, Promotion, Refresh } from '@element-plus/icons-vue'
|
import { Back, CircleCheck, Promotion, Refresh } from '@element-plus/icons-vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { deliveryApi, type DeliveryTarget, type TaskEvent } from '@/api/delivery'
|
import { deliveryApi, type DeliveryTarget, type DeliveryTask, type TaskEvent } from '@/api/delivery'
|
||||||
import { useBusinessLineStore } from '@/stores/businessLine'
|
import { useBusinessLineStore } from '@/stores/businessLine'
|
||||||
import { useBusinessLineMockProfile } from '@/utils/businessLineMock'
|
import { useBusinessLineMockProfile } from '@/utils/businessLineMock'
|
||||||
|
|
||||||
@@ -392,9 +408,9 @@ const basicServices = ref<Service[]>([
|
|||||||
modes: [
|
modes: [
|
||||||
{ value: 'single', label: '单实例' },
|
{ value: 'single', label: '单实例' },
|
||||||
],
|
],
|
||||||
specs: ['1C / 1G', '2C / 2G', '4C / 4G'],
|
specs: ['1C / 2G', '2C / 4G', '4C / 8G', '8C / 16G', '16C / 32G'],
|
||||||
disks: ['10 GB', '20 GB', '50 GB', '100 GB'],
|
disks: ['20 GB', '50 GB', '100 GB', '500 GB', '1000 GB'],
|
||||||
charsets: ['utf8mb4', 'utf8'],
|
charsets: ['utf8mb4', 'utf8', 'gbk', 'latin1'],
|
||||||
defaultPort: 3306,
|
defaultPort: 3306,
|
||||||
defaultPaths: { install: '/opt/mysql', data: '/data/mysql', log: '/data/mysql-log' },
|
defaultPaths: { install: '/opt/mysql', data: '/data/mysql', log: '/data/mysql-log' },
|
||||||
registerTo: 'CloudDM / CMDB / Prometheus',
|
registerTo: 'CloudDM / CMDB / Prometheus',
|
||||||
@@ -533,7 +549,8 @@ const deliveredPort = ref<number>()
|
|||||||
const deliveryTargets = ref<DeliveryTarget[]>([])
|
const deliveryTargets = ref<DeliveryTarget[]>([])
|
||||||
const selectedTargetId = ref<number>()
|
const selectedTargetId = ref<number>()
|
||||||
const targetsLoading = ref(false)
|
const targetsLoading = ref(false)
|
||||||
const pollTimer = ref<number | undefined>()
|
const taskRefreshing = ref(false)
|
||||||
|
const taskRestoring = ref(false)
|
||||||
const seenEventIds = ref(new Set<number>())
|
const seenEventIds = ref(new Set<number>())
|
||||||
const deliveryLog = ref('[ready] 等待创建交付任务...')
|
const deliveryLog = ref('[ready] 等待创建交付任务...')
|
||||||
|
|
||||||
@@ -541,14 +558,24 @@ const deliveryForm = reactive({
|
|||||||
instanceName: `mysql-${currentName.value}-billing-02`,
|
instanceName: `mysql-${currentName.value}-billing-02`,
|
||||||
version: 'MySQL 8.0',
|
version: 'MySQL 8.0',
|
||||||
mode: 'single',
|
mode: 'single',
|
||||||
spec: '2C / 2G',
|
spec: '2C / 4G',
|
||||||
disk: '20 GB',
|
disk: '50 GB',
|
||||||
port: 3306,
|
port: undefined as number | undefined,
|
||||||
charset: 'utf8mb4',
|
dataDisk: '/data',
|
||||||
|
paramTemplate: 'default',
|
||||||
|
timezone: '+08:00',
|
||||||
|
lowerCaseTableNames: 1,
|
||||||
|
characterSet: 'utf8mb4',
|
||||||
|
collation: 'utf8mb4_general_ci',
|
||||||
|
maxConnections: 'auto' as string | number,
|
||||||
|
innodbRedoLogCapacity: 'auto',
|
||||||
|
innodbFlushLogAtTrxCommit: 1,
|
||||||
|
syncBinlog: 1,
|
||||||
|
innodbIoCapacity: 2000,
|
||||||
|
longQueryTime: 1,
|
||||||
|
binlogExpireLogsSeconds: 604800,
|
||||||
|
maxBinlogSize: '256M',
|
||||||
pool: 'auto',
|
pool: 'auto',
|
||||||
installPath: '/opt/mysql',
|
|
||||||
dataPath: '/data/mysql',
|
|
||||||
logPath: '/data/mysql-log',
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const steps = ref<DeliveryStep[]>([])
|
const steps = ref<DeliveryStep[]>([])
|
||||||
@@ -588,12 +615,12 @@ const runnerPreview = computed(() => {
|
|||||||
`instance: ${deliveryForm.instanceName}`,
|
`instance: ${deliveryForm.instanceName}`,
|
||||||
`topology: ${currentModeLabel.value}`,
|
`topology: ${currentModeLabel.value}`,
|
||||||
`node_selector: business-line=${currentName.value}`,
|
`node_selector: business-line=${currentName.value}`,
|
||||||
`install_path: ${deliveryForm.installPath}`,
|
`data_disk: ${deliveryForm.dataDisk}`,
|
||||||
`data_path: ${deliveryForm.dataPath}`,
|
`param_template: ${deliveryForm.paramTemplate}`,
|
||||||
`register_to: ${activeService.value?.registerTo || '-'}`,
|
`register_to: ${activeService.value?.registerTo || '-'}`,
|
||||||
].join('\n')
|
].join('\n')
|
||||||
})
|
})
|
||||||
const resultAddress = computed(() => `${deliveredHost.value || topologyNodes.value[0]?.ip || '10.24.18.21'}:${deliveredPort.value || deliveryForm.port}`)
|
const resultAddress = computed(() => `${deliveredHost.value || topologyNodes.value[0]?.ip || '10.24.18.21'}:${deliveredPort.value || deliveryForm.port || 'auto'}`)
|
||||||
const resultTitle = computed(() => {
|
const resultTitle = computed(() => {
|
||||||
if (deliveryFailed.value) return '交付失败,已自动回退'
|
if (deliveryFailed.value) return '交付失败,已自动回退'
|
||||||
return activeServiceKey.value === 'mysql' ? 'MySQL 实例已交付' : 'OpenResty 集群已交付'
|
return activeServiceKey.value === 'mysql' ? 'MySQL 实例已交付' : 'OpenResty 集群已交付'
|
||||||
@@ -630,12 +657,22 @@ watch(
|
|||||||
() => {
|
() => {
|
||||||
activeServiceKey.value = componentParam()
|
activeServiceKey.value = componentParam()
|
||||||
activeView.value = 'config'
|
activeView.value = 'config'
|
||||||
|
void restoreActiveTask()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => businessLineStore.current?.id,
|
||||||
|
() => {
|
||||||
|
void restoreActiveTask()
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
hydrateServiceDefaults()
|
hydrateServiceDefaults()
|
||||||
onMounted(loadDeliveryTargets)
|
onMounted(async () => {
|
||||||
onBeforeUnmount(stopPolling)
|
await loadDeliveryTargets()
|
||||||
|
await restoreActiveTask()
|
||||||
|
})
|
||||||
|
|
||||||
function handleCardClick(service: Service) {
|
function handleCardClick(service: Service) {
|
||||||
if (service.disabled) {
|
if (service.disabled) {
|
||||||
@@ -663,11 +700,21 @@ function hydrateServiceDefaults() {
|
|||||||
deliveryForm.mode = service.modes[1]?.value || service.modes[0]?.value || 'single'
|
deliveryForm.mode = service.modes[1]?.value || service.modes[0]?.value || 'single'
|
||||||
deliveryForm.spec = service.specs[1] || service.specs[0] || ''
|
deliveryForm.spec = service.specs[1] || service.specs[0] || ''
|
||||||
deliveryForm.disk = service.disks[1] || service.disks[0] || ''
|
deliveryForm.disk = service.disks[1] || service.disks[0] || ''
|
||||||
deliveryForm.port = service.defaultPort
|
deliveryForm.port = undefined
|
||||||
deliveryForm.charset = service.charsets[0] || ''
|
deliveryForm.dataDisk = '/data'
|
||||||
deliveryForm.installPath = service.defaultPaths.install
|
deliveryForm.paramTemplate = 'default'
|
||||||
deliveryForm.dataPath = service.defaultPaths.data
|
deliveryForm.timezone = '+08:00'
|
||||||
deliveryForm.logPath = service.defaultPaths.log
|
deliveryForm.lowerCaseTableNames = 1
|
||||||
|
deliveryForm.characterSet = service.charsets[0] || 'utf8mb4'
|
||||||
|
deliveryForm.collation = defaultCollation(deliveryForm.characterSet)
|
||||||
|
deliveryForm.maxConnections = 'auto'
|
||||||
|
deliveryForm.innodbRedoLogCapacity = 'auto'
|
||||||
|
deliveryForm.innodbFlushLogAtTrxCommit = 1
|
||||||
|
deliveryForm.syncBinlog = 1
|
||||||
|
deliveryForm.innodbIoCapacity = 2000
|
||||||
|
deliveryForm.longQueryTime = 1
|
||||||
|
deliveryForm.binlogExpireLogsSeconds = 604800
|
||||||
|
deliveryForm.maxBinlogSize = '256M'
|
||||||
resetExecutionState()
|
resetExecutionState()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -677,8 +724,12 @@ function resetWorkbench() {
|
|||||||
ElMessage.success('交付工作台已重置')
|
ElMessage.success('交付工作台已重置')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function startNewTask() {
|
||||||
|
hydrateServiceDefaults()
|
||||||
|
activeView.value = 'config'
|
||||||
|
}
|
||||||
|
|
||||||
function resetExecutionState() {
|
function resetExecutionState() {
|
||||||
stopPolling()
|
|
||||||
precheckPassed.value = false
|
precheckPassed.value = false
|
||||||
running.value = false
|
running.value = false
|
||||||
deliveryDone.value = false
|
deliveryDone.value = false
|
||||||
@@ -688,6 +739,7 @@ function resetExecutionState() {
|
|||||||
deploymentId.value = ''
|
deploymentId.value = ''
|
||||||
deliveredHost.value = ''
|
deliveredHost.value = ''
|
||||||
deliveredPort.value = undefined
|
deliveredPort.value = undefined
|
||||||
|
seenEventIds.value = new Set()
|
||||||
deliveryLog.value = '[ready] 等待创建交付任务...'
|
deliveryLog.value = '[ready] 等待创建交付任务...'
|
||||||
steps.value = defaultSteps().map((step) => ({ ...step, state: 'pending' }))
|
steps.value = defaultSteps().map((step) => ({ ...step, state: 'pending' }))
|
||||||
}
|
}
|
||||||
@@ -713,6 +765,11 @@ function defaultSteps(): DeliveryStep[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function precheck() {
|
function precheck() {
|
||||||
|
const validationError = validateDeliveryForm()
|
||||||
|
if (validationError) {
|
||||||
|
ElMessage.warning(validationError)
|
||||||
|
return
|
||||||
|
}
|
||||||
precheckPassed.value = true
|
precheckPassed.value = true
|
||||||
deliveryFailed.value = false
|
deliveryFailed.value = false
|
||||||
deliveryLog.value = [
|
deliveryLog.value = [
|
||||||
@@ -743,6 +800,11 @@ async function createTask() {
|
|||||||
ElMessage.warning('请先选择部署目标')
|
ElMessage.warning('请先选择部署目标')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
const validationError = validateDeliveryForm()
|
||||||
|
if (validationError) {
|
||||||
|
ElMessage.warning(validationError)
|
||||||
|
return
|
||||||
|
}
|
||||||
running.value = true
|
running.value = true
|
||||||
deliveryDone.value = false
|
deliveryDone.value = false
|
||||||
deliveryFailed.value = false
|
deliveryFailed.value = false
|
||||||
@@ -753,7 +815,7 @@ async function createTask() {
|
|||||||
deploymentId.value = task.id
|
deploymentId.value = task.id
|
||||||
deliveryLog.value += `\n[task] ${task.id} created by ${currentName.value}`
|
deliveryLog.value += `\n[task] ${task.id} created by ${currentName.value}`
|
||||||
applyDeliveryStatus(task.status, '')
|
applyDeliveryStatus(task.status, '')
|
||||||
startTaskPolling(task.id)
|
await refreshTaskSnapshot()
|
||||||
ElMessage.success('交付任务已创建')
|
ElMessage.success('交付任务已创建')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
running.value = false
|
running.value = false
|
||||||
@@ -763,6 +825,114 @@ async function createTask() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function validateDeliveryForm() {
|
||||||
|
const instanceName = normalizeDNSLabel(deliveryForm.instanceName)
|
||||||
|
if (instanceName !== deliveryForm.instanceName || !/^[a-z0-9](?:[-a-z0-9]*[a-z0-9])?$/.test(deliveryForm.instanceName)) {
|
||||||
|
return '实例名称只能使用小写字母、数字和中划线,且不能以中划线开头或结尾'
|
||||||
|
}
|
||||||
|
if (deliveryForm.mode !== 'single') {
|
||||||
|
return '当前 MySQL 交付只支持单实例架构'
|
||||||
|
}
|
||||||
|
if (deliveryForm.port && (deliveryForm.port < 13306 || deliveryForm.port > 13999)) {
|
||||||
|
return '监听端口必须为空自动分配,或填写 13306-13999'
|
||||||
|
}
|
||||||
|
const resources = parseSpec(deliveryForm.spec)
|
||||||
|
if (!oneOf(resources.cpuCores, [1, 2, 4, 8, 16])) {
|
||||||
|
return 'CPU 规格必须是 1C、2C、4C、8C 或 16C'
|
||||||
|
}
|
||||||
|
if (!oneOf(resources.memoryGb, [2, 4, 8, 16, 32, 64])) {
|
||||||
|
return '内存规格必须是 2G、4G、8G、16G、32G 或 64G'
|
||||||
|
}
|
||||||
|
const storageGb = parseStorageGb(deliveryForm.disk)
|
||||||
|
if (storageGb < 20 || storageGb > 2000) {
|
||||||
|
return '数据盘容量必须在 20GB 到 2000GB 之间'
|
||||||
|
}
|
||||||
|
if (!['/data', '/disk1', '/mnt/vol-1'].includes(deliveryForm.dataDisk)) {
|
||||||
|
return '数据盘挂载点不在支持范围内'
|
||||||
|
}
|
||||||
|
if (!['default', 'high_performance', 'high_safety'].includes(deliveryForm.paramTemplate)) {
|
||||||
|
return '参数模板不在支持范围内'
|
||||||
|
}
|
||||||
|
if (!['SYSTEM', '+08:00', '+00:00', 'Asia/Shanghai'].includes(deliveryForm.timezone)) {
|
||||||
|
return '时区不在支持范围内'
|
||||||
|
}
|
||||||
|
if (!oneOf(deliveryForm.lowerCaseTableNames, [0, 1])) {
|
||||||
|
return 'lower_case_table_names 必须是 0 或 1'
|
||||||
|
}
|
||||||
|
if (!['utf8mb4', 'utf8', 'gbk', 'latin1'].includes(deliveryForm.characterSet)) {
|
||||||
|
return '字符集不在支持范围内'
|
||||||
|
}
|
||||||
|
if (!validCollation(deliveryForm.characterSet, deliveryForm.collation)) {
|
||||||
|
return '排序规则与字符集不匹配'
|
||||||
|
}
|
||||||
|
if (!validMaxConnections(deliveryForm.maxConnections)) {
|
||||||
|
return '最大连接数必须为 auto 或支持的固定档位'
|
||||||
|
}
|
||||||
|
if (!['auto', '128M', '256M', '512M', '1G'].includes(deliveryForm.innodbRedoLogCapacity)) {
|
||||||
|
return 'Redo Log 容量不在支持范围内'
|
||||||
|
}
|
||||||
|
if (!oneOf(deliveryForm.innodbFlushLogAtTrxCommit, [0, 1, 2])) {
|
||||||
|
return 'innodb_flush_log_at_trx_commit 必须是 0、1 或 2'
|
||||||
|
}
|
||||||
|
if (!oneOf(deliveryForm.syncBinlog, [0, 1])) {
|
||||||
|
return 'sync_binlog 必须是 0 或 1'
|
||||||
|
}
|
||||||
|
if (!oneOf(deliveryForm.innodbIoCapacity, [200, 2000, 5000])) {
|
||||||
|
return 'innodb_io_capacity 必须是 200、2000 或 5000'
|
||||||
|
}
|
||||||
|
if (!oneOf(deliveryForm.longQueryTime, [0.5, 1, 2, 5, 10])) {
|
||||||
|
return '慢查询阈值必须是 0.5、1、2、5 或 10 秒'
|
||||||
|
}
|
||||||
|
if (!oneOf(deliveryForm.binlogExpireLogsSeconds, [86400, 259200, 604800, 1209600])) {
|
||||||
|
return 'Binlog 保留时间不在支持范围内'
|
||||||
|
}
|
||||||
|
if (!['128M', '256M', '512M', '1G'].includes(deliveryForm.maxBinlogSize)) {
|
||||||
|
return 'Binlog 文件大小不在支持范围内'
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
async function restoreActiveTask() {
|
||||||
|
const businessLineId = businessLineStore.current?.id
|
||||||
|
if (!businessLineId || !activeService.value || activeService.value.key !== 'mysql') return
|
||||||
|
taskRestoring.value = true
|
||||||
|
try {
|
||||||
|
const tasks = await deliveryApi.listTasks({ businessLineId, component: activeService.value.key, active: true })
|
||||||
|
const task = tasks[0]
|
||||||
|
if (!task) return
|
||||||
|
seenEventIds.value = new Set()
|
||||||
|
hydrateTaskSnapshot(task)
|
||||||
|
activeView.value = 'execution'
|
||||||
|
const detail = await deliveryApi.getTask(task.id)
|
||||||
|
hydrateTaskSnapshot(detail.task)
|
||||||
|
applyTaskEvents(detail.events)
|
||||||
|
applyDeliveryStatus(detail.task.status, detail.task.error_message || '')
|
||||||
|
if (isTerminalDeliveryStatus(detail.task.status)) {
|
||||||
|
activeView.value = 'result'
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '恢复交付任务失败')
|
||||||
|
} finally {
|
||||||
|
taskRestoring.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hydrateTaskSnapshot(task: DeliveryTask) {
|
||||||
|
deploymentId.value = task.id
|
||||||
|
deliveredHost.value = task.target_host_ip || deliveredHost.value
|
||||||
|
deliveredPort.value = task.mysql_port || deliveredPort.value
|
||||||
|
selectedTargetId.value = task.target_id || selectedTargetId.value
|
||||||
|
deliveryForm.instanceName = task.instance_name || deliveryForm.instanceName
|
||||||
|
if (task.status) {
|
||||||
|
running.value = !isTerminalDeliveryStatus(task.status)
|
||||||
|
deliveryDone.value = task.status === 'finished'
|
||||||
|
deliveryFailed.value = ['execution_failed', 'validation_failed', 'register_failed', 'canceled'].includes(task.status)
|
||||||
|
}
|
||||||
|
if (!deliveryLog.value || deliveryLog.value === '[ready] 等待创建交付任务...') {
|
||||||
|
deliveryLog.value = `[task] ${task.id} restored from ${task.status}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function cancelDeployment() {
|
async function cancelDeployment() {
|
||||||
if (!deploymentId.value) return
|
if (!deploymentId.value) return
|
||||||
canceling.value = true
|
canceling.value = true
|
||||||
@@ -778,43 +948,55 @@ async function cancelDeployment() {
|
|||||||
|
|
||||||
function mysqlDeliveryPayload(businessLineId: number) {
|
function mysqlDeliveryPayload(businessLineId: number) {
|
||||||
const resources = parseSpec(deliveryForm.spec)
|
const resources = parseSpec(deliveryForm.spec)
|
||||||
return {
|
const payload = {
|
||||||
business_line_id: businessLineId,
|
business_line_id: businessLineId,
|
||||||
target_id: selectedTargetId.value || 0,
|
target_id: selectedTargetId.value || 0,
|
||||||
namespace: normalizeDNSLabel(currentName.value),
|
namespace: normalizeDNSLabel(currentName.value),
|
||||||
instance_name: normalizeDNSLabel(deliveryForm.instanceName),
|
instance_name: normalizeDNSLabel(deliveryForm.instanceName),
|
||||||
mysql_version: mysqlVersionValue(deliveryForm.version),
|
mysql_version: mysqlVersionValue(deliveryForm.version),
|
||||||
cpu_milli: resources.cpuMilli,
|
topology: mysqlTopologyValue(deliveryForm.mode),
|
||||||
memory_mi: resources.memoryMi,
|
data_disk: deliveryForm.dataDisk,
|
||||||
storage_gi: parseStorageGi(deliveryForm.disk),
|
cpu_cores: resources.cpuCores,
|
||||||
|
memory_gb: resources.memoryGb,
|
||||||
|
storage_gb: parseStorageGb(deliveryForm.disk),
|
||||||
|
param_template: deliveryForm.paramTemplate,
|
||||||
|
timezone: deliveryForm.timezone,
|
||||||
|
lower_case_table_names: deliveryForm.lowerCaseTableNames,
|
||||||
|
character_set: deliveryForm.characterSet,
|
||||||
|
collation: deliveryForm.collation,
|
||||||
|
max_connections: deliveryForm.maxConnections,
|
||||||
|
innodb_redo_log_capacity: deliveryForm.innodbRedoLogCapacity,
|
||||||
|
innodb_flush_log_at_trx_commit: deliveryForm.innodbFlushLogAtTrxCommit,
|
||||||
|
sync_binlog: deliveryForm.syncBinlog,
|
||||||
|
innodb_io_capacity: deliveryForm.innodbIoCapacity,
|
||||||
|
long_query_time: deliveryForm.longQueryTime,
|
||||||
|
binlog_expire_logs_seconds: deliveryForm.binlogExpireLogsSeconds,
|
||||||
|
max_binlog_size: deliveryForm.maxBinlogSize,
|
||||||
|
}
|
||||||
|
if (deliveryForm.port) {
|
||||||
|
return { ...payload, mysql_port: Number(deliveryForm.port) }
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...payload,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function startTaskPolling(id: string) {
|
async function refreshTaskSnapshot() {
|
||||||
stopPolling()
|
if (!deploymentId.value) return
|
||||||
seenEventIds.value = new Set()
|
taskRefreshing.value = true
|
||||||
pollTask(id)
|
|
||||||
pollTimer.value = window.setInterval(() => pollTask(id), 3000)
|
|
||||||
}
|
|
||||||
|
|
||||||
function stopPolling() {
|
|
||||||
if (pollTimer.value) window.clearInterval(pollTimer.value)
|
|
||||||
pollTimer.value = undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
async function pollTask(id: string) {
|
|
||||||
try {
|
try {
|
||||||
const data = await deliveryApi.getTask(id)
|
const data = await deliveryApi.getTask(deploymentId.value)
|
||||||
deliveredHost.value = data.task.target_host_ip || deliveredHost.value
|
deliveredHost.value = data.task.target_host_ip || deliveredHost.value
|
||||||
deliveredPort.value = data.task.mysql_port || deliveredPort.value
|
deliveredPort.value = data.task.mysql_port || deliveredPort.value
|
||||||
applyTaskEvents(data.events)
|
applyTaskEvents(data.events)
|
||||||
applyDeliveryStatus(data.task.status, data.task.error_message || '')
|
applyDeliveryStatus(data.task.status, data.task.error_message || '')
|
||||||
if (isTerminalDeliveryStatus(data.task.status)) {
|
if (isTerminalDeliveryStatus(data.task.status)) {
|
||||||
stopPolling()
|
|
||||||
activeView.value = 'result'
|
activeView.value = 'result'
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
appendLog(`[poll] ${error instanceof Error ? error.message : '获取任务状态失败'}`)
|
appendLog(`[refresh] ${error instanceof Error ? error.message : '获取任务状态失败'}`)
|
||||||
|
} finally {
|
||||||
|
taskRefreshing.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -823,9 +1005,44 @@ function applyTaskEvents(events: TaskEvent[]) {
|
|||||||
if (seenEventIds.value.has(event.id)) return
|
if (seenEventIds.value.has(event.id)) return
|
||||||
seenEventIds.value.add(event.id)
|
seenEventIds.value.add(event.id)
|
||||||
appendLog(`[${event.to_state}] ${event.message}`)
|
appendLog(`[${event.to_state}] ${event.message}`)
|
||||||
|
applyStageEvent(event)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applyStageEvent(event: TaskEvent) {
|
||||||
|
const stage = event.stage || stageFromState(event.to_state)
|
||||||
|
const status = event.event_status || statusFromState(event.to_state)
|
||||||
|
const index = stageStepIndex(stage)
|
||||||
|
if (index < 0 || !status) return
|
||||||
|
steps.value = steps.value.map((step, stepIndex) => {
|
||||||
|
if (stepIndex < index) return { ...step, state: 'done' }
|
||||||
|
if (stepIndex > index) return step
|
||||||
|
if (status === 'running') return { ...step, state: 'running' }
|
||||||
|
if (status === 'success') return { ...step, state: 'done' }
|
||||||
|
if (status === 'failed') return { ...step, state: 'failed' }
|
||||||
|
return step
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function stageStepIndex(stage?: string) {
|
||||||
|
const map: Record<string, number> = {
|
||||||
|
precheck: 0,
|
||||||
|
install: 1,
|
||||||
|
configure: 2,
|
||||||
|
healthcheck: 3,
|
||||||
|
register: 4,
|
||||||
|
}
|
||||||
|
return stage ? map[stage] ?? -1 : -1
|
||||||
|
}
|
||||||
|
|
||||||
|
function stageFromState(state: string) {
|
||||||
|
return state.match(/^stage_([^_]+)_/)?.[1] || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusFromState(state: string) {
|
||||||
|
return state.match(/^stage_[^_]+_(running|success|failed)$/)?.[1] || ''
|
||||||
|
}
|
||||||
|
|
||||||
function appendLog(message: unknown) {
|
function appendLog(message: unknown) {
|
||||||
if (!message) return
|
if (!message) return
|
||||||
deliveryLog.value += `\n${String(message)}`
|
deliveryLog.value += `\n${String(message)}`
|
||||||
@@ -870,12 +1087,12 @@ function parseSpec(spec: string) {
|
|||||||
const cpu = Number(spec.match(/(\d+)\s*C/i)?.[1] || 1)
|
const cpu = Number(spec.match(/(\d+)\s*C/i)?.[1] || 1)
|
||||||
const memory = Number(spec.match(/\/\s*(\d+)\s*G/i)?.[1] || 1)
|
const memory = Number(spec.match(/\/\s*(\d+)\s*G/i)?.[1] || 1)
|
||||||
return {
|
return {
|
||||||
cpuMilli: cpu * 1000,
|
cpuCores: cpu,
|
||||||
memoryMi: memory * 1024,
|
memoryGb: memory,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseStorageGi(disk: string) {
|
function parseStorageGb(disk: string) {
|
||||||
const value = Number(disk.match(/(\d+)/)?.[1] || 1)
|
const value = Number(disk.match(/(\d+)/)?.[1] || 1)
|
||||||
if (/TB/i.test(disk)) return value * 1024
|
if (/TB/i.test(disk)) return value * 1024
|
||||||
return value
|
return value
|
||||||
@@ -886,6 +1103,37 @@ function mysqlVersionValue(version: string) {
|
|||||||
return matched.split('.').slice(0, 2).join('.')
|
return matched.split('.').slice(0, 2).join('.')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mysqlTopologyValue(mode: string) {
|
||||||
|
if (mode === 'single') return 'standalone'
|
||||||
|
if (mode === 'replica') return 'primary_replica'
|
||||||
|
return mode
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultCollation(characterSet: string) {
|
||||||
|
if (characterSet === 'utf8mb4') return 'utf8mb4_general_ci'
|
||||||
|
if (characterSet === 'utf8') return 'utf8_general_ci'
|
||||||
|
if (characterSet === 'gbk') return 'gbk_chinese_ci'
|
||||||
|
return 'latin1_swedish_ci'
|
||||||
|
}
|
||||||
|
|
||||||
|
function validCollation(characterSet: string, collation: string) {
|
||||||
|
const allowed: Record<string, string[]> = {
|
||||||
|
utf8mb4: ['utf8mb4_general_ci', 'utf8mb4_unicode_ci', 'utf8mb4_0900_ai_ci'],
|
||||||
|
utf8: ['utf8_general_ci'],
|
||||||
|
gbk: ['gbk_chinese_ci'],
|
||||||
|
latin1: ['latin1_swedish_ci'],
|
||||||
|
}
|
||||||
|
return allowed[characterSet]?.includes(collation) || false
|
||||||
|
}
|
||||||
|
|
||||||
|
function validMaxConnections(value: string | number) {
|
||||||
|
return value === 'auto' || ['200', '500', '1000', '2000', '4000', '8000', '16000'].includes(String(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
function oneOf<T extends string | number>(value: T, allowed: T[]) {
|
||||||
|
return allowed.includes(value)
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeDNSLabel(value: string) {
|
function normalizeDNSLabel(value: string) {
|
||||||
const normalized = value
|
const normalized = value
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
@@ -1045,6 +1293,12 @@ h4 {
|
|||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.restore-state {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.workbench-head {
|
.workbench-head {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user