feat(ui): harden MySQL delivery workbench session and credential flow

- Persist workbench state (form, view, task progress) per user/service in sessionStorage; restore on reload re-fetches the task and resumes polling, never persisting the root password itself
- Gate the result tab behind resultReady so an unfinished task cannot show a stale result view
- Add random instance name generation and an async precheck that detects name conflicts against active tasks and delivered instances, mirroring the backend occupancy rules
- Guarantee all four character classes in generated root passwords
- Fetch credentials via the one-time reveal endpoint instead of caching plaintext passwords in localStorage; sanitize legacy history entries and clear the form password once the task is created
This commit is contained in:
Hungerdream
2026-07-29 13:50:18 +08:00
parent ecad474785
commit 244f6adfb8
+354 -72
View File
@@ -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%;