Merge branch '1024XEngineer:main' into main
This commit is contained in:
Vendored
+3
-2
@@ -14,6 +14,9 @@ declare module 'vue' {
|
|||||||
BusinessLineSwitcher: typeof import('./src/components/BusinessLineSwitcher.vue')['default']
|
BusinessLineSwitcher: typeof import('./src/components/BusinessLineSwitcher.vue')['default']
|
||||||
ElButton: typeof import('element-plus/es')['ElButton']
|
ElButton: typeof import('element-plus/es')['ElButton']
|
||||||
ElDialog: typeof import('element-plus/es')['ElDialog']
|
ElDialog: typeof import('element-plus/es')['ElDialog']
|
||||||
|
ElDropdown: typeof import('element-plus/es')['ElDropdown']
|
||||||
|
ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
|
||||||
|
ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu']
|
||||||
ElForm: typeof import('element-plus/es')['ElForm']
|
ElForm: typeof import('element-plus/es')['ElForm']
|
||||||
ElFormItem: typeof import('element-plus/es')['ElFormItem']
|
ElFormItem: typeof import('element-plus/es')['ElFormItem']
|
||||||
ElIcon: typeof import('element-plus/es')['ElIcon']
|
ElIcon: typeof import('element-plus/es')['ElIcon']
|
||||||
@@ -26,8 +29,6 @@ declare module 'vue' {
|
|||||||
ElSwitch: typeof import('element-plus/es')['ElSwitch']
|
ElSwitch: typeof import('element-plus/es')['ElSwitch']
|
||||||
ElTable: typeof import('element-plus/es')['ElTable']
|
ElTable: typeof import('element-plus/es')['ElTable']
|
||||||
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
|
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
|
||||||
ElTabPane: typeof import('element-plus/es')['ElTabPane']
|
|
||||||
ElTabs: typeof import('element-plus/es')['ElTabs']
|
|
||||||
RouterLink: typeof import('vue-router')['RouterLink']
|
RouterLink: typeof import('vue-router')['RouterLink']
|
||||||
RouterView: typeof import('vue-router')['RouterView']
|
RouterView: typeof import('vue-router')['RouterView']
|
||||||
SubsystemCard: typeof import('./src/components/SubsystemCard.vue')['default']
|
SubsystemCard: typeof import('./src/components/SubsystemCard.vue')['default']
|
||||||
|
|||||||
@@ -56,8 +56,9 @@
|
|||||||
:key="tab.key"
|
:key="tab.key"
|
||||||
type="button"
|
type="button"
|
||||||
class="flow-tab"
|
class="flow-tab"
|
||||||
:class="{ active: activeView === tab.key }"
|
:class="{ active: activeView === tab.key && (tab.key !== 'result' || resultReady), disabled: tab.key === 'result' && !resultReady }"
|
||||||
@click="activeView = tab.key"
|
:disabled="tab.key === 'result' && !resultReady"
|
||||||
|
@click="selectFlowTab(tab.key)"
|
||||||
>
|
>
|
||||||
<strong>{{ tab.title }}</strong>
|
<strong>{{ tab.title }}</strong>
|
||||||
<span>{{ tab.subtitle }}</span>
|
<span>{{ tab.subtitle }}</span>
|
||||||
@@ -82,11 +83,14 @@
|
|||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
<label class="form-field" :class="{ 'is-invalid': instanceNameError }">
|
<label class="form-field" :class="{ 'is-invalid': instanceNameError }">
|
||||||
实例名称
|
实例名称
|
||||||
<el-input
|
<div class="instance-name-editor">
|
||||||
v-model="deliveryForm.instanceName"
|
<el-input
|
||||||
placeholder="mysql-business-service"
|
v-model="deliveryForm.instanceName"
|
||||||
maxlength="63"
|
placeholder="mysql-business-service"
|
||||||
/>
|
maxlength="63"
|
||||||
|
/>
|
||||||
|
<el-button class="random-name-button" text type="primary" :icon="Refresh" aria-label="随机生成实例名称" title="随机生成符合命名规范的实例名称" @click.stop="regenerateInstanceName">随机</el-button>
|
||||||
|
</div>
|
||||||
<small v-if="instanceNameError" class="field-error">{{ instanceNameError }}</small>
|
<small v-if="instanceNameError" class="field-error">{{ instanceNameError }}</small>
|
||||||
<small v-else class="field-help">3–63 位,以小写字母开头,仅支持小写字母、数字和短横线。</small>
|
<small v-else class="field-help">3–63 位,以小写字母开头,仅支持小写字母、数字和短横线。</small>
|
||||||
</label>
|
</label>
|
||||||
@@ -325,7 +329,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section v-else-if="activeView === 'execution'" class="execution-view">
|
<section v-else-if="activeView === 'execution' || !resultReady" class="execution-view">
|
||||||
<div class="execution-head">
|
<div class="execution-head">
|
||||||
<div>
|
<div>
|
||||||
<h3>AWX 自动交付</h3>
|
<h3>AWX 自动交付</h3>
|
||||||
@@ -409,11 +413,11 @@
|
|||||||
<span class="tag tag-amber">仅展示一次</span>
|
<span class="tag tag-amber">仅展示一次</span>
|
||||||
</div>
|
</div>
|
||||||
<p>请立即复制或下载凭证文件;关闭后页面不再显示明文密码。</p>
|
<p>请立即复制或下载凭证文件;关闭后页面不再显示明文密码。</p>
|
||||||
<el-button v-if="!credentialRevealed" type="warning" @click="revealCredential">查看一次性密码</el-button>
|
<el-button v-if="!credentialRevealed" type="warning" :loading="credentialRevealing" @click="revealCredential">查看一次性密码</el-button>
|
||||||
<div v-else class="credential-secret">
|
<div v-else class="credential-secret">
|
||||||
<code>{{ deliveryForm.rootPassword }}</code>
|
<code>{{ revealedRootCredential?.password }}</code>
|
||||||
<div class="credential-actions">
|
<div class="credential-actions">
|
||||||
<el-button :icon="CopyDocument" @click="copyText(deliveryForm.rootPassword, 'root 密码已复制')">复制密码</el-button>
|
<el-button :icon="CopyDocument" @click="copyText(revealedRootCredential?.password || '', 'root 密码已复制')">复制密码</el-button>
|
||||||
<el-button :icon="Download" @click="downloadCredentialFile">下载凭证文件</el-button>
|
<el-button :icon="Download" @click="downloadCredentialFile">下载凭证文件</el-button>
|
||||||
<el-button type="success" plain :icon="CircleCheck" @click="dismissCredential">已保存,关闭</el-button>
|
<el-button type="success" plain :icon="CircleCheck" @click="dismissCredential">已保存,关闭</el-button>
|
||||||
</div>
|
</div>
|
||||||
@@ -491,7 +495,7 @@ import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
|
|||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { ArrowDown, Back, CircleCheck, Connection, CopyDocument, DocumentCopy, Download, Monitor, Promotion, Refresh, Setting, Tickets } from '@element-plus/icons-vue'
|
import { ArrowDown, Back, CircleCheck, Connection, CopyDocument, DocumentCopy, Download, Monitor, Promotion, Refresh, Setting, Tickets } 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 DeploymentCredential, type TaskEvent } from '@/api/delivery'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { useBusinessLineStore } from '@/stores/businessLine'
|
import { useBusinessLineStore } from '@/stores/businessLine'
|
||||||
import { useBusinessLineMockProfile } from '@/utils/businessLineMock'
|
import { useBusinessLineMockProfile } from '@/utils/businessLineMock'
|
||||||
@@ -560,8 +564,45 @@ interface DeliveryHistoryItem {
|
|||||||
errorMessage: string
|
errorMessage: string
|
||||||
createdAt: string
|
createdAt: string
|
||||||
updatedAt: string
|
updatedAt: string
|
||||||
credential?: string
|
}
|
||||||
credentialConsumed?: boolean
|
|
||||||
|
interface WorkbenchSnapshot {
|
||||||
|
version: 1
|
||||||
|
serviceKey: string
|
||||||
|
activeView: FlowView
|
||||||
|
precheckPassed: boolean
|
||||||
|
running: boolean
|
||||||
|
deliveryDone: boolean
|
||||||
|
deliveryFailed: boolean
|
||||||
|
deploymentId: string
|
||||||
|
deliveryError: string
|
||||||
|
deliveredHost: string
|
||||||
|
deliveredPort?: number
|
||||||
|
lastDeliveryStatus: string
|
||||||
|
deliveryLog: string
|
||||||
|
steps: DeliveryStep[]
|
||||||
|
form: {
|
||||||
|
instanceName: string
|
||||||
|
version: string
|
||||||
|
mode: string
|
||||||
|
spec: string
|
||||||
|
storageGi: number
|
||||||
|
port?: number
|
||||||
|
charset: string
|
||||||
|
collation: string
|
||||||
|
lowerCaseTableNames: number
|
||||||
|
timezone: string
|
||||||
|
dataDisk: string
|
||||||
|
targetHost: string
|
||||||
|
maxConnections: string
|
||||||
|
redoCapacity: string
|
||||||
|
flushLogAtCommit: number
|
||||||
|
syncBinlog: number
|
||||||
|
ioCapacity: number
|
||||||
|
longQueryTime: number
|
||||||
|
binlogExpireSeconds: number
|
||||||
|
maxBinlogSize: string
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const { currentName } = useBusinessLineMockProfile()
|
const { currentName } = useBusinessLineMockProfile()
|
||||||
@@ -587,7 +628,7 @@ const basicServices = ref<Service[]>([
|
|||||||
modes: [
|
modes: [
|
||||||
{ value: 'single', label: '单实例' },
|
{ value: 'single', label: '单实例' },
|
||||||
],
|
],
|
||||||
specs: ['0.5C / 2G', '1C / 2G', '2C / 4G', '4C / 8G', '8C / 16G', '16C / 32G'],
|
specs: ['1C / 2G', '2C / 4G', '4C / 8G', '8C / 16G', '16C / 32G'],
|
||||||
disks: ['20 GB', '50 GB', '100 GB', '500 GB', '1000 GB'],
|
disks: ['20 GB', '50 GB', '100 GB', '500 GB', '1000 GB'],
|
||||||
charsets: ['utf8mb4', 'utf8', 'gbk', 'latin1'],
|
charsets: ['utf8mb4', 'utf8', 'gbk', 'latin1'],
|
||||||
defaultPort: 0,
|
defaultPort: 0,
|
||||||
@@ -720,17 +761,14 @@ const precheckPassed = ref(false)
|
|||||||
const running = ref(false)
|
const running = ref(false)
|
||||||
const deliveryDone = ref(false)
|
const deliveryDone = ref(false)
|
||||||
const deliveryFailed = ref(false)
|
const deliveryFailed = ref(false)
|
||||||
|
const taskStateLoading = ref(false)
|
||||||
const credentialRevealed = ref(false)
|
const credentialRevealed = ref(false)
|
||||||
const credentialDismissed = ref(false)
|
const credentialDismissed = ref(false)
|
||||||
|
const credentialRevealing = ref(false)
|
||||||
|
const revealedCredentials = ref<DeploymentCredential[]>([])
|
||||||
const deliveryHistory = ref<DeliveryHistoryItem[]>(loadDeliveryHistory())
|
const deliveryHistory = ref<DeliveryHistoryItem[]>(loadDeliveryHistory())
|
||||||
const deliveryCredentials = new Map<string, string>()
|
let workbenchReady = false
|
||||||
const deliveryCredentialStates = new Map<string, { revealed: boolean; dismissed: boolean }>()
|
let restoringWorkbench = false
|
||||||
deliveryHistory.value.forEach((item) => {
|
|
||||||
if (item.credential && !item.credentialConsumed) {
|
|
||||||
deliveryCredentials.set(item.id, item.credential)
|
|
||||||
deliveryCredentialStates.set(item.id, { revealed: false, dismissed: false })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
const canceling = ref(false)
|
const canceling = ref(false)
|
||||||
const deploymentId = ref('')
|
const deploymentId = ref('')
|
||||||
const deliveryError = ref('')
|
const deliveryError = ref('')
|
||||||
@@ -758,7 +796,7 @@ const deliveryForm = reactive({
|
|||||||
version: 'MySQL 8.0',
|
version: 'MySQL 8.0',
|
||||||
mode: 'single',
|
mode: 'single',
|
||||||
spec: '1C / 2G',
|
spec: '1C / 2G',
|
||||||
storageGi: 50,
|
storageGi: 20,
|
||||||
port: undefined as number | undefined,
|
port: undefined as number | undefined,
|
||||||
rootPassword: generateRootPassword(),
|
rootPassword: generateRootPassword(),
|
||||||
charset: 'utf8mb4',
|
charset: 'utf8mb4',
|
||||||
@@ -850,6 +888,8 @@ const runnerPreview = computed(() => {
|
|||||||
})
|
})
|
||||||
const connectionReady = computed(() => Boolean(deliveredHost.value && deliveredPort.value))
|
const connectionReady = computed(() => Boolean(deliveredHost.value && deliveredPort.value))
|
||||||
const credentialEligible = computed(() => connectionReady.value && ['finished', 'register_failed'].includes(lastDeliveryStatus.value))
|
const credentialEligible = computed(() => connectionReady.value && ['finished', 'register_failed'].includes(lastDeliveryStatus.value))
|
||||||
|
const revealedRootCredential = computed(() => revealedCredentials.value.find((item) => item.username === 'root'))
|
||||||
|
const resultReady = computed(() => !taskStateLoading.value && !running.value && isTerminalDeliveryStatus(lastDeliveryStatus.value))
|
||||||
const previewAddress = computed(() => `${selectedHost.value?.ip || 'AWX 自动分配'}:${deliveryForm.port || '自动端口'}`)
|
const previewAddress = computed(() => `${selectedHost.value?.ip || 'AWX 自动分配'}:${deliveryForm.port || '自动端口'}`)
|
||||||
const resultAddress = computed(() => connectionReady.value ? `${deliveredHost.value}:${deliveredPort.value}` : '交付接口未返回地址')
|
const resultAddress = computed(() => connectionReady.value ? `${deliveredHost.value}:${deliveredPort.value}` : '交付接口未返回地址')
|
||||||
const connectionUri = computed(() => connectionReady.value ? `mysql://root@${deliveredHost.value}:${deliveredPort.value}` : '')
|
const connectionUri = computed(() => connectionReady.value ? `mysql://root@${deliveredHost.value}:${deliveredPort.value}` : '')
|
||||||
@@ -918,16 +958,24 @@ watch(
|
|||||||
|
|
||||||
watch(deliveryForm, () => {
|
watch(deliveryForm, () => {
|
||||||
precheckPassed.value = false
|
precheckPassed.value = false
|
||||||
|
if (workbenchReady && !restoringWorkbench) persistWorkbench()
|
||||||
}, { deep: true })
|
}, { deep: true })
|
||||||
|
|
||||||
watch(activeServiceKey, () => {
|
watch(activeServiceKey, () => {
|
||||||
hydrateServiceDefaults()
|
hydrateServiceDefaults()
|
||||||
|
if (activeServiceKey.value) {
|
||||||
|
workbenchReady = false
|
||||||
|
restoreWorkbench()
|
||||||
|
} else {
|
||||||
|
workbenchReady = true
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(activeView, (view, previousView) => {
|
watch(activeView, (view, previousView) => {
|
||||||
if (previousView === 'result' && view !== 'result' && credentialRevealed.value) {
|
if (previousView === 'result' && view !== 'result' && credentialRevealed.value) {
|
||||||
dismissCredential()
|
dismissCredential()
|
||||||
}
|
}
|
||||||
|
if (workbenchReady && !restoringWorkbench) persistWorkbench()
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
@@ -942,9 +990,11 @@ hydrateServiceDefaults()
|
|||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (route.query.preview === 'credential') {
|
if (route.query.preview === 'credential') {
|
||||||
setupCredentialPreview()
|
setupCredentialPreview()
|
||||||
|
workbenchReady = true
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
loadDeliveryTargets()
|
loadDeliveryTargets()
|
||||||
|
restoreWorkbench()
|
||||||
})
|
})
|
||||||
onBeforeUnmount(stopPolling)
|
onBeforeUnmount(stopPolling)
|
||||||
|
|
||||||
@@ -965,10 +1015,130 @@ function backToCatalog() {
|
|||||||
router.push({ name: 'ServiceCatalog' })
|
router.push({ name: 'ServiceCatalog' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function selectFlowTab(view: FlowView) {
|
||||||
|
if (view === 'result' && !resultReady.value) return
|
||||||
|
activeView.value = view
|
||||||
|
}
|
||||||
|
|
||||||
function openDeliveryRecords() {
|
function openDeliveryRecords() {
|
||||||
router.push({ name: 'ServiceManagement' })
|
router.push({ name: 'ServiceManagement' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function workbenchStorageKey() {
|
||||||
|
return `xinfra:delivery-workbench:${currentName.value}:${activeServiceKey.value || 'catalog'}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistWorkbench() {
|
||||||
|
if (!workbenchReady || !activeServiceKey.value) return
|
||||||
|
const snapshot: WorkbenchSnapshot = {
|
||||||
|
version: 1,
|
||||||
|
serviceKey: activeServiceKey.value,
|
||||||
|
activeView: activeView.value,
|
||||||
|
precheckPassed: precheckPassed.value,
|
||||||
|
running: running.value,
|
||||||
|
deliveryDone: deliveryDone.value,
|
||||||
|
deliveryFailed: deliveryFailed.value,
|
||||||
|
deploymentId: deploymentId.value,
|
||||||
|
deliveryError: deliveryError.value,
|
||||||
|
deliveredHost: deliveredHost.value,
|
||||||
|
deliveredPort: deliveredPort.value,
|
||||||
|
lastDeliveryStatus: lastDeliveryStatus.value,
|
||||||
|
deliveryLog: deliveryLog.value,
|
||||||
|
steps: steps.value,
|
||||||
|
form: {
|
||||||
|
instanceName: deliveryForm.instanceName,
|
||||||
|
version: deliveryForm.version,
|
||||||
|
mode: deliveryForm.mode,
|
||||||
|
spec: deliveryForm.spec,
|
||||||
|
storageGi: deliveryForm.storageGi,
|
||||||
|
port: deliveryForm.port,
|
||||||
|
charset: deliveryForm.charset,
|
||||||
|
collation: deliveryForm.collation,
|
||||||
|
lowerCaseTableNames: deliveryForm.lowerCaseTableNames,
|
||||||
|
timezone: deliveryForm.timezone,
|
||||||
|
dataDisk: deliveryForm.dataDisk,
|
||||||
|
targetHost: deliveryForm.targetHost,
|
||||||
|
maxConnections: deliveryForm.maxConnections,
|
||||||
|
redoCapacity: deliveryForm.redoCapacity,
|
||||||
|
flushLogAtCommit: deliveryForm.flushLogAtCommit,
|
||||||
|
syncBinlog: deliveryForm.syncBinlog,
|
||||||
|
ioCapacity: deliveryForm.ioCapacity,
|
||||||
|
longQueryTime: deliveryForm.longQueryTime,
|
||||||
|
binlogExpireSeconds: deliveryForm.binlogExpireSeconds,
|
||||||
|
maxBinlogSize: deliveryForm.maxBinlogSize,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem(workbenchStorageKey(), JSON.stringify(snapshot))
|
||||||
|
} catch {
|
||||||
|
// Session storage may be disabled; the backend task history remains available.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function restoreWorkbench() {
|
||||||
|
let snapshot: WorkbenchSnapshot | undefined
|
||||||
|
try {
|
||||||
|
const raw = sessionStorage.getItem(workbenchStorageKey())
|
||||||
|
snapshot = raw ? JSON.parse(raw) as WorkbenchSnapshot : undefined
|
||||||
|
} catch {
|
||||||
|
snapshot = undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!snapshot || snapshot.version !== 1 || snapshot.serviceKey !== activeServiceKey.value) {
|
||||||
|
workbenchReady = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
restoringWorkbench = true
|
||||||
|
Object.assign(deliveryForm, snapshot.form)
|
||||||
|
deliveryForm.rootPassword = generateRootPassword()
|
||||||
|
precheckPassed.value = snapshot.precheckPassed
|
||||||
|
running.value = snapshot.running
|
||||||
|
deliveryDone.value = snapshot.deliveryDone
|
||||||
|
deliveryFailed.value = snapshot.deliveryFailed
|
||||||
|
deploymentId.value = snapshot.deploymentId
|
||||||
|
deliveryError.value = snapshot.deliveryError
|
||||||
|
deliveredHost.value = snapshot.deliveredHost
|
||||||
|
deliveredPort.value = snapshot.deliveredPort
|
||||||
|
lastDeliveryStatus.value = snapshot.lastDeliveryStatus
|
||||||
|
deliveryLog.value = snapshot.deliveryLog
|
||||||
|
steps.value = snapshot.steps?.length ? snapshot.steps : defaultSteps()
|
||||||
|
credentialRevealed.value = false
|
||||||
|
credentialDismissed.value = Boolean(snapshot.deploymentId)
|
||||||
|
|
||||||
|
if (snapshot.deploymentId) {
|
||||||
|
taskStateLoading.value = true
|
||||||
|
try {
|
||||||
|
const data = await deliveryApi.getTask(snapshot.deploymentId)
|
||||||
|
deliveredHost.value = data.task.target_host_ip || deliveredHost.value
|
||||||
|
deliveredPort.value = data.task.mysql_port || deliveredPort.value
|
||||||
|
lastDeliveryStatus.value = data.task.status
|
||||||
|
deliveryError.value = data.task.error_message || deliveryError.value
|
||||||
|
seenEventIds.value = new Set(data.events.map((event) => event.id))
|
||||||
|
applyDeliveryStatus(data.task.status, data.task.error_message || '')
|
||||||
|
if (isTerminalDeliveryStatus(data.task.status)) {
|
||||||
|
activeView.value = 'result'
|
||||||
|
stopPolling()
|
||||||
|
} else {
|
||||||
|
activeView.value = 'execution'
|
||||||
|
startTaskPolling(snapshot.deploymentId)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
appendLog(`[restore] ${error instanceof Error ? error.message : '获取任务状态失败'}`)
|
||||||
|
running.value = true
|
||||||
|
activeView.value = 'execution'
|
||||||
|
} finally {
|
||||||
|
taskStateLoading.value = false
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
activeView.value = snapshot.activeView
|
||||||
|
}
|
||||||
|
|
||||||
|
restoringWorkbench = false
|
||||||
|
workbenchReady = true
|
||||||
|
persistWorkbench()
|
||||||
|
}
|
||||||
|
|
||||||
function hydrateServiceDefaults() {
|
function hydrateServiceDefaults() {
|
||||||
const service = activeService.value
|
const service = activeService.value
|
||||||
if (!service) return
|
if (!service) return
|
||||||
@@ -976,8 +1146,12 @@ function hydrateServiceDefaults() {
|
|||||||
deliveryForm.instanceName = `${prefix}-${currentName.value}-billing-02`
|
deliveryForm.instanceName = `${prefix}-${currentName.value}-billing-02`
|
||||||
deliveryForm.version = service.versions[0] || ''
|
deliveryForm.version = service.versions[0] || ''
|
||||||
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.key === 'mysql'
|
||||||
deliveryForm.storageGi = parseStorageGi(service.disks[1] || service.disks[0] || '50 GB')
|
? (service.specs[0] || '')
|
||||||
|
: (service.specs[1] || service.specs[0] || '')
|
||||||
|
deliveryForm.storageGi = service.key === 'mysql'
|
||||||
|
? parseStorageGi(service.disks[0] || '20 GB')
|
||||||
|
: parseStorageGi(service.disks[1] || service.disks[0] || '50 GB')
|
||||||
deliveryForm.port = service.defaultPort || undefined
|
deliveryForm.port = service.defaultPort || undefined
|
||||||
deliveryForm.charset = service.charsets[0] || ''
|
deliveryForm.charset = service.charsets[0] || ''
|
||||||
deliveryForm.collation = collationMap[deliveryForm.charset]?.[0] || ''
|
deliveryForm.collation = collationMap[deliveryForm.charset]?.[0] || ''
|
||||||
@@ -998,19 +1172,30 @@ function hydrateServiceDefaults() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resetWorkbench() {
|
function resetWorkbench() {
|
||||||
|
clearWorkbenchSnapshot()
|
||||||
hydrateServiceDefaults()
|
hydrateServiceDefaults()
|
||||||
activeView.value = 'config'
|
activeView.value = 'config'
|
||||||
ElMessage.success('交付工作台已重置')
|
ElMessage.success('交付工作台已重置')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clearWorkbenchSnapshot() {
|
||||||
|
try {
|
||||||
|
sessionStorage.removeItem(workbenchStorageKey())
|
||||||
|
} catch {
|
||||||
|
// Session storage may be disabled.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function resetExecutionState() {
|
function resetExecutionState() {
|
||||||
stopPolling()
|
stopPolling()
|
||||||
|
taskStateLoading.value = false
|
||||||
precheckPassed.value = false
|
precheckPassed.value = false
|
||||||
running.value = false
|
running.value = false
|
||||||
deliveryDone.value = false
|
deliveryDone.value = false
|
||||||
deliveryFailed.value = false
|
deliveryFailed.value = false
|
||||||
credentialRevealed.value = false
|
credentialRevealed.value = false
|
||||||
credentialDismissed.value = false
|
credentialDismissed.value = false
|
||||||
|
revealedCredentials.value = []
|
||||||
deliveryError.value = ''
|
deliveryError.value = ''
|
||||||
lastDeliveryStatus.value = ''
|
lastDeliveryStatus.value = ''
|
||||||
canceling.value = false
|
canceling.value = false
|
||||||
@@ -1042,8 +1227,13 @@ function defaultSteps(): DeliveryStep[] {
|
|||||||
return copy.map(([name, desc]) => ({ name, desc, state: 'pending' as StepState }))
|
return copy.map(([name, desc]) => ({ name, desc, state: 'pending' as StepState }))
|
||||||
}
|
}
|
||||||
|
|
||||||
function precheck() {
|
async function precheck() {
|
||||||
if (!validateDeliveryForm()) return
|
if (!validateDeliveryForm()) return
|
||||||
|
const businessLineId = businessLineStore.current?.id
|
||||||
|
if (!businessLineId) {
|
||||||
|
ElMessage.warning('请先选择业务线')
|
||||||
|
return
|
||||||
|
}
|
||||||
if (!selectedTargetId.value) {
|
if (!selectedTargetId.value) {
|
||||||
ElMessage.warning('当前没有可用的 AWX 部署模板,请联系平台管理员配置')
|
ElMessage.warning('当前没有可用的 AWX 部署模板,请联系平台管理员配置')
|
||||||
return
|
return
|
||||||
@@ -1052,6 +1242,18 @@ function precheck() {
|
|||||||
ElMessage.warning('手动端口必须在 13306–13999 范围内')
|
ElMessage.warning('手动端口必须在 13306–13999 范围内')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
const conflict = await findInstanceNameConflict(businessLineId, normalizeDNSLabel(deliveryForm.instanceName))
|
||||||
|
if (conflict) {
|
||||||
|
precheckPassed.value = false
|
||||||
|
ElMessage.error(conflict)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
precheckPassed.value = false
|
||||||
|
ElMessage.error(error instanceof Error ? `实例名称可用性检查失败:${error.message}` : '实例名称可用性检查失败')
|
||||||
|
return
|
||||||
|
}
|
||||||
precheckPassed.value = true
|
precheckPassed.value = true
|
||||||
deliveryFailed.value = false
|
deliveryFailed.value = false
|
||||||
deliveryLog.value = [
|
deliveryLog.value = [
|
||||||
@@ -1063,6 +1265,33 @@ function precheck() {
|
|||||||
ElMessage.success('预检查通过,可以创建交付任务')
|
ElMessage.success('预检查通过,可以创建交付任务')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function findInstanceNameConflict(businessLineId: number, instanceName: string) {
|
||||||
|
const occupiedStatuses = new Set([
|
||||||
|
'pending',
|
||||||
|
'validating',
|
||||||
|
'dispatching',
|
||||||
|
'running',
|
||||||
|
'registering',
|
||||||
|
'finished',
|
||||||
|
'register_failed',
|
||||||
|
'canceling',
|
||||||
|
'rollback_pending',
|
||||||
|
'rolling_back',
|
||||||
|
'rollback_failed',
|
||||||
|
])
|
||||||
|
const [tasks, services] = await Promise.all([
|
||||||
|
deliveryApi.listTasks({ businessLineId, component: 'mysql' }),
|
||||||
|
deliveryApi.listMySQLServices(businessLineId),
|
||||||
|
])
|
||||||
|
if (tasks.some((task) => task.instance_name === instanceName && occupiedStatuses.has(task.status))) {
|
||||||
|
return `实例名称 ${instanceName} 已被运行中或尚未释放的交付任务占用`
|
||||||
|
}
|
||||||
|
if (services.some((service) => service.name === instanceName)) {
|
||||||
|
return `实例名称 ${instanceName} 已被现有 MySQL 实例占用`
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
async function createTask() {
|
async function createTask() {
|
||||||
if (!precheckPassed.value) {
|
if (!precheckPassed.value) {
|
||||||
ElMessage.warning('请先执行预检查')
|
ElMessage.warning('请先执行预检查')
|
||||||
@@ -1091,18 +1320,20 @@ async function createTask() {
|
|||||||
try {
|
try {
|
||||||
const task = await deliveryApi.createMySQL(mysqlDeliveryPayload(businessLineId))
|
const task = await deliveryApi.createMySQL(mysqlDeliveryPayload(businessLineId))
|
||||||
deploymentId.value = task.id
|
deploymentId.value = task.id
|
||||||
deliveryCredentials.set(task.id, deliveryForm.rootPassword)
|
deliveryForm.rootPassword = ''
|
||||||
deliveryCredentialStates.set(task.id, { revealed: false, dismissed: false })
|
upsertDeliveryHistory(task.id, task.status, task.error_message || '', task.created_at)
|
||||||
upsertDeliveryHistory(task.id, task.status, task.error_message || '', task.created_at, deliveryForm.rootPassword)
|
|
||||||
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)
|
startTaskPolling(task.id)
|
||||||
|
persistWorkbench()
|
||||||
ElMessage.success('交付任务已创建')
|
ElMessage.success('交付任务已创建')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
running.value = false
|
running.value = false
|
||||||
deliveryFailed.value = true
|
deliveryFailed.value = true
|
||||||
|
lastDeliveryStatus.value = 'execution_failed'
|
||||||
|
deliveryError.value = error instanceof Error ? error.message : '创建交付任务失败'
|
||||||
activeView.value = 'result'
|
activeView.value = 'result'
|
||||||
ElMessage.error(error instanceof Error ? error.message : '创建交付任务失败')
|
ElMessage.error(deliveryError.value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1191,6 +1422,7 @@ async function pollTask(id: string) {
|
|||||||
stopPolling()
|
stopPolling()
|
||||||
activeView.value = 'result'
|
activeView.value = 'result'
|
||||||
}
|
}
|
||||||
|
persistWorkbench()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
appendLog(`[poll] ${error instanceof Error ? error.message : '获取任务状态失败'}`)
|
appendLog(`[poll] ${error instanceof Error ? error.message : '获取任务状态失败'}`)
|
||||||
}
|
}
|
||||||
@@ -1276,9 +1508,8 @@ function setupCredentialPreview() {
|
|||||||
deliveredPort.value = 13306
|
deliveredPort.value = 13306
|
||||||
credentialRevealed.value = false
|
credentialRevealed.value = false
|
||||||
credentialDismissed.value = false
|
credentialDismissed.value = false
|
||||||
deliveryCredentials.set(deploymentId.value, deliveryForm.rootPassword)
|
revealedCredentials.value = []
|
||||||
deliveryCredentialStates.set(deploymentId.value, { revealed: false, dismissed: false })
|
upsertDeliveryHistory(deploymentId.value, 'finished', '', new Date().toISOString())
|
||||||
upsertDeliveryHistory(deploymentId.value, 'finished', '', new Date().toISOString(), deliveryForm.rootPassword)
|
|
||||||
if (deliveryHistory.value.length < 3) {
|
if (deliveryHistory.value.length < 3) {
|
||||||
addPreviewHistoryItems()
|
addPreviewHistoryItems()
|
||||||
}
|
}
|
||||||
@@ -1317,10 +1548,29 @@ function normalizeDNSLabel(value: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function generateRootPassword() {
|
function generateRootPassword() {
|
||||||
const alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789!@#$%^&*_-+='
|
const groups = [
|
||||||
const values = new Uint32Array(24)
|
'ABCDEFGHJKLMNPQRSTUVWXYZ',
|
||||||
crypto.getRandomValues(values)
|
'abcdefghijkmnopqrstuvwxyz',
|
||||||
return Array.from(values, (value) => alphabet[value % alphabet.length]).join('')
|
'23456789',
|
||||||
|
'!@#$%^&*_-+=',
|
||||||
|
]
|
||||||
|
const alphabet = groups.join('')
|
||||||
|
const pick = (characters: string) => {
|
||||||
|
const value = new Uint32Array(1)
|
||||||
|
crypto.getRandomValues(value)
|
||||||
|
return characters[value[0] % characters.length]
|
||||||
|
}
|
||||||
|
const password = groups.map(pick)
|
||||||
|
while (password.length < 24) password.push(pick(alphabet))
|
||||||
|
for (let index = password.length - 1; index > 0; index--) {
|
||||||
|
const value = new Uint32Array(1)
|
||||||
|
crypto.getRandomValues(value)
|
||||||
|
const swapIndex = value[0] % (index + 1)
|
||||||
|
const current = password[index]
|
||||||
|
password[index] = password[swapIndex]
|
||||||
|
password[swapIndex] = current
|
||||||
|
}
|
||||||
|
return password.join('')
|
||||||
}
|
}
|
||||||
|
|
||||||
function rootPasswordValidationError(value: string) {
|
function rootPasswordValidationError(value: string) {
|
||||||
@@ -1339,33 +1589,55 @@ function regenerateRootPassword() {
|
|||||||
ElMessage.success('已生成新的 root 密码')
|
ElMessage.success('已生成新的 root 密码')
|
||||||
}
|
}
|
||||||
|
|
||||||
function revealCredential() {
|
function regenerateInstanceName() {
|
||||||
|
const prefix = activeServiceKey.value === 'mysql' ? 'mysql' : 'service'
|
||||||
|
const businessLine = normalizeDNSLabel(currentName.value)
|
||||||
|
const alphabet = 'abcdefghijklmnopqrstuvwxyz0123456789'
|
||||||
|
const values = new Uint32Array(6)
|
||||||
|
crypto.getRandomValues(values)
|
||||||
|
const suffix = Array.from(values, (value) => alphabet[value % alphabet.length]).join('')
|
||||||
|
deliveryForm.instanceName = normalizeDNSLabel(`${prefix}-${businessLine}-${suffix}`)
|
||||||
|
precheckPassed.value = false
|
||||||
|
ElMessage.success('已生成新的实例名称,请执行预检查确认可用')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function revealCredential() {
|
||||||
if (!credentialEligible.value) return
|
if (!credentialEligible.value) return
|
||||||
credentialRevealed.value = true
|
credentialRevealing.value = true
|
||||||
if (deploymentId.value) {
|
try {
|
||||||
deliveryCredentialStates.set(deploymentId.value, { revealed: true, dismissed: false })
|
if (deploymentId.value === 'PREVIEW-CREDENTIAL') {
|
||||||
|
revealedCredentials.value = [{ username: 'root', host: 'localhost', password: deliveryForm.rootPassword }]
|
||||||
|
} else {
|
||||||
|
revealedCredentials.value = await deliveryApi.revealCredentials(deploymentId.value)
|
||||||
|
}
|
||||||
|
if (!revealedRootCredential.value) {
|
||||||
|
throw new Error('后端未返回 root 凭证')
|
||||||
|
}
|
||||||
|
credentialRevealed.value = true
|
||||||
|
} catch (error) {
|
||||||
|
credentialDismissed.value = true
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '一次性凭证领取失败')
|
||||||
|
} finally {
|
||||||
|
credentialRevealing.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function dismissCredential() {
|
function dismissCredential() {
|
||||||
credentialRevealed.value = false
|
credentialRevealed.value = false
|
||||||
credentialDismissed.value = true
|
credentialDismissed.value = true
|
||||||
if (deploymentId.value) {
|
revealedCredentials.value = []
|
||||||
deliveryCredentialStates.set(deploymentId.value, { revealed: false, dismissed: true })
|
|
||||||
deliveryCredentials.delete(deploymentId.value)
|
|
||||||
markHistoryCredentialConsumed(deploymentId.value)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function downloadCredentialFile() {
|
function downloadCredentialFile() {
|
||||||
if (!credentialRevealed.value || !deliveryForm.rootPassword) return
|
const password = revealedRootCredential.value?.password
|
||||||
|
if (!credentialRevealed.value || !password) return
|
||||||
const content = [
|
const content = [
|
||||||
'# XInfra MySQL delivery credential',
|
'# XInfra MySQL delivery credential',
|
||||||
`instance=${deliveryForm.instanceName}`,
|
`instance=${deliveryForm.instanceName}`,
|
||||||
`host=${deliveredHost.value}`,
|
`host=${deliveredHost.value}`,
|
||||||
`port=${deliveredPort.value || ''}`,
|
`port=${deliveredPort.value || ''}`,
|
||||||
'username=root',
|
'username=root',
|
||||||
`password=${deliveryForm.rootPassword}`,
|
`password=${password}`,
|
||||||
].join('\n') + '\n'
|
].join('\n') + '\n'
|
||||||
const url = URL.createObjectURL(new Blob([content], { type: 'text/plain;charset=utf-8' }))
|
const url = URL.createObjectURL(new Blob([content], { type: 'text/plain;charset=utf-8' }))
|
||||||
const link = document.createElement('a')
|
const link = document.createElement('a')
|
||||||
@@ -1380,7 +1652,14 @@ function loadDeliveryHistory(): DeliveryHistoryItem[] {
|
|||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem('xinfra:mysql-delivery-history')
|
const raw = localStorage.getItem('xinfra:mysql-delivery-history')
|
||||||
const parsed = raw ? JSON.parse(raw) : []
|
const parsed = raw ? JSON.parse(raw) : []
|
||||||
return Array.isArray(parsed) ? parsed.slice(0, 12) : []
|
if (!Array.isArray(parsed)) return []
|
||||||
|
const sanitized = parsed.slice(0, 12).map((item) => {
|
||||||
|
if (!item || typeof item !== 'object') return item
|
||||||
|
const { credential: _credential, credentialConsumed: _credentialConsumed, ...history } = item
|
||||||
|
return history
|
||||||
|
})
|
||||||
|
localStorage.setItem('xinfra:mysql-delivery-history', JSON.stringify(sanitized))
|
||||||
|
return sanitized
|
||||||
} catch {
|
} catch {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
@@ -1390,7 +1669,7 @@ function persistDeliveryHistory() {
|
|||||||
localStorage.setItem('xinfra:mysql-delivery-history', JSON.stringify(deliveryHistory.value.slice(0, 12)))
|
localStorage.setItem('xinfra:mysql-delivery-history', JSON.stringify(deliveryHistory.value.slice(0, 12)))
|
||||||
}
|
}
|
||||||
|
|
||||||
function upsertDeliveryHistory(id: string, status: string, errorMessage = '', createdAt?: string, credential?: string) {
|
function upsertDeliveryHistory(id: string, status: string, errorMessage = '', createdAt?: string) {
|
||||||
if (!id) return
|
if (!id) return
|
||||||
const existing = deliveryHistory.value.find((item) => item.id === id)
|
const existing = deliveryHistory.value.find((item) => item.id === id)
|
||||||
const item: DeliveryHistoryItem = {
|
const item: DeliveryHistoryItem = {
|
||||||
@@ -1406,8 +1685,6 @@ function upsertDeliveryHistory(id: string, status: string, errorMessage = '', cr
|
|||||||
errorMessage,
|
errorMessage,
|
||||||
createdAt: createdAt || existing?.createdAt || new Date().toISOString(),
|
createdAt: createdAt || existing?.createdAt || new Date().toISOString(),
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
credential: credential ?? existing?.credential,
|
|
||||||
credentialConsumed: existing?.credentialConsumed ?? false,
|
|
||||||
}
|
}
|
||||||
deliveryHistory.value = [item, ...deliveryHistory.value.filter((entry) => entry.id !== id)]
|
deliveryHistory.value = [item, ...deliveryHistory.value.filter((entry) => entry.id !== id)]
|
||||||
.sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt))
|
.sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt))
|
||||||
@@ -1415,16 +1692,6 @@ function upsertDeliveryHistory(id: string, status: string, errorMessage = '', cr
|
|||||||
persistDeliveryHistory()
|
persistDeliveryHistory()
|
||||||
}
|
}
|
||||||
|
|
||||||
function markHistoryCredentialConsumed(id: string) {
|
|
||||||
const item = deliveryHistory.value.find((entry) => entry.id === id)
|
|
||||||
if (!item) return
|
|
||||||
if (!item.credential && !deliveryCredentials.has(id)) return
|
|
||||||
item.credentialConsumed = true
|
|
||||||
item.credential = undefined
|
|
||||||
deliveryHistory.value = [...deliveryHistory.value]
|
|
||||||
persistDeliveryHistory()
|
|
||||||
}
|
|
||||||
|
|
||||||
function addPreviewHistoryItems() {
|
function addPreviewHistoryItems() {
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
const previewItems: DeliveryHistoryItem[] = [
|
const previewItems: DeliveryHistoryItem[] = [
|
||||||
@@ -1441,8 +1708,6 @@ function addPreviewHistoryItems() {
|
|||||||
errorMessage: '',
|
errorMessage: '',
|
||||||
createdAt: new Date(now - 18 * 60 * 1000).toISOString(),
|
createdAt: new Date(now - 18 * 60 * 1000).toISOString(),
|
||||||
updatedAt: new Date(now - 12 * 60 * 1000).toISOString(),
|
updatedAt: new Date(now - 12 * 60 * 1000).toISOString(),
|
||||||
credential: 'preview-r0ot-Password!2026',
|
|
||||||
credentialConsumed: false,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'PREVIEW-FAILED',
|
id: 'PREVIEW-FAILED',
|
||||||
@@ -1457,18 +1722,11 @@ function addPreviewHistoryItems() {
|
|||||||
errorMessage: '目标主机资源预检查未通过',
|
errorMessage: '目标主机资源预检查未通过',
|
||||||
createdAt: new Date(now - 95 * 60 * 1000).toISOString(),
|
createdAt: new Date(now - 95 * 60 * 1000).toISOString(),
|
||||||
updatedAt: new Date(now - 92 * 60 * 1000).toISOString(),
|
updatedAt: new Date(now - 92 * 60 * 1000).toISOString(),
|
||||||
credentialConsumed: true,
|
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
deliveryHistory.value = [...deliveryHistory.value, ...previewItems]
|
deliveryHistory.value = [...deliveryHistory.value, ...previewItems]
|
||||||
.filter((item, index, items) => items.findIndex((entry) => entry.id === item.id) === index)
|
.filter((item, index, items) => items.findIndex((entry) => entry.id === item.id) === index)
|
||||||
.slice(0, 12)
|
.slice(0, 12)
|
||||||
previewItems.forEach((item) => {
|
|
||||||
if (item.credential && !item.credentialConsumed) {
|
|
||||||
deliveryCredentials.set(item.id, item.credential)
|
|
||||||
deliveryCredentialStates.set(item.id, { revealed: false, dismissed: false })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
persistDeliveryHistory()
|
persistDeliveryHistory()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1763,6 +2021,17 @@ h4 {
|
|||||||
background: var(--accent-dim);
|
background: var(--accent-dim);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.flow-tab:disabled,
|
||||||
|
.flow-tab.disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.52;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flow-tab:disabled:hover {
|
||||||
|
border-color: var(--line);
|
||||||
|
background: var(--bg-panel-2);
|
||||||
|
}
|
||||||
|
|
||||||
.workbench-grid {
|
.workbench-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1.18fr) minmax(360px, 0.82fr);
|
grid-template-columns: minmax(0, 1.18fr) minmax(360px, 0.82fr);
|
||||||
@@ -1832,6 +2101,19 @@ h4 {
|
|||||||
font-size: 11.5px;
|
font-size: 11.5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.instance-name-editor {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.random-name-button {
|
||||||
|
height: 32px;
|
||||||
|
padding: 0 8px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.form-field :deep(.el-select),
|
.form-field :deep(.el-select),
|
||||||
.form-field :deep(.el-input-number) {
|
.form-field :deep(.el-input-number) {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|||||||
@@ -5,15 +5,8 @@
|
|||||||
<h1>服务管理</h1>
|
<h1>服务管理</h1>
|
||||||
<p>同步基础服务实例台账,统一查看服务名 / 服务 IP / 业务标签 / 实例数</p>
|
<p>同步基础服务实例台账,统一查看服务名 / 服务 IP / 业务标签 / 实例数</p>
|
||||||
</div>
|
</div>
|
||||||
<el-button type="primary" :loading="serviceLoading || deliveryLoading" @click="refreshCurrentTab">同步</el-button>
|
<el-button type="primary" :loading="serviceLoading || deliveryLoading" @click="refreshServices">同步</el-button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-tabs v-model="activeTab" class="management-tabs">
|
|
||||||
<el-tab-pane label="服务台账" name="services" />
|
|
||||||
<el-tab-pane label="交付记录" name="delivery" />
|
|
||||||
</el-tabs>
|
|
||||||
|
|
||||||
<template v-if="activeTab === 'services'">
|
|
||||||
<div class="stat-row">
|
<div class="stat-row">
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<div class="label">接入 Datacenter</div>
|
<div class="label">接入 Datacenter</div>
|
||||||
@@ -84,12 +77,13 @@
|
|||||||
<th>实例数</th>
|
<th>实例数</th>
|
||||||
<th>健康实例</th>
|
<th>健康实例</th>
|
||||||
<th>示例 IP</th>
|
<th>示例 IP</th>
|
||||||
|
<th>交付记录</th>
|
||||||
<th>状态</th>
|
<th>状态</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-if="!serviceLoading && filteredServices.length === 0">
|
<tr v-if="!serviceLoading && filteredServices.length === 0">
|
||||||
<td colspan="7" class="text-dim">当前业务线暂无基础服务实例</td>
|
<td colspan="8" class="text-dim">当前业务线暂无基础服务实例</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr v-for="service in filteredServices" :key="service.name" class="tr-hover">
|
<tr v-for="service in filteredServices" :key="service.name" class="tr-hover">
|
||||||
<td class="strong mono">{{ service.name }}</td>
|
<td class="strong mono">{{ service.name }}</td>
|
||||||
@@ -98,6 +92,31 @@
|
|||||||
<td class="mono">{{ service.instances }}</td>
|
<td class="mono">{{ service.instances }}</td>
|
||||||
<td class="mono">{{ service.healthy }}</td>
|
<td class="mono">{{ service.healthy }}</td>
|
||||||
<td class="mono">{{ service.ip }}</td>
|
<td class="mono">{{ service.ip }}</td>
|
||||||
|
<td>
|
||||||
|
<el-dropdown
|
||||||
|
v-if="deliveryTasksForService(service.name).length"
|
||||||
|
trigger="click"
|
||||||
|
popper-class="delivery-record-dropdown"
|
||||||
|
@command="openDeliveryRecordById"
|
||||||
|
>
|
||||||
|
<el-button link type="primary" :icon="Tickets">查看 {{ deliveryTasksForService(service.name).length }} 条</el-button>
|
||||||
|
<template #dropdown>
|
||||||
|
<el-dropdown-menu>
|
||||||
|
<el-dropdown-item
|
||||||
|
v-for="task in deliveryTasksForService(service.name)"
|
||||||
|
:key="task.id"
|
||||||
|
:command="task.id"
|
||||||
|
>
|
||||||
|
<span class="delivery-dropdown-item">
|
||||||
|
<strong>{{ formatDeliveryTime(task.created_at, true) }}</strong>
|
||||||
|
<small>{{ deliveryStatusText(task.status) }} · {{ credentialStatusText(task) }}</small>
|
||||||
|
</span>
|
||||||
|
</el-dropdown-item>
|
||||||
|
</el-dropdown-menu>
|
||||||
|
</template>
|
||||||
|
</el-dropdown>
|
||||||
|
<span v-else class="text-dim">-</span>
|
||||||
|
</td>
|
||||||
<td :class="['status-text', service.statusClass]">● {{ service.status }}</td>
|
<td :class="['status-text', service.statusClass]">● {{ service.status }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -157,61 +176,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
|
||||||
|
|
||||||
<template v-else>
|
|
||||||
<div class="delivery-toolbar">
|
|
||||||
<el-input v-model="deliverySearch" placeholder="搜索实例名称 / 任务编号 / 主机" clearable />
|
|
||||||
<el-select v-model="deliveryStatusFilter" placeholder="全部交付状态">
|
|
||||||
<el-option label="全部交付状态" value="" />
|
|
||||||
<el-option v-for="(label, value) in deliveryStatusLabels" :key="value" :label="label" :value="value" />
|
|
||||||
</el-select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="panel delivery-record-panel">
|
|
||||||
<div class="panel-head">
|
|
||||||
<div>
|
|
||||||
<h3>基础服务交付记录</h3>
|
|
||||||
<span class="meta">共 {{ filteredDeliveryRecords.length }} 条 · 当前业务线:{{ currentName }}</span>
|
|
||||||
</div>
|
|
||||||
<span class="meta">{{ deliveryLoading ? '同步中...' : deliveryMetaText }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="panel-body">
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>实例名称</th>
|
|
||||||
<th>任务编号</th>
|
|
||||||
<th>连接地址</th>
|
|
||||||
<th>命名空间</th>
|
|
||||||
<th>交付状态</th>
|
|
||||||
<th>凭证</th>
|
|
||||||
<th>创建时间</th>
|
|
||||||
<th>操作</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr v-if="!deliveryLoading && filteredDeliveryRecords.length === 0">
|
|
||||||
<td colspan="8" class="delivery-empty">暂无交付记录</td>
|
|
||||||
</tr>
|
|
||||||
<tr v-for="task in filteredDeliveryRecords" :key="task.id" class="tr-hover">
|
|
||||||
<td><strong class="mono">{{ task.instance_name }}</strong></td>
|
|
||||||
<td class="mono">{{ task.id }}</td>
|
|
||||||
<td class="mono">{{ deliveryEndpoint(task) }}</td>
|
|
||||||
<td class="mono">{{ task.namespace }}</td>
|
|
||||||
<td><span class="tag" :class="deliveryStatusClass(task.status)">{{ deliveryStatusText(task.status) }}</span></td>
|
|
||||||
<td><span class="tag" :class="credentialStatusClass(task)">{{ credentialStatusText(task) }}</span></td>
|
|
||||||
<td class="mono">{{ formatDeliveryTime(task.created_at) }}</td>
|
|
||||||
<td class="delivery-table-actions">
|
|
||||||
<el-button link type="primary" @click="openDeliveryDetail(task)">查看详情</el-button>
|
|
||||||
<el-button v-if="credentialEligible(task)" link type="warning" @click="openDeliveryDetail(task)">领取凭证</el-button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<el-dialog v-model="deliveryDetailVisible" width="640px" align-center destroy-on-close>
|
<el-dialog v-model="deliveryDetailVisible" width="640px" align-center destroy-on-close>
|
||||||
<template #header>
|
<template #header>
|
||||||
@@ -270,7 +234,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, watch } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { CircleCheck, CopyDocument, Download } from '@element-plus/icons-vue'
|
import { CircleCheck, CopyDocument, Download, Tickets } from '@element-plus/icons-vue'
|
||||||
import { containerServiceApi, emptyContainerServiceSummary, type ContainerServiceSummary, type ContainerWorkload } from '@/api/containerService'
|
import { containerServiceApi, emptyContainerServiceSummary, type ContainerServiceSummary, type ContainerWorkload } from '@/api/containerService'
|
||||||
import { deliveryApi, type DeliveryTask, type DeploymentCredential, type MySQLServiceLedgerItem } from '@/api/delivery'
|
import { deliveryApi, type DeliveryTask, type DeploymentCredential, type MySQLServiceLedgerItem } from '@/api/delivery'
|
||||||
import { useBusinessLineStore } from '@/stores/businessLine'
|
import { useBusinessLineStore } from '@/stores/businessLine'
|
||||||
@@ -296,12 +260,8 @@ const services = ref<ServiceLedgerRow[]>([])
|
|||||||
const serviceLoading = ref(false)
|
const serviceLoading = ref(false)
|
||||||
const serviceError = ref('')
|
const serviceError = ref('')
|
||||||
|
|
||||||
const activeTab = ref('services')
|
|
||||||
const deliveryRecords = ref<DeliveryTask[]>([])
|
const deliveryRecords = ref<DeliveryTask[]>([])
|
||||||
const deliveryLoading = ref(false)
|
const deliveryLoading = ref(false)
|
||||||
const deliveryError = ref('')
|
|
||||||
const deliverySearch = ref('')
|
|
||||||
const deliveryStatusFilter = ref('')
|
|
||||||
const deliveryDetailVisible = ref(false)
|
const deliveryDetailVisible = ref(false)
|
||||||
const selectedDeliveryTask = ref<DeliveryTask>()
|
const selectedDeliveryTask = ref<DeliveryTask>()
|
||||||
const revealedCredentials = ref<DeploymentCredential[]>([])
|
const revealedCredentials = ref<DeploymentCredential[]>([])
|
||||||
@@ -350,18 +310,6 @@ const deliveryStatusLabels: Record<string, string> = {
|
|||||||
rollback_acknowledged: '已确认清理',
|
rollback_acknowledged: '已确认清理',
|
||||||
}
|
}
|
||||||
|
|
||||||
const deliveryMetaText = computed(() => deliveryError.value || '数据源:delivery_tasks')
|
|
||||||
|
|
||||||
const filteredDeliveryRecords = computed(() => {
|
|
||||||
const keyword = deliverySearch.value.trim().toLowerCase()
|
|
||||||
return deliveryRecords.value.filter((task) => {
|
|
||||||
const matchesKeyword = !keyword || [task.instance_name, task.id, task.target_host, task.target_host_ip]
|
|
||||||
.some((value) => value?.toLowerCase().includes(keyword))
|
|
||||||
const matchesStatus = !deliveryStatusFilter.value || task.status === deliveryStatusFilter.value
|
|
||||||
return matchesKeyword && matchesStatus
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const credentialClipboardText = computed(() =>
|
const credentialClipboardText = computed(() =>
|
||||||
revealedCredentials.value.map((item) => `${item.username}@${item.host} ${item.password}`).join('\n'),
|
revealedCredentials.value.map((item) => `${item.username}@${item.host} ${item.password}`).join('\n'),
|
||||||
)
|
)
|
||||||
@@ -373,25 +321,21 @@ const loadDeliveryRecords = async () => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
deliveryLoading.value = true
|
deliveryLoading.value = true
|
||||||
deliveryError.value = ''
|
|
||||||
try {
|
try {
|
||||||
const items = await deliveryApi.listTasks({ businessLineId, component: 'mysql' })
|
const items = await deliveryApi.listTasks({ businessLineId, component: 'mysql' })
|
||||||
deliveryRecords.value = items.sort((a, b) => Date.parse(b.created_at) - Date.parse(a.created_at))
|
deliveryRecords.value = items.sort((a, b) => Date.parse(b.created_at) - Date.parse(a.created_at))
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
deliveryRecords.value = []
|
deliveryRecords.value = []
|
||||||
deliveryError.value = error instanceof Error ? error.message : '交付记录同步失败'
|
ElMessage.error(error instanceof Error ? error.message : '交付记录同步失败')
|
||||||
} finally {
|
} finally {
|
||||||
deliveryLoading.value = false
|
deliveryLoading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const refreshCurrentTab = () => {
|
const refreshServices = () => {
|
||||||
if (activeTab.value === 'delivery') {
|
loadServiceLedger()
|
||||||
loadDeliveryRecords()
|
loadContainerServices()
|
||||||
} else {
|
loadDeliveryRecords()
|
||||||
loadServiceLedger()
|
|
||||||
loadContainerServices()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function openDeliveryDetail(task: DeliveryTask) {
|
function openDeliveryDetail(task: DeliveryTask) {
|
||||||
@@ -400,6 +344,15 @@ function openDeliveryDetail(task: DeliveryTask) {
|
|||||||
deliveryDetailVisible.value = true
|
deliveryDetailVisible.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openDeliveryRecordById(id: string) {
|
||||||
|
const task = deliveryRecords.value.find((item) => item.id === id)
|
||||||
|
if (task) openDeliveryDetail(task)
|
||||||
|
}
|
||||||
|
|
||||||
|
function deliveryTasksForService(serviceName: string) {
|
||||||
|
return deliveryRecords.value.filter((task) => task.instance_name === serviceName)
|
||||||
|
}
|
||||||
|
|
||||||
function credentialEligible(task: DeliveryTask) {
|
function credentialEligible(task: DeliveryTask) {
|
||||||
return ['finished', 'register_failed'].includes(task.status) && !consumedCredentialTasks.value.has(task.id)
|
return ['finished', 'register_failed'].includes(task.status) && !consumedCredentialTasks.value.has(task.id)
|
||||||
}
|
}
|
||||||
@@ -564,43 +517,28 @@ watch(deliveryDetailVisible, (visible) => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
/* 台账部分样式在 global.css 中定义;以下为交付记录专属样式 */
|
/* 台账部分样式在 global.css 中定义;以下为交付详情专属样式 */
|
||||||
.management-tabs {
|
:global(.delivery-dropdown-item) {
|
||||||
margin-bottom: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.management-tabs :deep(.el-tabs__header) {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.delivery-toolbar {
|
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(280px, 1fr) 220px;
|
gap: 3px;
|
||||||
gap: 12px;
|
min-width: 220px;
|
||||||
margin-bottom: 14px;
|
padding: 3px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.delivery-record-panel .panel-head > div {
|
:global(.delivery-dropdown-item strong) {
|
||||||
display: grid;
|
color: var(--text-hi);
|
||||||
gap: 4px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.delivery-record-panel .panel-body {
|
:global(.delivery-dropdown-item small) {
|
||||||
overflow-x: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.delivery-record-panel table {
|
|
||||||
min-width: 1120px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.delivery-table-actions {
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.delivery-empty {
|
|
||||||
padding: 48px 16px;
|
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
text-align: center;
|
font-size: 10.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.delivery-record-dropdown .el-dropdown-menu) {
|
||||||
|
max-height: min(420px, calc(100vh - 32px));
|
||||||
|
overflow-y: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
.delivery-dialog-head,
|
.delivery-dialog-head,
|
||||||
|
|||||||
@@ -552,7 +552,18 @@ func (s *DeliveryService) CreateTask(ctx context.Context, userID uint64, isAdmin
|
|||||||
PayloadHash: hex.EncodeToString(digest[:]),
|
PayloadHash: hex.EncodeToString(digest[:]),
|
||||||
IdempotencyKey: idempotencyKey,
|
IdempotencyKey: idempotencyKey,
|
||||||
}
|
}
|
||||||
if err := s.db.WithContext(ctx).Create(&task).Error; err != nil {
|
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
// Serialize name allocation per business line so concurrent requests cannot
|
||||||
|
// pass the availability check and create the same instance identity.
|
||||||
|
var businessLine model.BusinessLine
|
||||||
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&businessLine, input.BusinessLineID).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := ensureInstanceNameAvailable(tx, input.BusinessLineID, task.Component, input.InstanceName); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Create(&task).Error
|
||||||
|
}); err != nil {
|
||||||
if lookupErr := s.db.WithContext(ctx).Where("idempotency_key = ?", idempotencyKey).First(&existing).Error; lookupErr == nil {
|
if lookupErr := s.db.WithContext(ctx).Where("idempotency_key = ?", idempotencyKey).First(&existing).Error; lookupErr == nil {
|
||||||
return &existing, true, nil
|
return &existing, true, nil
|
||||||
}
|
}
|
||||||
@@ -567,6 +578,45 @@ func (s *DeliveryService) CreateTask(ctx context.Context, userID uint64, isAdmin
|
|||||||
return &task, false, nil
|
return &task, false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ensureInstanceNameAvailable treats in-flight tasks, retained failed deployments,
|
||||||
|
// and active results as name reservations. A name is reusable only after rollback
|
||||||
|
// or explicit cleanup has released the corresponding task/result.
|
||||||
|
func ensureInstanceNameAvailable(tx *gorm.DB, businessLineID uint64, component, instanceName string) error {
|
||||||
|
occupiedTaskStatuses := []string{
|
||||||
|
model.TaskPending,
|
||||||
|
model.TaskValidating,
|
||||||
|
model.TaskDispatching,
|
||||||
|
model.TaskRunning,
|
||||||
|
model.TaskRegistering,
|
||||||
|
model.TaskFinished,
|
||||||
|
model.TaskRegisterFailed,
|
||||||
|
model.TaskCanceling,
|
||||||
|
model.TaskRollbackPending,
|
||||||
|
model.TaskRollingBack,
|
||||||
|
model.TaskRollbackFailed,
|
||||||
|
}
|
||||||
|
var taskCount int64
|
||||||
|
if err := tx.Model(&model.DeliveryTask{}).
|
||||||
|
Where("business_line_id = ? AND component = ? AND instance_name = ? AND status IN ?", businessLineID, component, instanceName, occupiedTaskStatuses).
|
||||||
|
Count(&taskCount).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if taskCount > 0 {
|
||||||
|
return fmt.Errorf("instance_name %q is already in use by an active or pending delivery", instanceName)
|
||||||
|
}
|
||||||
|
|
||||||
|
var resultCount int64
|
||||||
|
if err := tx.Model(&model.DeploymentResult{}).
|
||||||
|
Where("business_line_id = ? AND component = ? AND instance_name = ? AND status = ?", businessLineID, component, instanceName, "active").
|
||||||
|
Count(&resultCount).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if resultCount > 0 {
|
||||||
|
return fmt.Errorf("instance_name %q is already in use by a delivered instance", instanceName)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// 版本白名单与 playbook 的 mysql_package_map 保持同步:
|
// 版本白名单与 playbook 的 mysql_package_map 保持同步:
|
||||||
// 8.0 走 Ubuntu 自带源,8.4 走 MySQL 官方 APT 源;5.6/5.7 已 EOL 且无 noble 包,不支持。
|
// 8.0 走 Ubuntu 自带源,8.4 走 MySQL 官方 APT 源;5.6/5.7 已 EOL 且无 noble 包,不支持。
|
||||||
var supportedMySQLVersions = map[string]bool{"8.0": true, "8.4": true}
|
var supportedMySQLVersions = map[string]bool{"8.0": true, "8.4": true}
|
||||||
|
|||||||
Reference in New Issue
Block a user