Files
xinfra/frontend/src/views/service/Management.vue
T
Hungerdream 5715379a26 refactor(ui): embed delivery records into the service ledger
Replace the separate delivery records tab with a per-service dropdown column in the ledger table that opens the existing detail dialog with one-time credential claim. Sync now refreshes ledger, container services and delivery records together; errors surface via message toast. components.d.ts regenerated (dropdown components in, tabs out).
2026-07-29 13:50:29 +08:00

685 lines
23 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div>
<div class="page-head">
<div>
<h1>服务管理</h1>
<p>同步基础服务实例台账,统一查看服务名 / 服务 IP / 业务标签 / 实例数</p>
</div>
<el-button type="primary" :loading="serviceLoading || deliveryLoading" @click="refreshServices">同步</el-button>
</div>
<div class="stat-row">
<div class="stat-card">
<div class="label">接入 Datacenter</div>
<div class="value">{{ serviceDatacenters }}</div>
<div class="delta">来自基础服务实例台账</div>
</div>
<div class="stat-card">
<div class="label">服务总数</div>
<div class="value">{{ filteredServices.length }}</div>
<div class="delta">去重后唯一服务名</div>
</div>
<div class="stat-card">
<div class="label">服务实例总数</div>
<div class="value">{{ serviceInstances }}</div>
<div class="delta">当前业务线实例汇总</div>
</div>
<div class="stat-card">
<div class="label">健康实例占比</div>
<div class="value accent">{{ serviceHealthPercent }}%</div>
<div class="delta warn">{{ unhealthyInstances }} 个实例异常</div>
</div>
<div class="stat-card">
<div class="label">最近同步</div>
<div class="value accent-pill">● {{ serviceError ? '异常' : '正常' }}</div>
<div class="delta">{{ serviceMetaText }}</div>
</div>
</div>
<div class="las-toolbar">
<el-select placeholder="全部机房">
<el-option label="全部机房" value="" />
<el-option label="YZH" value="yzh" />
<el-option label="XS" value="xs" />
<el-option label="JF" value="jf" />
<el-option label="达拉斯" value="dallas" />
<el-option label="新加坡" value="singapore" />
<el-option label="香港" value="hk" />
<el-option label="东南亚" value="sea" />
</el-select>
<el-select placeholder="全部业务标签">
<el-option label="全部业务标签" value="" />
<el-option label="kodo" value="kodo" />
<el-option label="linxi" value="linxi" />
<el-option label="xinfra" value="xinfra" />
<el-option label="las" value="las" />
</el-select>
<el-select placeholder="全部状态">
<el-option label="全部状态" value="" />
<el-option label="健康" value="healthy" />
<el-option label="部分异常" value="partial" />
<el-option label="全部下线" value="offline" />
</el-select>
<el-input placeholder="搜索服务名 / IP" class="search-input" />
</div>
<div class="panel">
<div class="panel-head">
<h3>服务台账 · 按 Consul Datacenter 同步</h3>
<span class="meta">{{ serviceLoading ? '同步中...' : serviceMetaText }}</span>
</div>
<div class="panel-body">
<table>
<thead>
<tr>
<th>服务名</th>
<th>机房 / Datacenter</th>
<th>业务标签</th>
<th>实例数</th>
<th>健康实例</th>
<th>示例 IP</th>
<th>交付记录</th>
<th>状态</th>
</tr>
</thead>
<tbody>
<tr v-if="!serviceLoading && filteredServices.length === 0">
<td colspan="8" class="text-dim">当前业务线暂无基础服务实例</td>
</tr>
<tr v-for="service in filteredServices" :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>
<td class="mono">{{ service.instances }}</td>
<td class="mono">{{ service.healthy }}</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>
</tr>
</tbody>
</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>
</div>
</div>
</div>
<div class="panel">
<div class="panel-head">
<h3>容器化服务台账 · 按集群 / Namespace 同步</h3>
<span class="meta">{{ containerLoading ? '同步中...' : containerMetaText }}</span>
</div>
<div class="panel-body">
<table>
<thead>
<tr>
<th>服务名</th>
<th>集群</th>
<th>Namespace</th>
<th>工作负载</th>
<th>Pod Ready</th>
<th>镜像</th>
<th>业务标签</th>
<th>状态</th>
</tr>
</thead>
<tbody>
<tr v-if="!containerLoading && containerServices.length === 0">
<td colspan="8" class="text-dim">当前业务线暂无容器化服务</td>
</tr>
<tr v-for="service in containerServices" :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>
<td>{{ service.workload }}</td>
<td class="mono">{{ service.ready }}</td>
<td class="mono text-xs">{{ service.image || '-' }}</td>
<td class="mono">{{ service.biz }}</td>
<td :class="['status-text', service.statusClass]">● {{ service.status }}</td>
</tr>
</tbody>
</table>
<div class="pagination">
<span>共 {{ containerServices.length }} 条容器化服务 · 当前业务线:{{ currentName }}</span>
</div>
</div>
</div>
<el-dialog v-model="deliveryDetailVisible" width="640px" align-center destroy-on-close>
<template #header>
<div class="delivery-dialog-head">
<div>
<span class="eyebrow">DELIVERY RECORD</span>
<h3>{{ selectedDeliveryTask?.instance_name }}</h3>
</div>
<span v-if="selectedDeliveryTask" class="tag" :class="deliveryStatusClass(selectedDeliveryTask.status)">{{ deliveryStatusText(selectedDeliveryTask.status) }}</span>
</div>
</template>
<template v-if="selectedDeliveryTask">
<dl class="delivery-detail-grid">
<div><dt>任务编号</dt><dd class="mono">{{ selectedDeliveryTask.id }}</dd></div>
<div><dt>连接地址</dt><dd class="mono">{{ deliveryEndpoint(selectedDeliveryTask) }}</dd></div>
<div><dt>命名空间</dt><dd class="mono">{{ selectedDeliveryTask.namespace }}</dd></div>
<div><dt>部署主机</dt><dd class="mono">{{ selectedDeliveryTask.target_host || '自动调度' }}</dd></div>
<div><dt>创建时间</dt><dd>{{ formatDeliveryTime(selectedDeliveryTask.created_at, true) }}</dd></div>
<div><dt>完成时间</dt><dd>{{ selectedDeliveryTask.finished_at ? formatDeliveryTime(selectedDeliveryTask.finished_at, true) : '-' }}</dd></div>
</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>
<span class="eyebrow">ONE-TIME CREDENTIAL</span>
<h4>管理员凭证</h4>
</div>
<span class="tag" :class="credentialStatusClass(selectedDeliveryTask)">{{ credentialStatusText(selectedDeliveryTask) }}</span>
</div>
<template v-if="revealedCredentials.length">
<p>凭证仅展示一次;请立即复制或下载,关闭弹窗后平台不再提供明文密码。</p>
<div class="delivery-secret">
<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="downloadDeliveryCredential(selectedDeliveryTask)">下载凭证文件</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="consumedCredentialTasks.has(selectedDeliveryTask.id)">该任务的一次性凭证已领取或不可用,平台不再提供明文密码。</p>
<p v-else>仅交付成功的任务提供一次性凭证。</p>
</div>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { CircleCheck, CopyDocument, Download, Tickets } from '@element-plus/icons-vue'
import { containerServiceApi, emptyContainerServiceSummary, type ContainerServiceSummary, type ContainerWorkload } from '@/api/containerService'
import { deliveryApi, type DeliveryTask, type DeploymentCredential, type MySQLServiceLedgerItem } from '@/api/delivery'
import { useBusinessLineStore } from '@/stores/businessLine'
import { useBusinessLineMockProfile } from '@/utils/businessLineMock'
const { currentName } = useBusinessLineMockProfile()
const businessLineStore = useBusinessLineStore()
interface ServiceLedgerRow {
name: string
dc: string
dcClass: string
biz: string
instances: number
healthy: string
healthyCount: number
ip: string
status: string
statusClass: string
}
const services = ref<ServiceLedgerRow[]>([])
const serviceLoading = ref(false)
const serviceError = ref('')
const deliveryRecords = ref<DeliveryTask[]>([])
const deliveryLoading = ref(false)
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 })
const containerLoading = ref(false)
const containerError = ref('')
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 unhealthyInstances = computed(() => Math.max(serviceInstances.value - healthyInstances.value, 0))
const serviceDatacenters = computed(() => new Set(filteredServices.value.map((service) => service.dc).filter(Boolean)).size)
const serviceHealthPercent = computed(() => {
if (serviceInstances.value === 0) return '0.0'
return ((healthyInstances.value / serviceInstances.value) * 100).toFixed(1)
})
const serviceMetaText = computed(() => serviceError.value || '数据源:deployment_results')
const containerMetaText = computed(() => {
if (containerError.value) {
return containerError.value
}
const errors = containerSummary.value.errors?.length || 0
return errors > 0 ? `数据源:Kubernetes Workload API · ${errors} 项同步失败` : '数据源:Kubernetes Workload API'
})
const deliveryStatusLabels: Record<string, string> = {
pending: '等待执行',
validating: '检查中',
dispatching: '调度中',
running: '交付中',
registering: '注册中',
finished: '交付完成',
register_failed: '注册失败',
execution_failed: '执行失败',
validation_failed: '检查失败',
canceling: '取消中',
canceled: '已取消',
rollback_pending: '等待回退',
rolling_back: '回退中',
rolled_back: '已回退',
rollback_failed: '回退失败',
rollback_acknowledged: '已确认清理',
}
const credentialClipboardText = computed(() =>
revealedCredentials.value.map((item) => `${item.username}@${item.host} ${item.password}`).join('\n'),
)
const loadDeliveryRecords = async () => {
const businessLineId = businessLineStore.current?.id
if (!businessLineId) {
deliveryRecords.value = []
return
}
deliveryLoading.value = true
try {
const items = await deliveryApi.listTasks({ businessLineId, component: 'mysql' })
deliveryRecords.value = items.sort((a, b) => Date.parse(b.created_at) - Date.parse(a.created_at))
} catch (error) {
deliveryRecords.value = []
ElMessage.error(error instanceof Error ? error.message : '交付记录同步失败')
} finally {
deliveryLoading.value = false
}
}
const refreshServices = () => {
loadServiceLedger()
loadContainerServices()
loadDeliveryRecords()
}
function openDeliveryDetail(task: DeliveryTask) {
selectedDeliveryTask.value = task
revealedCredentials.value = []
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) {
return ['finished', 'register_failed'].includes(task.status) && !consumedCredentialTasks.value.has(task.id)
}
function credentialStatusText(task: DeliveryTask) {
if (consumedCredentialTasks.value.has(task.id)) return '已领取'
return credentialEligible(task) ? '可领取' : '不提供'
}
function credentialStatusClass(task: DeliveryTask) {
if (consumedCredentialTasks.value.has(task.id)) return 'tag-green'
return credentialEligible(task) ? 'tag-amber' : ''
}
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)
ElMessage.warning('该任务没有可领取的凭证')
return
}
revealedCredentials.value = items
consumedCredentialTasks.value.add(task.id)
} catch (error) {
consumedCredentialTasks.value.add(task.id)
ElMessage.error(error instanceof Error ? error.message : '凭证领取失败')
} finally {
credentialRevealing.value = false
}
}
function deliveryStatusText(status: string) {
return deliveryStatusLabels[status] || status || '未知状态'
}
function deliveryStatusClass(status: string) {
if (status === 'finished') return 'tag-green'
if (['pending', 'validating', 'dispatching', 'running', 'registering'].includes(status)) return 'tag-blue'
if (['register_failed', 'rollback_pending', 'rolling_back', 'canceling'].includes(status)) return 'tag-amber'
return 'tag-red'
}
function deliveryEndpoint(task: DeliveryTask) {
const host = task.target_host_ip || task.target_host
if (!host) return '等待主机分配'
return task.mysql_port ? `${host}:${task.mysql_port}` : host
}
function formatDeliveryTime(value: string, withYear = false) {
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
return new Intl.DateTimeFormat('zh-CN', {
...(withYear ? { year: 'numeric' } : {}),
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
}).format(date)
}
async function copyDeliveryText(value: string) {
if (!value) return
try {
await navigator.clipboard.writeText(value)
ElMessage.success('凭证已复制')
} catch {
ElMessage.error('复制失败,请手动选择文本')
}
}
function downloadDeliveryCredential(task: DeliveryTask) {
if (!revealedCredentials.value.length) return
const lines = [
'# XInfra MySQL delivery credential',
`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 link = document.createElement('a')
link.href = url
link.download = `${task.instance_name}-credential.txt`
link.click()
URL.revokeObjectURL(url)
ElMessage.success('凭证文件已下载,请妥善保管')
}
const loadServiceLedger = async () => {
const businessLineId = businessLineStore.current?.id
if (!businessLineId) {
services.value = []
return
}
serviceLoading.value = true
serviceError.value = ''
try {
const items = await deliveryApi.listMySQLServices(businessLineId)
services.value = items.map(serviceLedgerRow)
} catch (error) {
services.value = []
serviceError.value = error instanceof Error ? error.message : '基础服务实例同步失败'
} finally {
serviceLoading.value = false
}
}
function serviceLedgerRow(item: MySQLServiceLedgerItem): ServiceLedgerRow {
const instances = item.instances || 1
const healthy = item.healthy || 0
return {
name: item.name,
dc: item.datacenter || '-',
dcClass: 'zone-a',
biz: item.business_tag || currentName.value,
instances,
healthy: `${healthy} / ${instances}`,
healthyCount: healthy,
ip: item.address || '-',
status: item.status || '未知',
statusClass: item.status_class || 'warn',
}
}
const loadContainerServices = async () => {
const businessLineId = businessLineStore.current?.id
if (!businessLineId) {
containerServices.value = []
containerSummary.value = { ...emptyContainerServiceSummary }
return
}
containerLoading.value = true
containerError.value = ''
try {
const data = await containerServiceApi.listWorkloads(businessLineId)
containerServices.value = data.items
containerSummary.value = data.summary
} catch (error) {
containerServices.value = []
containerSummary.value = { ...emptyContainerServiceSummary }
containerError.value = error instanceof Error ? error.message : '容器化服务数据同步失败'
} finally {
containerLoading.value = false
}
}
watch(
() => businessLineStore.current?.id,
() => {
loadServiceLedger()
loadContainerServices()
loadDeliveryRecords()
},
{ immediate: true },
)
watch(deliveryDetailVisible, (visible) => {
if (!visible) revealedCredentials.value = []
})
</script>
<style scoped>
/* 台账部分样式在 global.css 中定义;以下为交付详情专属样式 */
:global(.delivery-dropdown-item) {
display: grid;
gap: 3px;
min-width: 220px;
padding: 3px 0;
}
:global(.delivery-dropdown-item strong) {
color: var(--text-hi);
font-size: 12px;
}
:global(.delivery-dropdown-item small) {
color: var(--text-dim);
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-credential-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.delivery-dialog-head h3,
.delivery-credential-head h4 {
margin: 4px 0 0;
color: var(--text-hi);
}
.delivery-dialog-head h3 {
font-size: 16px;
}
.delivery-credential-head h4 {
font-size: 13px;
}
.eyebrow {
color: var(--text-dim);
font-family: var(--mono);
font-size: 10px;
letter-spacing: 0.08em;
}
.delivery-detail-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0 20px;
margin: 0;
}
.delivery-detail-grid > div {
display: flex;
justify-content: space-between;
gap: 12px;
min-width: 0;
padding: 10px 0;
border-bottom: 1px solid var(--line-soft);
}
.delivery-detail-grid dt {
flex: 0 0 auto;
color: var(--text-dim);
font-size: 11px;
}
.delivery-detail-grid dd {
min-width: 0;
overflow-wrap: anywhere;
margin: 0;
color: var(--text-mid);
font-size: 11px;
text-align: right;
}
.delivery-error-text {
margin: 12px 0 0;
color: var(--err);
font-size: 11.5px;
line-height: 1.5;
overflow-wrap: anywhere;
}
.delivery-credential-panel {
margin-top: 18px;
padding: 14px;
border: 1px solid var(--line-soft);
border-radius: 7px;
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: 12px 0;
color: var(--text-dim);
font-size: 11.5px;
line-height: 1.5;
}
.delivery-secret {
display: grid;
gap: 10px;
margin-top: 12px;
}
.delivery-secret > code {
overflow-wrap: anywhere;
padding: 10px 11px;
border: 1px solid var(--line-soft);
border-radius: 5px;
background: var(--bg-panel);
color: var(--text-hi);
font-size: 12px;
}
.delivery-secret-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.tag.tag-green {
color: var(--tag-green-text);
background: var(--tag-green-bg);
border-color: var(--tag-green-border);
}
.tag.tag-amber {
color: var(--tag-amber-text);
background: var(--tag-amber-bg);
border-color: var(--tag-amber-border);
}
.tag.tag-blue {
color: var(--tag-blue-text);
background: var(--tag-blue-bg);
border-color: var(--tag-blue-border);
}
.tag.tag-red {
color: var(--err);
background: var(--logo-cc-bg);
border-color: var(--err);
}
@media (max-width: 900px) {
.delivery-toolbar,
.delivery-detail-grid {
grid-template-columns: 1fr;
}
}
</style>