feat(delivery): refine base service integrations
This commit is contained in:
@@ -14,6 +14,11 @@ export interface WayneNamespace {
|
||||
kubeNamespace: string
|
||||
}
|
||||
|
||||
export interface SinaOrganization {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export const businessLineApi = {
|
||||
async listMine(): Promise<BusinessLine[]> {
|
||||
const token = getToken()
|
||||
@@ -87,6 +92,28 @@ export const businessLineApi = {
|
||||
body: JSON.stringify({ namespaces }),
|
||||
})
|
||||
},
|
||||
|
||||
async listSinaOrganizations(businessLineId: number, keyword = ''): Promise<SinaOrganization[]> {
|
||||
const params = new URLSearchParams()
|
||||
if (keyword.trim()) {
|
||||
params.set('keyword', keyword.trim())
|
||||
}
|
||||
const suffix = params.toString() ? `?${params.toString()}` : ''
|
||||
const data = await request(`/auth/api/v1/business-lines/${businessLineId}/sina-organizations${suffix}`)
|
||||
return Array.isArray(data.items) ? data.items : []
|
||||
},
|
||||
|
||||
async listMappedSinaOrganizations(businessLineId: number): Promise<SinaOrganization[]> {
|
||||
const data = await request(`/auth/api/v1/business-lines/${businessLineId}/sina-organization-mappings`)
|
||||
return Array.isArray(data.items) ? data.items : []
|
||||
},
|
||||
|
||||
async replaceMappedSinaOrganizations(businessLineId: number, organizations: SinaOrganization[]): Promise<void> {
|
||||
await request(`/auth/api/v1/business-lines/${businessLineId}/sina-organization-mappings`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ organizations }),
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
async function request(path: string, init: RequestInit = {}) {
|
||||
|
||||
@@ -66,6 +66,7 @@ export interface DeliveryTask {
|
||||
updated_at: string
|
||||
started_at?: string
|
||||
finished_at?: string
|
||||
credential_available?: boolean
|
||||
}
|
||||
|
||||
export interface TaskEvent {
|
||||
@@ -85,8 +86,12 @@ export interface DeliveryTaskSnapshot {
|
||||
}
|
||||
|
||||
export interface DeploymentCredential {
|
||||
username: string
|
||||
service: string
|
||||
instance_name: string
|
||||
host: string
|
||||
port: number
|
||||
username: string
|
||||
account_host: string
|
||||
password: string
|
||||
}
|
||||
|
||||
@@ -109,8 +114,13 @@ export const deliveryApi = {
|
||||
return Array.isArray(data.items) ? data.items : []
|
||||
},
|
||||
|
||||
async listTargetMountPaths(targetId: number, host: string): Promise<DeliveryMountPath[]> {
|
||||
const data = await authRequest(`/auth/api/v1/delivery/targets/${targetId}/hosts/${encodeURIComponent(host)}/mount-paths`)
|
||||
async listTargetMountPaths(targetId: number, host: string, prefix = ''): Promise<DeliveryMountPath[]> {
|
||||
const search = new URLSearchParams()
|
||||
if (prefix) search.set('prefix', prefix)
|
||||
const query = search.toString()
|
||||
const data = await authRequest(
|
||||
`/auth/api/v1/delivery/targets/${targetId}/hosts/${encodeURIComponent(host)}/mount-paths${query ? `?${query}` : ''}`,
|
||||
)
|
||||
return Array.isArray(data.items) ? data.items : []
|
||||
},
|
||||
|
||||
|
||||
@@ -46,13 +46,13 @@ export interface MachineResourceList {
|
||||
export interface MachineResourceQuery {
|
||||
page: number
|
||||
size: number
|
||||
businessLineId?: number
|
||||
hostname?: string
|
||||
assetNumber?: string
|
||||
type?: string
|
||||
location?: string
|
||||
ip?: string
|
||||
spec?: string
|
||||
businessLine?: string
|
||||
source?: string
|
||||
status?: string
|
||||
}
|
||||
@@ -83,21 +83,24 @@ export const emptyMachineOverview: MachineOverview = {
|
||||
}
|
||||
|
||||
export const machineApi = {
|
||||
async getOverview(): Promise<MachineOverview> {
|
||||
return authRequest('/auth/api/v1/machines/overview')
|
||||
async getOverview(businessLineId?: number): Promise<MachineOverview> {
|
||||
const params = new URLSearchParams()
|
||||
if (businessLineId) params.set('business_line_id', String(businessLineId))
|
||||
const suffix = params.toString() ? `?${params.toString()}` : ''
|
||||
return authRequest(`/auth/api/v1/machines/overview${suffix}`)
|
||||
},
|
||||
|
||||
async listResources(query: MachineResourceQuery): Promise<MachineResourceList> {
|
||||
const params = new URLSearchParams()
|
||||
params.set('page', String(query.page))
|
||||
params.set('size', String(query.size))
|
||||
if (query.businessLineId) params.set('business_line_id', String(query.businessLineId))
|
||||
if (query.hostname) params.set('hostname', query.hostname)
|
||||
if (query.assetNumber) params.set('assetNumber', query.assetNumber)
|
||||
if (query.type) params.set('type', query.type)
|
||||
if (query.location) params.set('location', query.location)
|
||||
if (query.ip) params.set('ip', query.ip)
|
||||
if (query.spec) params.set('spec', query.spec)
|
||||
if (query.businessLine) params.set('businessLine', query.businessLine)
|
||||
if (query.source) params.set('source', query.source)
|
||||
if (query.status) params.set('status', query.status)
|
||||
const data = await authRequest(`/auth/api/v1/machines/resources?${params.toString()}`)
|
||||
|
||||
@@ -45,6 +45,34 @@
|
||||
</el-form-item>
|
||||
<el-button type="primary" :loading="savingWayneNamespaces" @click="saveWayneNamespaceMapping">保存 Wayne namespace 映射</el-button>
|
||||
</el-form>
|
||||
|
||||
<div class="section-divider"></div>
|
||||
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="SINA 映射">
|
||||
<el-select
|
||||
v-model="selectedSinaOrganizationIds"
|
||||
multiple
|
||||
filterable
|
||||
remote
|
||||
reserve-keyword
|
||||
collapse-tags
|
||||
collapse-tags-tooltip
|
||||
:loading="loadingSinaOrganizations"
|
||||
:remote-method="searchSinaOrganizations"
|
||||
placeholder="选择 SINA 业务线"
|
||||
@visible-change="handleSinaSelectVisible"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in sinaOrganizations"
|
||||
:key="item.id"
|
||||
:label="item.name"
|
||||
:value="item.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-button type="primary" :loading="savingSinaOrganizations" @click="saveSinaOrganizationMapping">保存 SINA 映射</el-button>
|
||||
</el-form>
|
||||
</div>
|
||||
<div v-else class="empty-state">需要当前业务线管理员权限</div>
|
||||
</section>
|
||||
@@ -53,7 +81,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { businessLineApi, type WayneNamespace } from '@/api/businessLine'
|
||||
import { businessLineApi, type SinaOrganization, type WayneNamespace } from '@/api/businessLine'
|
||||
import { userApi, type UserOption } from '@/api/user'
|
||||
import { useBusinessLineStore } from '@/stores/businessLine'
|
||||
|
||||
@@ -63,9 +91,13 @@ const currentName = computed(() => businessLineStore.current?.name || '未选择
|
||||
const users = ref<UserOption[]>([])
|
||||
const wayneNamespaces = ref<WayneNamespace[]>([])
|
||||
const selectedWayneNamespaceIds = ref<number[]>([])
|
||||
const sinaOrganizations = ref<SinaOrganization[]>([])
|
||||
const selectedSinaOrganizationIds = ref<string[]>([])
|
||||
const granting = ref(false)
|
||||
const loadingWayneNamespaces = ref(false)
|
||||
const savingWayneNamespaces = ref(false)
|
||||
const loadingSinaOrganizations = ref(false)
|
||||
const savingSinaOrganizations = ref(false)
|
||||
const grantForm = reactive<{
|
||||
target_user_id: number | null
|
||||
target_business_line_id: number | null
|
||||
@@ -82,6 +114,7 @@ watch(
|
||||
}
|
||||
if (id && isCurrentBusinessLineAdmin.value) {
|
||||
loadWayneNamespaceMapping(id)
|
||||
loadSinaOrganizationMapping(id)
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
@@ -98,6 +131,7 @@ watch(
|
||||
const businessLineID = businessLineStore.current?.id
|
||||
if (businessLineID) {
|
||||
await loadWayneNamespaceMapping(businessLineID)
|
||||
await loadSinaOrganizationMapping(businessLineID)
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -113,6 +147,7 @@ onMounted(async () => {
|
||||
const businessLineID = businessLineStore.current?.id
|
||||
if (businessLineID) {
|
||||
await loadWayneNamespaceMapping(businessLineID)
|
||||
await loadSinaOrganizationMapping(businessLineID)
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -182,6 +217,75 @@ async function saveWayneNamespaceMapping() {
|
||||
savingWayneNamespaces.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSinaSelectVisible(visible: boolean) {
|
||||
if (!visible || sinaOrganizations.value.length) {
|
||||
return
|
||||
}
|
||||
await searchSinaOrganizations('')
|
||||
}
|
||||
|
||||
async function searchSinaOrganizations(keyword: string) {
|
||||
const businessLineID = businessLineStore.current?.id
|
||||
if (!businessLineID) {
|
||||
return
|
||||
}
|
||||
loadingSinaOrganizations.value = true
|
||||
try {
|
||||
const rows = await businessLineApi.listSinaOrganizations(businessLineID, keyword)
|
||||
sinaOrganizations.value = mergeSinaOrganizations(rows, selectedSinaOrganizations.value)
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '查询 SINA 业务线失败')
|
||||
} finally {
|
||||
loadingSinaOrganizations.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const selectedSinaOrganizations = computed(() =>
|
||||
sinaOrganizations.value.filter((item) => selectedSinaOrganizationIds.value.includes(item.id)),
|
||||
)
|
||||
|
||||
async function loadSinaOrganizationMapping(businessLineID: number) {
|
||||
loadingSinaOrganizations.value = true
|
||||
try {
|
||||
const mapped = await businessLineApi.listMappedSinaOrganizations(businessLineID)
|
||||
sinaOrganizations.value = mergeSinaOrganizations(sinaOrganizations.value, mapped)
|
||||
selectedSinaOrganizationIds.value = mapped.map((item) => item.id)
|
||||
} catch (error) {
|
||||
selectedSinaOrganizationIds.value = []
|
||||
ElMessage.error(error instanceof Error ? error.message : '查询 SINA 映射失败')
|
||||
} finally {
|
||||
loadingSinaOrganizations.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSinaOrganizationMapping() {
|
||||
const businessLineID = businessLineStore.current?.id
|
||||
if (!businessLineID) {
|
||||
ElMessage.warning('请选择当前业务线')
|
||||
return
|
||||
}
|
||||
const selected = sinaOrganizations.value.filter((item) => selectedSinaOrganizationIds.value.includes(item.id))
|
||||
savingSinaOrganizations.value = true
|
||||
try {
|
||||
await businessLineApi.replaceMappedSinaOrganizations(businessLineID, selected)
|
||||
ElMessage.success('已保存 SINA 映射')
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '保存 SINA 映射失败')
|
||||
} finally {
|
||||
savingSinaOrganizations.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function mergeSinaOrganizations(...groups: SinaOrganization[][]) {
|
||||
const rows = new Map<string, SinaOrganization>()
|
||||
for (const group of groups) {
|
||||
for (const item of group) {
|
||||
rows.set(item.id, item)
|
||||
}
|
||||
}
|
||||
return Array.from(rows.values())
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -51,7 +51,6 @@
|
||||
<th>机房 / 区域</th>
|
||||
<th>内网 IP</th>
|
||||
<th>规格</th>
|
||||
<th>业务线</th>
|
||||
<th>数据来源</th>
|
||||
<th>状态</th>
|
||||
</tr>
|
||||
@@ -112,16 +111,6 @@
|
||||
@keyup.enter="reloadFirstPage"
|
||||
/>
|
||||
</th>
|
||||
<th>
|
||||
<el-input
|
||||
v-model="filters.businessLine"
|
||||
clearable
|
||||
size="small"
|
||||
placeholder="筛选业务线"
|
||||
@clear="reloadFirstPage"
|
||||
@keyup.enter="reloadFirstPage"
|
||||
/>
|
||||
</th>
|
||||
<th>
|
||||
<el-select v-model="filters.source" clearable size="small" placeholder="全部" @change="reloadFirstPage">
|
||||
<el-option label="SINA CMDB" value="cmdb" />
|
||||
@@ -149,12 +138,11 @@
|
||||
<td><span class="tag zone-a">{{ item.location }}</span></td>
|
||||
<td class="mono">{{ item.ip }}</td>
|
||||
<td class="mono text-xs">{{ item.spec }}</td>
|
||||
<td class="mono">{{ item.businessLine }}</td>
|
||||
<td><span class="tag src-cmdb">{{ item.source }}</span></td>
|
||||
<td class="status-text ok">● {{ item.status }}</td>
|
||||
</tr>
|
||||
<tr v-if="!loading && resources.length === 0">
|
||||
<td colspan="9" class="empty-cell">暂无机器数据</td>
|
||||
<td colspan="8" class="empty-cell">当前业务线暂无机器数据</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -177,10 +165,12 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { emptyMachineOverview, machineApi, type MachineResource } from '@/api/machine'
|
||||
import { useBusinessLineStore } from '@/stores/businessLine'
|
||||
|
||||
const businessLineStore = useBusinessLineStore()
|
||||
const overview = ref(emptyMachineOverview)
|
||||
const resources = ref<MachineResource[]>([])
|
||||
const total = ref(0)
|
||||
@@ -195,7 +185,6 @@ const filters = reactive({
|
||||
location: '',
|
||||
ip: '',
|
||||
spec: '',
|
||||
businessLine: '',
|
||||
source: '',
|
||||
status: '',
|
||||
})
|
||||
@@ -207,22 +196,33 @@ const syncStatusText = computed(() => {
|
||||
})
|
||||
|
||||
async function loadOverview() {
|
||||
overview.value = await machineApi.getOverview()
|
||||
const businessLineId = businessLineStore.current?.id
|
||||
if (!businessLineId) {
|
||||
overview.value = emptyMachineOverview
|
||||
return
|
||||
}
|
||||
overview.value = await machineApi.getOverview(businessLineId)
|
||||
}
|
||||
|
||||
async function loadResources() {
|
||||
const businessLineId = businessLineStore.current?.id
|
||||
if (!businessLineId) {
|
||||
resources.value = []
|
||||
total.value = 0
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await machineApi.listResources({
|
||||
page: page.value,
|
||||
size: size.value,
|
||||
businessLineId,
|
||||
hostname: filters.hostname.trim(),
|
||||
assetNumber: filters.assetNumber.trim(),
|
||||
type: filters.type,
|
||||
location: filters.location.trim(),
|
||||
ip: filters.ip.trim(),
|
||||
spec: filters.spec.trim(),
|
||||
businessLine: filters.businessLine.trim(),
|
||||
source: filters.source,
|
||||
status: filters.status,
|
||||
})
|
||||
@@ -252,6 +252,14 @@ async function handleSync() {
|
||||
onMounted(async () => {
|
||||
await Promise.all([loadOverview(), loadResources()])
|
||||
})
|
||||
|
||||
watch(
|
||||
() => businessLineStore.current?.id,
|
||||
() => {
|
||||
page.value = 1
|
||||
Promise.all([loadOverview(), loadResources()])
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -176,7 +176,20 @@
|
||||
<div class="form-grid resource-fields">
|
||||
<label class="form-field" :class="{ 'is-invalid': dataDiskError }">
|
||||
数据盘挂载点
|
||||
<el-input v-model="deliveryForm.dataDisk" placeholder="/data" />
|
||||
<el-autocomplete
|
||||
v-model="deliveryForm.dataDisk"
|
||||
:fetch-suggestions="queryMountPathSuggestions"
|
||||
placeholder="/data"
|
||||
:loading="mountPathsLoading"
|
||||
clearable
|
||||
>
|
||||
<template #default="{ item }">
|
||||
<div class="mount-path-option">
|
||||
<span>{{ item.path }}</span>
|
||||
<small>{{ mountPathMeta(item) }}</small>
|
||||
</div>
|
||||
</template>
|
||||
</el-autocomplete>
|
||||
<small v-if="dataDiskError" class="field-error">{{ dataDiskError }}</small>
|
||||
<small v-else class="field-help">填写目标主机上的绝对挂载点,例如 /data 或 /data/ax;实例数据会写入其下的标准目录。</small>
|
||||
</label>
|
||||
@@ -415,10 +428,20 @@
|
||||
<p>请立即复制或下载凭证文件;关闭后页面不再显示明文密码。</p>
|
||||
<el-button v-if="!credentialRevealed" type="warning" :loading="credentialRevealing" @click="revealCredential">查看一次性密码</el-button>
|
||||
<div v-else class="credential-secret">
|
||||
<code>{{ revealedRootCredential?.password }}</code>
|
||||
<div v-for="credential in revealedCredentials" :key="`${credential.username}@${credential.account_host || credential.host}`" class="credential-account">
|
||||
<div class="credential-field">
|
||||
<span>用户名</span>
|
||||
<code>{{ formatCredentialUsername(credential) }}</code>
|
||||
<el-button text :icon="CopyDocument" aria-label="复制用户名" title="复制用户名" @click="copyText(formatCredentialUsername(credential), '用户名已复制')" />
|
||||
</div>
|
||||
<div class="credential-field">
|
||||
<span>密码</span>
|
||||
<code>{{ credential.password }}</code>
|
||||
<el-button text :icon="CopyDocument" aria-label="复制密码" title="复制密码" @click="copyText(credential.password, '密码已复制')" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="credential-actions">
|
||||
<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">下载 Excel 凭证</el-button>
|
||||
<el-button type="success" plain :icon="CircleCheck" @click="dismissCredential">已保存,关闭</el-button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -495,7 +518,7 @@ import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
|
||||
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 { useRoute, useRouter } from 'vue-router'
|
||||
import { deliveryApi, type DeliveryTarget, type DeploymentCredential, type TaskEvent } from '@/api/delivery'
|
||||
import { deliveryApi, type DeliveryMountPath, type DeliveryTarget, type DeploymentCredential, type TaskEvent } from '@/api/delivery'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useBusinessLineStore } from '@/stores/businessLine'
|
||||
import { useBusinessLineMockProfile } from '@/utils/businessLineMock'
|
||||
@@ -780,6 +803,8 @@ const targetsLoading = ref(false)
|
||||
const pollTimer = ref<number | undefined>()
|
||||
const seenEventIds = ref(new Set<number>())
|
||||
const deliveryLog = ref('[ready] 等待创建交付任务...')
|
||||
const mountPathsLoading = ref(false)
|
||||
let mountPathSuggestSeq = 0
|
||||
const lastDeliveryStatus = ref('')
|
||||
const deliveryRolledBack = computed(() => lastDeliveryStatus.value === 'rolled_back')
|
||||
const deliveryAcknowledged = computed(() => lastDeliveryStatus.value === 'rollback_acknowledged')
|
||||
@@ -1536,6 +1561,48 @@ function mysqlVersionValue(version: string) {
|
||||
return matched.split('.').slice(0, 2).join('.')
|
||||
}
|
||||
|
||||
function mountPathMeta(item: DeliveryMountPath) {
|
||||
const parts = []
|
||||
if (item.available_gi > 0) parts.push(`可用 ${item.available_gi}GiB`)
|
||||
if (item.fstype) parts.push(item.fstype)
|
||||
return parts.join(' · ')
|
||||
}
|
||||
|
||||
function queryMountPathSuggestions(query: string, callback: (items: Array<DeliveryMountPath & { value: string }>) => void) {
|
||||
const prefix = query.trim()
|
||||
if (!prefix) {
|
||||
callback(defaultMountPathOptions().map((item) => ({ ...item, value: item.path })))
|
||||
return
|
||||
}
|
||||
const host = deliveryForm.targetHost || targetHosts.value[0]?.name || ''
|
||||
if (!selectedTargetId.value || !host || !prefix.startsWith('/')) {
|
||||
callback([])
|
||||
return
|
||||
}
|
||||
|
||||
const seq = ++mountPathSuggestSeq
|
||||
mountPathsLoading.value = true
|
||||
deliveryApi
|
||||
.listTargetMountPaths(selectedTargetId.value, host, prefix)
|
||||
.then((items) => {
|
||||
if (seq !== mountPathSuggestSeq) return
|
||||
callback(items.map((item) => ({ ...item, value: item.path })))
|
||||
})
|
||||
.catch(() => {
|
||||
if (seq !== mountPathSuggestSeq) return
|
||||
callback([])
|
||||
})
|
||||
.finally(() => {
|
||||
if (seq === mountPathSuggestSeq) {
|
||||
mountPathsLoading.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function defaultMountPathOptions(): DeliveryMountPath[] {
|
||||
return ['/data', '/disk1', '/mnt', '/opt/mysql-delivery'].map((path) => ({ path, available_gi: 0 }))
|
||||
}
|
||||
|
||||
function normalizeDNSLabel(value: string) {
|
||||
const normalized = value
|
||||
.toLowerCase()
|
||||
@@ -1552,7 +1619,6 @@ function generateRootPassword() {
|
||||
'ABCDEFGHJKLMNPQRSTUVWXYZ',
|
||||
'abcdefghijkmnopqrstuvwxyz',
|
||||
'23456789',
|
||||
'!@#$%^&*_-+=',
|
||||
]
|
||||
const alphabet = groups.join('')
|
||||
const pick = (characters: string) => {
|
||||
@@ -1576,8 +1642,9 @@ function generateRootPassword() {
|
||||
function rootPasswordValidationError(value: string) {
|
||||
if (!value) return '请输入 root 密码'
|
||||
if (value.length < 16 || value.length > 64) return '密码长度必须为 16–64 位'
|
||||
if (!/[A-Z]/.test(value) || !/[a-z]/.test(value) || !/[0-9]/.test(value) || !/[^A-Za-z0-9]/.test(value)) {
|
||||
return '密码需同时包含大小写字母、数字和特殊字符'
|
||||
if (!/^[A-Za-z0-9]+$/.test(value)) return '密码只能包含大小写字母和数字'
|
||||
if (!/[A-Z]/.test(value) || !/[a-z]/.test(value) || !/[0-9]/.test(value)) {
|
||||
return '密码需同时包含大小写字母和数字'
|
||||
}
|
||||
return ''
|
||||
}
|
||||
@@ -1606,7 +1673,15 @@ async function revealCredential() {
|
||||
credentialRevealing.value = true
|
||||
try {
|
||||
if (deploymentId.value === 'PREVIEW-CREDENTIAL') {
|
||||
revealedCredentials.value = [{ username: 'root', host: 'localhost', password: deliveryForm.rootPassword }]
|
||||
revealedCredentials.value = [{
|
||||
service: 'mysql',
|
||||
instance_name: deliveryForm.instanceName,
|
||||
host: deliveredHost.value || '10.24.18.21',
|
||||
port: deliveredPort.value || 13306,
|
||||
username: 'root',
|
||||
account_host: 'localhost',
|
||||
password: deliveryForm.rootPassword,
|
||||
}]
|
||||
} else {
|
||||
revealedCredentials.value = await deliveryApi.revealCredentials(deploymentId.value)
|
||||
}
|
||||
@@ -1628,24 +1703,55 @@ function dismissCredential() {
|
||||
revealedCredentials.value = []
|
||||
}
|
||||
|
||||
function formatCredentialUsername(credential: DeploymentCredential) {
|
||||
return credential.account_host ? `${credential.username}@${credential.account_host}` : credential.username
|
||||
}
|
||||
|
||||
function escapeExcelCell(value: unknown) {
|
||||
return String(value ?? '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
function downloadCredentialFile() {
|
||||
const password = revealedRootCredential.value?.password
|
||||
if (!credentialRevealed.value || !password) return
|
||||
const content = [
|
||||
'# XInfra MySQL delivery credential',
|
||||
`instance=${deliveryForm.instanceName}`,
|
||||
`host=${deliveredHost.value}`,
|
||||
`port=${deliveredPort.value || ''}`,
|
||||
'username=root',
|
||||
`password=${password}`,
|
||||
].join('\n') + '\n'
|
||||
const url = URL.createObjectURL(new Blob([content], { type: 'text/plain;charset=utf-8' }))
|
||||
if (!credentialRevealed.value || !revealedCredentials.value.length) return
|
||||
const primary = revealedRootCredential.value || revealedCredentials.value[0]
|
||||
const headers = ['服务', '实例名称', '主机', '端口', '用户名', '账号 Host', '密码']
|
||||
const rows = revealedCredentials.value.map((credential) => [
|
||||
credential.service || activeService.value?.key || 'mysql',
|
||||
credential.instance_name || deliveryForm.instanceName,
|
||||
credential.host || deliveredHost.value,
|
||||
credential.port || deliveredPort.value || '',
|
||||
credential.username,
|
||||
credential.account_host || '',
|
||||
credential.password,
|
||||
])
|
||||
const tableRows = [headers, ...rows]
|
||||
.map((row) => `<tr>${row.map((cell) => `<td>${escapeExcelCell(cell)}</td>`).join('')}</tr>`)
|
||||
.join('')
|
||||
const content = `<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<style>
|
||||
table { border-collapse: collapse; }
|
||||
td { border: 1px solid #d9e2ef; padding: 8px; mso-number-format: "\\@"; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<table>${tableRows}</table>
|
||||
</body>
|
||||
</html>`
|
||||
const url = URL.createObjectURL(new Blob([content], { type: 'application/vnd.ms-excel;charset=utf-8' }))
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = `${deliveryForm.instanceName}-root-credential.txt`
|
||||
link.download = `${primary.instance_name || deliveryForm.instanceName}-credential.xls`
|
||||
link.click()
|
||||
URL.revokeObjectURL(url)
|
||||
ElMessage.success('凭证文件已下载,请妥善保管')
|
||||
ElMessage.success('Excel 凭证已下载,请妥善保管')
|
||||
}
|
||||
|
||||
function loadDeliveryHistory(): DeliveryHistoryItem[] {
|
||||
@@ -2795,7 +2901,25 @@ h4 {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.credential-secret > code {
|
||||
.credential-account {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.credential-field {
|
||||
display: grid;
|
||||
grid-template-columns: 64px minmax(0, 1fr) 32px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.credential-field > span {
|
||||
color: var(--text-dim);
|
||||
font-size: 11.5px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.credential-field > code {
|
||||
overflow-wrap: anywhere;
|
||||
padding: 9px 10px;
|
||||
border: 1px solid var(--line-soft);
|
||||
@@ -2805,6 +2929,11 @@ h4 {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.credential-field .el-button {
|
||||
width: 32px;
|
||||
min-width: 32px;
|
||||
}
|
||||
|
||||
.credential-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@@ -223,7 +223,7 @@
|
||||
<p>凭证仅可领取一次,领取后服务端立即销毁明文。</p>
|
||||
<el-button type="warning" :loading="credentialRevealing" @click="revealDeliveryCredential(selectedDeliveryTask)">领取一次性凭证</el-button>
|
||||
</template>
|
||||
<p v-else-if="consumedCredentialTasks.has(selectedDeliveryTask.id)">该任务的一次性凭证已领取或不可用,平台不再提供明文密码。</p>
|
||||
<p v-else-if="['finished', 'register_failed'].includes(selectedDeliveryTask.status)">该任务的一次性凭证已领取或不可用,平台不再提供明文密码。</p>
|
||||
<p v-else>仅交付成功的任务提供一次性凭证。</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -266,7 +266,6 @@ const deliveryDetailVisible = ref(false)
|
||||
const selectedDeliveryTask = ref<DeliveryTask>()
|
||||
const revealedCredentials = ref<DeploymentCredential[]>([])
|
||||
const credentialRevealing = ref(false)
|
||||
const consumedCredentialTasks = ref<Set<string>>(new Set())
|
||||
|
||||
const containerServices = ref<ContainerWorkload[]>([])
|
||||
const containerSummary = ref<ContainerServiceSummary>({ ...emptyContainerServiceSummary })
|
||||
@@ -354,32 +353,40 @@ function deliveryTasksForService(serviceName: string) {
|
||||
}
|
||||
|
||||
function credentialEligible(task: DeliveryTask) {
|
||||
return ['finished', 'register_failed'].includes(task.status) && !consumedCredentialTasks.value.has(task.id)
|
||||
return ['finished', 'register_failed'].includes(task.status) && Boolean(task.credential_available)
|
||||
}
|
||||
|
||||
function credentialStatusText(task: DeliveryTask) {
|
||||
if (consumedCredentialTasks.value.has(task.id)) return '已领取'
|
||||
return credentialEligible(task) ? '可领取' : '不提供'
|
||||
if (credentialEligible(task)) return '可领取'
|
||||
if (['finished', 'register_failed'].includes(task.status)) return '已领取'
|
||||
return '不提供'
|
||||
}
|
||||
|
||||
function credentialStatusClass(task: DeliveryTask) {
|
||||
if (consumedCredentialTasks.value.has(task.id)) return 'tag-green'
|
||||
return credentialEligible(task) ? 'tag-amber' : ''
|
||||
}
|
||||
|
||||
function markCredentialUnavailable(taskID: string) {
|
||||
const task = deliveryRecords.value.find((item) => item.id === taskID)
|
||||
if (task) task.credential_available = false
|
||||
if (selectedDeliveryTask.value?.id === taskID) {
|
||||
selectedDeliveryTask.value.credential_available = false
|
||||
}
|
||||
}
|
||||
|
||||
async function revealDeliveryCredential(task: DeliveryTask) {
|
||||
credentialRevealing.value = true
|
||||
try {
|
||||
const items = await deliveryApi.revealCredentials(task.id)
|
||||
if (items.length === 0) {
|
||||
consumedCredentialTasks.value.add(task.id)
|
||||
markCredentialUnavailable(task.id)
|
||||
ElMessage.warning('该任务没有可领取的凭证')
|
||||
return
|
||||
}
|
||||
revealedCredentials.value = items
|
||||
consumedCredentialTasks.value.add(task.id)
|
||||
markCredentialUnavailable(task.id)
|
||||
} catch (error) {
|
||||
consumedCredentialTasks.value.add(task.id)
|
||||
markCredentialUnavailable(task.id)
|
||||
ElMessage.error(error instanceof Error ? error.message : '凭证领取失败')
|
||||
} finally {
|
||||
credentialRevealing.value = false
|
||||
|
||||
@@ -42,11 +42,7 @@
|
||||
暂无任务记录
|
||||
</div>
|
||||
<div class="pagination">
|
||||
<span>共 {{ totalTasks }} 条 · 第 {{ currentPage }}/{{ totalPages }} 页 · 当前业务线:{{ currentName }}</span>
|
||||
<div class="pagination-actions">
|
||||
<el-button size="small" :disabled="currentPage <= 1" @click="prevPage">上一页</el-button>
|
||||
<el-button size="small" :disabled="currentPage >= totalPages" @click="nextPage">下一页</el-button>
|
||||
</div>
|
||||
<span>共 {{ tasks.length }} 条 · 当前业务线:{{ currentName }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -56,29 +52,11 @@
|
||||
<h3>
|
||||
<span>任务日志</span>
|
||||
<span class="selected-name">{{ selectedTaskName }}</span>
|
||||
<span v-if="isStreaming" class="streaming-indicator">● 实时更新中</span>
|
||||
<span v-if="streamRetryCount > 0" class="retry-indicator">重试中 ({{ streamRetryCount }}/3)</span>
|
||||
</h3>
|
||||
<div class="panel-actions">
|
||||
<el-switch
|
||||
v-model="autoScroll"
|
||||
active-text="自动滚动"
|
||||
class="auto-scroll-switch"
|
||||
/>
|
||||
<span class="meta">{{ selectedTaskMeta }}</span>
|
||||
</div>
|
||||
<span class="meta">{{ selectedTaskMeta }}</span>
|
||||
</div>
|
||||
<div
|
||||
ref="logContainerRef"
|
||||
class="panel-body log-stream"
|
||||
@scroll="handleScroll"
|
||||
>
|
||||
<!-- 简单滚动容器 -->
|
||||
<div
|
||||
v-for="(log, index) in logs"
|
||||
:key="index"
|
||||
:class="['task-log-line', log.class]"
|
||||
>
|
||||
<div class="panel-body log-stream">
|
||||
<div v-for="(log, index) in logs" :key="index" :class="['task-log-line', log.class]">
|
||||
<span class="t">{{ log.time }}</span>{{ log.message }}
|
||||
</div>
|
||||
<div v-if="loadingLogs" class="task-log-line">
|
||||
@@ -94,8 +72,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { taskLogApi, createTaskLogStream, type TaskLogLine, type TaskLogSummary } from '@/api/taskLog'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { taskLogApi, type TaskLogLine, type TaskLogSummary } from '@/api/taskLog'
|
||||
import { useBusinessLineStore } from '@/stores/businessLine'
|
||||
import { useBusinessLineMockProfile } from '@/utils/businessLineMock'
|
||||
|
||||
@@ -109,21 +87,6 @@ const logs = ref<TaskLogLine[]>([])
|
||||
const loadingTasks = ref(false)
|
||||
const loadingLogs = ref(false)
|
||||
const lastLoadedAt = ref<Date | null>(null)
|
||||
const isStreaming = ref(false)
|
||||
let eventSource: EventSource | null = null
|
||||
const autoScroll = ref(true)
|
||||
const logContainerRef = ref<HTMLDivElement | null>(null)
|
||||
|
||||
// 分页相关
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const totalTasks = ref(0)
|
||||
const totalPages = computed(() => Math.ceil(totalTasks.value / pageSize.value))
|
||||
|
||||
// SSE 重试相关
|
||||
const streamRetryCount = ref(0)
|
||||
const maxRetryCount = 3
|
||||
let retryTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const selectedTask = computed(() => tasks.value.find((task) => task.id === selectedTaskId.value))
|
||||
const selectedTaskName = computed(() => selectedTask.value?.name || '未选择')
|
||||
@@ -133,14 +96,11 @@ const lastLoadedText = computed(() => lastLoadedAt.value ? `更新于 ${formatTi
|
||||
async function loadTasks() {
|
||||
loadingTasks.value = true
|
||||
try {
|
||||
const result = await taskLogApi.list({
|
||||
const data = await taskLogApi.list({
|
||||
source: sourceFilter.value,
|
||||
businessLineId: businessLineStore.current?.id,
|
||||
page: currentPage.value,
|
||||
pageSize: pageSize.value,
|
||||
})
|
||||
tasks.value = result.items
|
||||
totalTasks.value = result.total
|
||||
tasks.value = data.items
|
||||
lastLoadedAt.value = new Date()
|
||||
if (!tasks.value.some((task) => task.id === selectedTaskId.value)) {
|
||||
selectedTaskId.value = tasks.value[0]?.id || ''
|
||||
@@ -163,9 +123,6 @@ async function loadLogs(taskId: string) {
|
||||
const data = await taskLogApi.get(taskId)
|
||||
logs.value = data.lines
|
||||
lastLoadedAt.value = new Date()
|
||||
scrollToBottom()
|
||||
// 启动 SSE 流式更新
|
||||
startStream(taskId)
|
||||
} catch (error) {
|
||||
logs.value = [{ time: formatTime(new Date()), message: error instanceof Error ? error.message : '任务日志加载失败', class: 'err' }]
|
||||
} finally {
|
||||
@@ -173,93 +130,10 @@ async function loadLogs(taskId: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function startStream(taskId: string) {
|
||||
stopStream() // 关闭之前的连接
|
||||
streamRetryCount.value = 0
|
||||
|
||||
eventSource = createTaskLogStream(taskId, {
|
||||
onInit: (lines) => {
|
||||
logs.value = lines
|
||||
lastLoadedAt.value = new Date()
|
||||
scrollToBottom()
|
||||
},
|
||||
onUpdate: (newLines) => {
|
||||
logs.value = newLines
|
||||
lastLoadedAt.value = new Date()
|
||||
scrollToBottom()
|
||||
},
|
||||
onFinished: () => {
|
||||
isStreaming.value = false
|
||||
streamRetryCount.value = 0
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('SSE error:', error)
|
||||
isStreaming.value = false
|
||||
// 尝试重连
|
||||
retryStream(taskId)
|
||||
},
|
||||
})
|
||||
|
||||
isStreaming.value = true
|
||||
}
|
||||
|
||||
function retryStream(taskId: string) {
|
||||
if (streamRetryCount.value >= maxRetryCount) {
|
||||
console.error('Max retry count reached')
|
||||
return
|
||||
}
|
||||
|
||||
streamRetryCount.value++
|
||||
// 指数退避:1s, 2s, 4s
|
||||
const delay = Math.pow(2, streamRetryCount.value - 1) * 1000
|
||||
|
||||
retryTimeout = setTimeout(() => {
|
||||
startStream(taskId)
|
||||
}, delay)
|
||||
}
|
||||
|
||||
function stopStream() {
|
||||
if (retryTimeout) {
|
||||
clearTimeout(retryTimeout)
|
||||
retryTimeout = null
|
||||
}
|
||||
if (eventSource) {
|
||||
eventSource.close()
|
||||
eventSource = null
|
||||
isStreaming.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function scrollToBottom() {
|
||||
if (!autoScroll.value) return
|
||||
|
||||
nextTick(() => {
|
||||
if (logContainerRef.value) {
|
||||
logContainerRef.value.scrollTop = logContainerRef.value.scrollHeight
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function handleScroll() {
|
||||
if (!logContainerRef.value) return
|
||||
|
||||
const { scrollTop, scrollHeight, clientHeight } = logContainerRef.value
|
||||
const distanceFromBottom = scrollHeight - scrollTop - clientHeight
|
||||
|
||||
// 距离底部小于 50px 时认为在底部,恢复自动滚动
|
||||
if (distanceFromBottom < 50) {
|
||||
autoScroll.value = true
|
||||
} else if (distanceFromBottom > 100) {
|
||||
// 用户向上滚动超过 100px 时暂停自动滚动
|
||||
autoScroll.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function selectTask(taskId: string) {
|
||||
if (selectedTaskId.value === taskId) {
|
||||
return
|
||||
}
|
||||
stopStream() // 停止之前的流
|
||||
selectedTaskId.value = taskId
|
||||
void loadLogs(taskId)
|
||||
}
|
||||
@@ -268,20 +142,6 @@ function refreshCurrent() {
|
||||
void loadTasks()
|
||||
}
|
||||
|
||||
function prevPage() {
|
||||
if (currentPage.value > 1) {
|
||||
currentPage.value--
|
||||
void loadTasks()
|
||||
}
|
||||
}
|
||||
|
||||
function nextPage() {
|
||||
if (currentPage.value < totalPages.value) {
|
||||
currentPage.value++
|
||||
void loadTasks()
|
||||
}
|
||||
}
|
||||
|
||||
function formatTime(date: Date) {
|
||||
return date.toLocaleTimeString('zh-CN', { hour12: false })
|
||||
}
|
||||
@@ -289,7 +149,6 @@ function formatTime(date: Date) {
|
||||
watch(
|
||||
() => businessLineStore.current?.id,
|
||||
() => {
|
||||
currentPage.value = 1
|
||||
void loadTasks()
|
||||
},
|
||||
)
|
||||
@@ -297,10 +156,6 @@ watch(
|
||||
onMounted(() => {
|
||||
void loadTasks()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stopStream()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -394,7 +249,6 @@ onUnmounted(() => {
|
||||
|
||||
.task-log-panel .panel-head {
|
||||
gap: 14px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.task-log-panel .panel-head h3 {
|
||||
@@ -404,36 +258,11 @@ onUnmounted(() => {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.task-log-panel .panel-head .panel-actions {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.selected-name {
|
||||
color: var(--text-dim);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.streaming-indicator {
|
||||
font-size: 11px;
|
||||
color: var(--accent);
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
.panel-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.auto-scroll-switch {
|
||||
--el-switch-on-color: var(--accent);
|
||||
}
|
||||
|
||||
.log-stream {
|
||||
max-height: calc(100vh - 210px);
|
||||
min-height: 520px;
|
||||
@@ -458,26 +287,6 @@ onUnmounted(() => {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 0;
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.pagination-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.retry-indicator {
|
||||
font-size: 11px;
|
||||
color: var(--warn);
|
||||
animation: pulse 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.task-layout {
|
||||
grid-template-columns: minmax(280px, 340px) minmax(0, 1fr);
|
||||
|
||||
Reference in New Issue
Block a user