@@ -9,19 +9,19 @@
< / div >
< div class = "stat-row" >
< div class = "stat-card" >
< div class = "label" > 接入 Datacenter < / div >
< div class = "label" > 接入资源域 < / div >
< div class = "value" > { { serviceDatacenters } } < / div >
< div class = "delta" > 来自基础服务实例台账 < / div >
< div class = "delta" > Datacenter / Cluster 合并统计 < / div >
< / div >
< div class = "stat-card" >
< div class = "label" > 服务总数 < / div >
< div class = "value" > { { filteredServices . length } } < / div >
< div class = "delta" > 去重后唯一服务名 < / div >
< div class = "value" > { { totalServiceCount } } < / div >
< div class = "delta" > 基础服务 + 容器化服务 < / div >
< / div >
< div class = "stat-card" >
< div class = "label" > 服务实例总数 < / div >
< div class = "value" > { { serviceInstances } } < / div >
< div class = "delta" > 当前业务线实例 汇总< / div >
< div class = "delta" > MySQL 实例 + Pod 汇总 < / div >
< / div >
< div class = "stat-card" >
< div class = "label" > 健康实例占比 < / div >
@@ -85,7 +85,7 @@
< tr v-if = "!serviceLoading && filteredServices.length === 0" >
< td colspan = "8" class = "text-dim" > 当前业务线暂无基础服务实例 < / td >
< / tr >
< tr v-for = "service in filter edServices" :key="service.name" class="tr-hover" >
< tr v-for = "service in pag edServices" :key="service.name" class="tr-hover" >
< td class = "strong mono" > { { service . name } } < / td >
< td > < span class = "tag" :class = "service.dcClass" > { { service . dc } } < / span > < / td >
< td class = "mono" > { { service . biz } } < / td >
@@ -93,29 +93,16 @@
< td class = "mono" > { { service . healthy } } < / td >
< td class = "mono" > { { service . ip } } < / td >
< td >
< el-dropdown
v-if = "delivery Tasks ForService(service.name).length "
trigger = "click"
popper -class = " delivery -record -dropdown "
@command ="openDeliveryRecordById"
< el-button
v-if = "latestCredential TaskForService(service.name)"
link
type = "primary"
:icon = "Tickets"
@click ="openDeliveryDetail(latestCredentialTaskForService(service.name)!)"
>
< 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 >
查看
< / el-button >
< span v-else class = "tag" > 已领取 < / span >
< / td >
< td : class = "['status-text', service.statusClass]" > ● { { service . status } } < / td >
< / tr >
@@ -123,15 +110,15 @@
< / table >
< div class = "pagination" >
< span > 共 { { filteredServices . length } } 条 · 当前业务线 : { { currentName } } < / span >
< div class = "pg-btns" >
< span class = "pg-btn disabled" > ‹ < / span >
< span class = "pg-btn active" > 1 < / span >
< span class = "pg-btn" > 2 < / span >
< span class = "pg-btn" > 3 < / span >
< span class = "pg-sep" > … < / span >
< span class = "pg-btn" > 27 < / span >
< span class = "pg-btn" > › < / span >
< / div >
< el-pagination
v -model :current-page = "servicePage"
v -model :page-size = "servicePageSize"
:total = "filteredServices.length"
: page -sizes = " [ 10 , 20 , 50 ] "
layout = "sizes, prev, pager, next"
background
small
/ >
< / div >
< / div >
< / div >
@@ -159,7 +146,7 @@
< tr v-if = "!containerLoading && containerServices.length === 0" >
< td colspan = "8" class = "text-dim" > 当前业务线暂无容器化服务 < / td >
< / tr >
< tr v-for = "service in c ontainerServices" :key="`${service.cluster}-${service.namespace}-${service.name}`" class="tr-hover" >
< tr v-for = "service in pagedC ontainerServices" :key="`${service.cluster}-${service.namespace}-${service.name}`" class="tr-hover" >
< td class = "strong mono" > { { service . name } } < / td >
< td > < span class = "tag" > { { service . cluster } } < / span > < / td >
< td class = "mono" > { { service . namespace } } < / td >
@@ -173,6 +160,15 @@
< / table >
< div class = "pagination" >
< span > 共 { { containerServices . length } } 条容器化服务 · 当前业务线 : { { currentName } } < / span >
< el-pagination
v -model :current-page = "containerPage"
v -model :page-size = "containerPageSize"
:total = "containerServices.length"
: page -sizes = " [ 10 , 20 , 50 ] "
layout = "sizes, prev, pager, next"
background
small
/ >
< / div >
< / div >
< / div >
@@ -199,33 +195,43 @@
< / dl >
< p v-if = "selectedDeliveryTask.error_message" class="delivery-error-text" > {{ selectedDeliveryTask.error_message }} < / p >
< div class = "delivery-credential-panel" : class = "{ available: credentialEligible(selectedDeliveryTask) || revealedCredentials.length > 0 } ">
< div class = "delivery- credential-head" >
< div v-if = " credentialEligible(selectedDeliveryTask) || revealedCredentials.length > 0" class="credential-handoff ">
< div class = "credential-head" >
< div >
< span class = "eyebrow" > ONE - TIME CREDENTIAL < / span >
< h4 > 管理员凭证 < / h4 >
< h4 > root 管理员凭证 < / h4 >
< / div >
< span class = "tag" :class = "credentialStatusClass(selectedDeliveryTask)" > { { credentialStatusText ( selectedDeliveryTask ) } } < / span >
< span class = "tag tag-amber" > 仅展示一次 < / span >
< / div >
< p > 请立即复制或下载凭证文件 ; 关闭后页面不再显示明文密码 。 < / p >
< template v-if = "revealedCredentials.length" >
< p > 凭证仅展示一次 ; 请立即复制或下载 , 关闭弹窗后平台不再提供明文密码 。 < / p >
< div class = "delivery-secre t" >
< code v-for = "item in revealedCredentials" :key="`${item.username}@${item.host}`" > {{ item.username }} @ {{ item.host }} {{ item.password }} < / code >
< div class = "delivery-secret-actions" >
< el-button :icon = "CopyDocument" @click ="copyDeliveryText(credentialClipboardText)" > 复制凭证 < / el -button >
< el-button :icon = "Download " @click ="download DeliveryC redential( selectedDeliveryTask)" > 下载凭证文件 < / el -button >
< div class = "credential-secret" >
< div v-for = "item in revealedCredentials" :key="`${item.username}@${item.account_host || item.host}`" class="credential-accoun t" >
< div class = "credential-field" >
< span > 用户名 < / span >
< code > { { credentialUsername ( item ) } } < / code >
< el-button text :icon = "CopyDocument" aria -label = " 复制用户名 " title = "复制用户名 " @click ="copy DeliveryText(c redentialU sername(item), '用户名已复制')" / >
< / div >
< div class = "credential-field" >
< span > 密码 < / span >
< code > { { item . password } } < / code >
< el-button text :icon = "CopyDocument" aria -label = " 复制密码 " title = "复制密码" @click ="copyDeliveryText(item.password, '密码已复制')" / >
< / div >
< / div >
< div class = "credential-actions" >
< el-button :icon = "Download" @click ="downloadDeliveryCredential(selectedDeliveryTask)" > 下载 Excel 凭证 < / el -button >
< el-button type = "success" plain :icon = "CircleCheck" @click ="deliveryDetailVisible = false" > 已保存 , 关闭 < / el -button >
< / div >
< / div >
< / template >
< template v-else-if = "credentialEligible (selectedDeliveryTask)">
< p > 凭证仅可领取一次 , 领取后服务端立即销毁明文 。 < / p >
< el-button type = "warning" :loading = "credentialRevealing" @click ="revealDeliveryCredential(selectedDeliveryTask)" > 领取一次性凭证 < / el -button >
< / template >
< p v-else-if = "['finished', 'register_failed'].includes(selectedDeliveryTask.status)" > 该任务的一次性凭证已领取或不可用 , 平台不再提供明文密码 。 < / p >
< p v-else > 仅交付成功的任务提供一次性凭证 。 < / p >
< el-button v-else type = "warning" :loading = "credentialRevealing" @click ="revealDeliveryCredential (selectedDeliveryTask)"> 查看一次性密码 < / el -button >
< / div >
< div v-else-if = "['finished', 'register_failed'].includes(selectedDeliveryTask.status)" class="credential-unavailable" >
< strong > 一次性凭证已关闭 < / strong >
< span > 历史任务仍可查看连接信息 , 但平台不会再次展示 root 明文密码 。 < / span >
< / div >
< p v-else class = "delivery-error-text" > 仅交付成功的任务提供一次性凭证 。 < / p >
< / template >
< / el-dialog >
< / div >
@@ -259,6 +265,8 @@ interface ServiceLedgerRow {
const services = ref < ServiceLedgerRow [ ] > ( [ ] )
const serviceLoading = ref ( false )
const serviceError = ref ( '' )
const servicePage = ref ( 1 )
const servicePageSize = ref ( 10 )
const deliveryRecords = ref < DeliveryTask [ ] > ( [ ] )
const deliveryLoading = ref ( false )
@@ -271,12 +279,30 @@ const containerServices = ref<ContainerWorkload[]>([])
const containerSummary = ref < ContainerServiceSummary > ( { ... emptyContainerServiceSummary } )
const containerLoading = ref ( false )
const containerError = ref ( '' )
const containerPage = ref ( 1 )
const containerPageSize = ref ( 10 )
const filteredServices = computed ( ( ) => services . value )
const serviceInstances = computed ( ( ) => filteredServices . value . reduce ( ( sum , service ) => sum + service . instances , 0 ) )
const healthyInstances = computed ( ( ) => filteredServices . value . reduce ( ( sum , service ) => sum + service . healthyCount , 0 ) )
const pagedServices = computed ( ( ) => paginate ( filteredServices . value , servicePage . value , servicePageSize . value ) )
const pagedContainerServices = computed ( ( ) => paginate ( containerServices . value , containerPage . value , containerPageSize . value ) )
const totalServiceCount = computed ( ( ) => filteredServices . value . length + containerServices . value . length )
const mysqlInstances = computed ( ( ) => filteredServices . value . reduce ( ( sum , service ) => sum + service . instances , 0 ) )
const serviceInstances = computed ( ( ) => mysqlInstances . value + ( containerSummary . value . pods || 0 ) )
const healthyInstances = computed ( ( ) => {
const mysqlHealthy = filteredServices . value . reduce ( ( sum , service ) => sum + service . healthyCount , 0 )
return mysqlHealthy + ( containerSummary . value . readyPods || 0 )
} )
const unhealthyInstances = computed ( ( ) => Math . max ( serviceInstances . value - healthyInstances . value , 0 ) )
const serviceDatacenters = computed ( ( ) => new Set ( filteredServices . value . map ( ( service ) => service . dc ) . filter ( Boolean ) ) . size )
const serviceDatacenters = computed ( ( ) => {
const domains = new Set < string > ( )
filteredServices . value . forEach ( ( service ) => {
if ( service . dc && service . dc !== '-' ) domains . add ( ` dc: ${ service . dc } ` )
} )
containerServices . value . forEach ( ( service ) => {
if ( service . cluster ) domains . add ( ` cluster: ${ service . cluster } ` )
} )
return domains . size
} )
const serviceHealthPercent = computed ( ( ) => {
if ( serviceInstances . value === 0 ) return '0.0'
return ( ( healthyInstances . value / serviceInstances . value ) * 100 ) . toFixed ( 1 )
@@ -309,9 +335,10 @@ const deliveryStatusLabels: Record<string, string> = {
rollback _acknowledged : '已确认清理' ,
}
const credentialClipboardText = computed ( ( ) =>
revealedCredentials . value . map ( ( item ) => ` ${ item . username } @ ${ item . host } ${ item . password } ` ) . join ( '\n' ) ,
)
function paginate < T > ( items : T [ ] , page : number , pageSize : number ) {
const start = ( Math . max ( page , 1 ) - 1 ) * pageSize
return items . slice ( start , start + pageSize )
}
const loadDeliveryRecords = async ( ) => {
const businessLineId = businessLineStore . current ? . id
@@ -331,8 +358,19 @@ const loadDeliveryRecords = async () => {
}
}
const refreshServices = ( ) => {
loadServiceLedger ( )
const refreshServices = async ( ) => {
const businessLineId = businessLineStore . current ? . id
if ( businessLineId ) {
serviceLoading . value = true
try {
await deliveryApi . syncMySQLServices ( businessLineId )
} catch ( error ) {
ElMessage . warning ( error instanceof Error ? error . message : 'MySQL 实例状态同步失败' )
} finally {
serviceLoading . value = false
}
}
await loadServiceLedger ( )
loadContainerServices ( )
loadDeliveryRecords ( )
}
@@ -343,29 +381,19 @@ function openDeliveryDetail(task: DeliveryTask) {
deliveryDetailVisible . value = true
}
function openDeliveryRecordById ( id : string ) {
const task = deliveryRecords . value . find ( ( item ) => item . id === id )
if ( task ) openDeliveryDetail ( task )
function latestDeliveryTaskForService ( serviceName : string ) {
return deliveryRecords . value . find ( ( task ) => task . instance _name === serviceName )
}
function deliveryTasksForService ( serviceName : string ) {
return deliveryRecords . value . filter ( ( task ) => task . instance _name === serviceName )
function latestCredentialTaskForService ( serviceName : string ) {
const task = latestDeliveryTaskForService ( serviceName )
return task && credentialEligible ( task ) ? task : undefined
}
function credentialEligible ( task : DeliveryTask ) {
return [ 'finished' , 'register_failed' ] . includes ( task . status ) && Boolean ( task . credential _available )
}
function credentialStatusText ( task : DeliveryTask ) {
if ( credentialEligible ( task ) ) return '可领取'
if ( [ 'finished' , 'register_failed' ] . includes ( task . status ) ) return '已领取'
return '不提供'
}
function credentialStatusClass ( task : DeliveryTask ) {
return credentialEligible ( task ) ? 'tag-amber' : ''
}
function markCredentialUnavailable ( taskID : string ) {
const task = deliveryRecords . value . find ( ( item ) => item . id === taskID )
if ( task ) task . credential _available = false
@@ -423,31 +451,58 @@ function formatDeliveryTime(value: string, withYear = false) {
} ) . format ( date )
}
async function copyDeliveryText ( value : string ) {
async function copyDeliveryText ( value : string , message = '已复制' ) {
if ( ! value ) return
try {
await navigator . clipboard . writeText ( value )
ElMessage . success ( '凭证已复制' )
ElMessage . success ( message )
} catch {
ElMessage . error ( '复制失败,请手动选择文本' )
}
}
function credentialUsername ( 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 , '>' )
}
function downloadDeliveryCredential ( task : DeliveryTask ) {
if ( ! revealedCredentials . value . length ) return
const lines = [
'# XInfra MySQL delivery credentia l',
` instance= ${ task . instance _name } ` ,
` endpoint= ${ deliveryEndpoint ( task ) } ` ,
... revealedCredentials . value . map ( ( item ) => ` ${ item . username } @ ${ item . host } = ${ item . password } ` ) ,
]
const url = URL . createObjectURL ( new Blob ( [ lines . join ( '\n' ) + '\n ' ] , { type : 'text/plain;charset=utf-8' } ) )
const rows = revealedCredentials . value . map ( ( item ) => [
item . service || 'mysq l',
item . instance _name || task . instance _name ,
item . host || task . target _host _ip || task . target _host || '' ,
item . port || task . mysql _port || '' ,
item . username ,
item . account _host || '' ,
item . password ,
] )
const bodyRows = rows
. map ( ( row ) => ` <tr> ${ row . map ( ( cell ) => ` <td> ${ escapeExcelCell ( cell ) } </td> ` ) . join ( '' ) } </tr> ` )
. join ( '' )
const content = ` <!doctype html>
<html>
<head><meta charset="utf-8"></head>
<body>
<table>
<thead><tr><th>服务</th><th>实例</th><th>主机</th><th>端口</th><th>用户名</th><th>账号域</th><th>密码</th></tr></thead>
<tbody> ${ bodyRows } </tbody>
</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 = ` ${ task . instance _name } -credential.txt `
link . download = ` ${ task . instance _name } -credential.xls `
link . click ( )
URL . revokeObjectURL ( url )
ElMessage . success ( '凭证文件 已下载,请妥善保管' )
ElMessage . success ( 'Excel 凭证已下载,请妥善保管' )
}
const loadServiceLedger = async ( ) => {
@@ -511,6 +566,8 @@ const loadContainerServices = async () => {
watch (
( ) => businessLineStore . current ? . id ,
( ) => {
servicePage . value = 1
containerPage . value = 1
loadServiceLedger ( )
loadContainerServices ( )
loadDeliveryRecords ( )
@@ -518,6 +575,14 @@ watch(
{ immediate : true } ,
)
watch ( [ ( ) => filteredServices . value . length , servicePageSize ] , ( [ total ] ) => {
servicePage . value = Math . min ( servicePage . value , Math . max ( Math . ceil ( total / servicePageSize . value ) , 1 ) )
} )
watch ( [ ( ) => containerServices . value . length , containerPageSize ] , ( [ total ] ) => {
containerPage . value = Math . min ( containerPage . value , Math . max ( Math . ceil ( total / containerPageSize . value ) , 1 ) )
} )
watch ( deliveryDetailVisible , ( visible ) => {
if ( ! visible ) revealedCredentials . value = [ ]
} )
@@ -616,35 +681,60 @@ watch(deliveryDetailVisible, (visible) => {
overflow - wrap : anywhere ;
}
. delivery - credential - panel {
. credential - handoff {
margin - top : 18 px ;
padding : 14 px ;
border : 1 px solid var ( -- line - soft ) ;
padding : 13 px ;
border : 1 px solid var ( -- tag - amber - border ) ;
border - radius : 7 px ;
background : var ( -- bg - panel - 2 ) ;
}
. delivery - credential - panel . available {
border - color : var ( -- tag - amber - border ) ;
background : var ( -- tag - amber - bg ) ;
}
. delivery - credential - panel > p {
margin : 12 px 0 ;
. credential - head {
display : flex ;
align - items : flex - start ;
justify - content : space - between ;
gap : 12 px ;
}
. credential - head h4 {
margin : 3 px 0 0 ;
color : var ( -- text - hi ) ;
font - size : 13 px ;
}
. credential - handoff > p {
margin : 10 px 0 ;
color : var ( -- text - dim ) ;
font - size : 11.5 px ;
line - height : 1.5 ;
}
. delivery - secret {
. credential - secret {
display : grid ;
gap : 10 px ;
margin - top : 12 px ;
}
. delivery - secret > code {
. credential - account {
display : grid ;
gap : 8 px ;
}
. credential - field {
display : grid ;
grid - template - columns : 64 px minmax ( 0 , 1 fr ) 32 px ;
align - items : center ;
gap : 8 px ;
}
. credential - field > span {
color : var ( -- text - dim ) ;
font - size : 11.5 px ;
font - weight : 700 ;
}
. credential - field > code {
overflow - wrap : anywhere ;
padding : 10 px 11 px ;
padding : 9 px 10 px ;
border : 1 px solid var ( -- line - soft ) ;
border - radius : 5 px ;
background : var ( -- bg - panel ) ;
@@ -652,12 +742,37 @@ watch(deliveryDetailVisible, (visible) => {
font - size : 12 px ;
}
. delivery - secret - actions {
. credential - field . el - button {
width : 32 px ;
min - width : 32 px ;
}
. credential - actions {
display : flex ;
flex - wrap : wrap ;
gap : 8 px ;
}
. credential - unavailable {
display : grid ;
gap : 5 px ;
margin - top : 18 px ;
padding : 13 px ;
border : 1 px dashed var ( -- line ) ;
border - radius : 7 px ;
background : var ( -- bg - panel - 2 ) ;
}
. credential - unavailable strong {
color : var ( -- text - hi ) ;
font - size : 12 px ;
}
. credential - unavailable span {
color : var ( -- text - dim ) ;
font - size : 11.5 px ;
}
. tag . tag - green {
color : var ( -- tag - green - text ) ;
background : var ( -- tag - green - bg ) ;