feat: update delivery and resource frontends

This commit is contained in:
mac
2026-07-28 17:56:58 +08:00
parent f7be87c7d9
commit 93981b9aad
7 changed files with 561 additions and 133 deletions
+1
View File
@@ -20,6 +20,7 @@ declare module 'vue' {
ElInput: typeof import('element-plus/es')['ElInput']
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
ElOption: typeof import('element-plus/es')['ElOption']
ElPagination: typeof import('element-plus/es')['ElPagination']
ElSelect: typeof import('element-plus/es')['ElSelect']
ElTable: typeof import('element-plus/es')['ElTable']
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
+36
View File
@@ -34,6 +34,33 @@ export interface ContainerWorkload {
statusClass: string
}
export interface ContainerCluster {
name: string
zone: string
zoneClass: string
status: string
statusClass: string
nodes: number
readyNodes: number
cpu: number
cpuClass: string
version: string
calico: string
}
export interface ContainerNode {
cluster: string
name: string
ip: string
label: string
taint: string
spec: string
cpu: number
cpuClass: string
status: string
statusClass: string
}
export const emptyContainerServiceSummary: ContainerServiceSummary = {
businessLineId: 0,
businessLineName: '',
@@ -65,6 +92,15 @@ export const containerServiceApi = {
summary: data.summary || emptyContainerServiceSummary,
}
},
async listClusters(businessLineId: number): Promise<{ items: ContainerCluster[]; nodes: ContainerNode[]; summary: ContainerServiceSummary }> {
const data = await authRequest(`/auth/api/v1/container-services/business-lines/${businessLineId}/clusters`)
return {
items: Array.isArray(data.items) ? data.items : [],
nodes: Array.isArray(data.nodes) ? data.nodes : [],
summary: data.summary || emptyContainerServiceSummary,
}
},
}
async function authRequest(path: string, init: RequestInit = {}) {
+18
View File
@@ -83,6 +83,19 @@ export interface DeliveryTaskSnapshot {
events: TaskEvent[]
}
export interface MySQLServiceLedgerItem {
name: string
datacenter: string
business_tag: string
instances: number
healthy: number
address: string
status: string
status_class: string
version: string
namespace: string
}
export const deliveryApi = {
async listTargets(): Promise<DeliveryTarget[]> {
const data = await authRequest('/auth/api/v1/delivery/targets?component=mysql')
@@ -115,6 +128,11 @@ export const deliveryApi = {
return Array.isArray(data.items) ? data.items : []
},
async listMySQLServices(businessLineId: number): Promise<MySQLServiceLedgerItem[]> {
const data = await authRequest(`/auth/api/v1/delivery/business-lines/${businessLineId}/mysql-services`)
return Array.isArray(data.items) ? data.items : []
},
async getTask(taskId: string): Promise<{ task: DeliveryTask; events: TaskEvent[] }> {
const data = await authRequest(`/auth/api/v1/delivery/tasks/${encodeURIComponent(taskId)}`)
return {
+144
View File
@@ -0,0 +1,144 @@
import { getToken } from '@/utils/auth'
export interface MachineOverview {
total: number
physical: number
virtual: number
cmdb: {
total: number
physical: number
virtual: number
}
aliyun: {
total: number
label: string
}
awsQiniu: {
total: number
aws: number
qiniu: number
}
lastSync: {
status: string
time: string
created: number
error?: string
}
}
export interface MachineResource {
hostname: string
assetNumber: string
type: string
location: string
ip: string
spec: string
businessLine: string
source: string
status: string
}
export interface MachineResourceList {
total: number
items: MachineResource[]
}
export interface MachineResourceQuery {
page: number
size: number
hostname?: string
assetNumber?: string
type?: string
location?: string
ip?: string
spec?: string
businessLine?: string
source?: string
status?: string
}
export const emptyMachineOverview: MachineOverview = {
total: 0,
physical: 0,
virtual: 0,
cmdb: {
total: 0,
physical: 0,
virtual: 0,
},
aliyun: {
total: 0,
label: 'ECS · 增量同步',
},
awsQiniu: {
total: 0,
aws: 0,
qiniu: 0,
},
lastSync: {
status: 'unknown',
time: '—',
created: 0,
},
}
export const machineApi = {
async getOverview(): Promise<MachineOverview> {
return authRequest('/auth/api/v1/machines/overview')
},
async listResources(query: MachineResourceQuery): Promise<MachineResourceList> {
const params = new URLSearchParams()
params.set('page', String(query.page))
params.set('size', String(query.size))
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()}`)
return {
total: Number(data.total || 0),
items: Array.isArray(data.items) ? data.items : [],
}
},
async sync(): Promise<void> {
await authRequest('/auth/api/v1/machines/sync', { method: 'POST' })
},
}
async function authRequest(path: string, init: RequestInit = {}) {
const token = getToken()
const response = await fetch(path, {
...init,
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
...init.headers,
},
})
const text = await response.text()
const data = parseResponseBody(text)
if (!response.ok) {
const message = data?.error || data?.message || text || `HTTP ${response.status}`
throw new Error(message)
}
return data || {}
}
function parseResponseBody(text: string) {
if (!text.trim()) {
return {}
}
try {
return JSON.parse(text)
} catch {
return { error: text }
}
}
+86 -53
View File
@@ -3,14 +3,15 @@
<div class="page-head">
<div>
<h1>集群与容器</h1>
<p>统一建设容器池 · RKE2 上建 Wayne 后新服务的标准发布流程 · Calico BGP 扁平网络</p>
<p>统一建设容器池 · RKE2 上建 Wayne 后新服务的标准发布流程 · 数据源:Wayne 原生接口</p>
</div>
<el-button type="primary">同步</el-button>
<el-button type="primary" :loading="loading" @click="loadClusters">同步</el-button>
</div>
<div class="panel">
<div class="panel-body">
<table>
<div v-if="error" class="error-line">{{ error }}</div>
<table v-loading="loading">
<thead>
<tr>
<th>集群</th>
@@ -24,7 +25,15 @@
</tr>
</thead>
<tbody>
<tr v-for="cluster in businessLineClusters" :key="cluster.name" class="tr-hover">
<tr v-if="!businessLineClusters.length">
<td colspan="8" class="text-dim">当前业务线暂无 Wayne 集群数据</td>
</tr>
<tr
v-for="cluster in businessLineClusters"
:key="cluster.name"
:class="['tr-hover', { selected: cluster.name === selectedCluster }]"
@click="selectedCluster = cluster.name"
>
<td class="strong">{{ cluster.name }}</td>
<td><span class="tag" :class="cluster.zoneClass">{{ cluster.zone }}</span></td>
<td :class="['status-text', cluster.statusClass]">● {{ cluster.status }}</td>
@@ -43,11 +52,11 @@
<div class="panel">
<div class="panel-head">
<h3>{{ businessLineClusters[0]?.name }} · 节点列表(节选)</h3>
<h3>{{ selectedCluster || businessLineClusters[0]?.name || '未选择集群' }} · 节点列表</h3>
<span class="meta">node-label 多租户隔离</span>
</div>
<div class="panel-body">
<table>
<table v-loading="loading">
<thead>
<tr>
<th>节点</th>
@@ -60,13 +69,21 @@
</tr>
</thead>
<tbody>
<tr v-if="!businessLineNodes.length">
<td colspan="7" class="text-dim">当前集群暂无 Wayne 节点数据</td>
</tr>
<tr v-for="node in businessLineNodes" :key="node.name" class="tr-hover">
<td class="strong mono">{{ node.name }}</td>
<td class="mono">{{ node.ip }}</td>
<td><span class="tag biz" :style="{ color: node.labelColor, borderColor: node.labelBorder }">{{ node.label }}</span></td>
<td><span class="tag biz">{{ node.label }}</span></td>
<td class="mono text-xs">{{ node.taint }}</td>
<td class="mono">{{ node.spec }}</td>
<td><span class="bar-wrap"><span class="bar-fill" :class="node.cpuClass" :style="{ width: node.cpu + '%' }"></span></span>{{ node.cpu }}%</td>
<td>
<template v-if="node.cpu > 0">
<span class="bar-wrap"><span class="bar-fill" :class="node.cpuClass" :style="{ width: node.cpu + '%' }"></span></span>{{ node.cpu }}%
</template>
<span v-else class="text-dim">-</span>
</td>
<td :class="['status-text', node.statusClass]">● {{ node.status }}</td>
</tr>
</tbody>
@@ -77,61 +94,77 @@
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useBusinessLineMockProfile } from '@/utils/businessLineMock'
import { computed, onMounted, ref, watch } from 'vue'
import { containerServiceApi, type ContainerCluster, type ContainerNode } from '@/api/containerService'
import { useBusinessLineStore } from '@/stores/businessLine'
const { currentName, profile } = useBusinessLineMockProfile()
const businessLineStore = useBusinessLineStore()
const clusters = ref<ContainerCluster[]>([])
const nodes = ref<ContainerNode[]>([])
const selectedCluster = ref('')
const loading = ref(false)
const error = ref('')
const clusters = ref([
{ name: 'rke2-bj-prod-01', zone: '华北 · IDC', zoneClass: 'zone-a', status: '健康', statusClass: 'ok', nodes: 46, cpu: 64, cpuClass: '', version: 'v1.28.9+rke2r1', calico: 'BGP AS64512' },
{ name: 'rke2-sh-prod-01', zone: '华东 · IDC', zoneClass: 'zone-b', status: '健康', statusClass: 'ok', nodes: 52, cpu: 71, cpuClass: '', version: 'v1.28.9+rke2r1', calico: 'BGP AS64513' },
{ name: 'rke2-ali-sz-01', zone: '阿里云 · 华南', zoneClass: 'zone-c', status: '1 节点告警', statusClass: 'warn', nodes: 30, cpu: 83, cpuClass: 'warn', version: 'v1.28.6+rke2r1', calico: 'BGP AS64514' },
])
const nodes = ref([
{ name: 'bj-node-014', ip: '10.21.4.14', label: 'business-line=kodo', labelColor: 'var(--tag-blue-text)', labelBorder: 'var(--tag-blue-border)', taint: 'kodo:NoSchedule', spec: '64C/256G', cpu: 58, cpuClass: '', status: 'Ready', statusClass: 'ok' },
{ name: 'bj-node-015', ip: '10.21.4.15', label: 'business-line=linxi', labelColor: 'var(--tag-blue-text)', labelBorder: 'var(--tag-blue-border)', taint: 'linxi:NoSchedule', spec: '64C/256G', cpu: 62, cpuClass: '', status: 'Ready', statusClass: 'ok' },
{ name: 'bj-node-031', ip: '10.21.4.31', label: 'business-line=xinfra', labelColor: 'var(--tag-purple-text)', labelBorder: 'var(--tag-purple-border)', taint: 'xinfra:NoSchedule', spec: '32C/128G', cpu: 88, cpuClass: 'warn', status: '资源告警', statusClass: 'warn' },
{ name: 'bj-node-048', ip: '10.21.4.48', label: 'business-line=las', labelColor: 'var(--text-dim)', labelBorder: 'var(--line)', taint: 'las:NoSchedule', spec: '32C/128G', cpu: 4, cpuClass: '', status: 'Ready · 空闲', statusClass: 'ok' },
])
const businessLineClusters = computed(() => {
const cpu = profile.value.cpuAllocated
return clusters.value.map((cluster, index) => ({
...cluster,
name: `${cluster.name}-${currentName.value}`,
nodes: Math.max(1, Math.round(profile.value.nodes * ([0.42, 0.36, 0.22][index] || 0.2))),
cpu: Math.min(96, Math.max(8, cpu + (index - 1) * 7)),
cpuClass: cpu > 75 ? 'warn' : '',
status: profile.value.alertsP1 && index === 2 ? `${profile.value.alertsP1} 节点告警` : '健康',
statusClass: profile.value.alertsP1 && index === 2 ? 'warn' : 'ok',
}))
})
const businessLineClusters = computed(() => clusters.value)
const businessLineNodes = computed(() => {
const selected = nodes.value.filter((node) => node.label === `business-line=${currentName.value}`)
if (selected.length) return selected
return [
{
name: `${currentName.value}-node-001`,
ip: '10.21.4.14',
label: `business-line=${currentName.value}`,
labelColor: 'var(--tag-blue-text)',
labelBorder: 'var(--tag-blue-border)',
taint: `${currentName.value}:NoSchedule`,
spec: '64C/256G',
cpu: profile.value.cpuAllocated,
cpuClass: profile.value.cpuAllocated > 75 ? 'warn' : '',
status: profile.value.alertsP1 ? '资源告警' : 'Ready',
statusClass: profile.value.alertsP1 ? 'warn' : 'ok',
},
]
if (!selectedCluster.value) return []
return nodes.value.filter((node) => node.cluster === selectedCluster.value)
})
async function loadClusters() {
const businessLineId = businessLineStore.current?.id
if (!businessLineId) {
clusters.value = []
nodes.value = []
selectedCluster.value = ''
return
}
loading.value = true
error.value = ''
try {
const data = await containerServiceApi.listClusters(businessLineId)
clusters.value = data.items
nodes.value = data.nodes
if (!clusters.value.some((cluster) => cluster.name === selectedCluster.value)) {
selectedCluster.value = clusters.value[0]?.name || ''
}
if (data.summary.errors?.length) {
error.value = data.summary.errors.join(';')
}
} catch (err) {
error.value = err instanceof Error ? err.message : 'Wayne 集群数据同步失败'
clusters.value = []
nodes.value = []
selectedCluster.value = ''
} finally {
loading.value = false
}
}
watch(
() => businessLineStore.current?.id,
() => {
void loadClusters()
},
)
onMounted(loadClusters)
</script>
<style scoped>
/* 公共样式已在 global.css 中定义 */
.tr-hover.selected {
background: rgba(22, 93, 255, 0.08);
}
.error-line {
margin-bottom: 12px;
color: var(--danger);
font-size: 13px;
}
.tag.biz {
color: var(--text-mid);
}
+199 -60
View File
@@ -5,60 +5,37 @@
<h1>机器管理</h1>
<p>统一纳管物理机与虚机资源 · 基础数据来自 SINA CMDB</p>
</div>
<el-button type="primary">同步</el-button>
<el-button type="primary" :loading="syncing" @click="handleSync">同步</el-button>
</div>
<div class="stat-row">
<div class="stat-card">
<div class="label">资源总数</div>
<div class="value">{{ filteredResources.length }}</div>
<div class="delta">物理机 {{ physicalCount }} · 虚机 {{ vmCount }}</div>
<div class="value">{{ overview.total }}</div>
<div class="delta">物理机 {{ overview.physical }} · 虚机 {{ overview.virtual }}</div>
</div>
<div class="stat-card">
<div class="label">SINA CMDB</div>
<div class="value">368</div>
<div class="delta">物理机 146 · 虚机 222</div>
<div class="value">{{ overview.cmdb.total }}</div>
<div class="delta">物理机 {{ overview.cmdb.physical }} · 虚机 {{ overview.cmdb.virtual }}</div>
</div>
<div class="stat-card">
<div class="label">阿里云同步</div>
<div class="value">154</div>
<div class="delta">ECS · 增量同步</div>
<div class="value">{{ overview.aliyun.total }}</div>
<div class="delta">{{ overview.aliyun.label }}</div>
</div>
<div class="stat-card">
<div class="label">AWS / 七牛同步</div>
<div class="value">136</div>
<div class="delta">AWS 58 · 七牛 78</div>
<div class="value">{{ overview.awsQiniu.total }}</div>
<div class="delta">AWS {{ overview.awsQiniu.aws }} · 七牛 {{ overview.awsQiniu.qiniu }}</div>
</div>
<div class="stat-card">
<div class="label">最近一次同步</div>
<div class="value accent-pill">● 正常</div>
<div class="delta">07:39:10 · 新增 3 条</div>
<div class="value accent-pill">● {{ syncStatusText }}</div>
<div class="delta">{{ overview.lastSync.time }} · 新增 {{ overview.lastSync.created }} 条</div>
</div>
</div>
<div class="las-toolbar">
<el-select placeholder="全部资源类型">
<el-option label="全部资源类型" value="" />
<el-option label="物理机" value="physical" />
<el-option label="虚机" value="vm" />
</el-select>
<el-select placeholder="全部数据来源">
<el-option label="全部数据来源" value="" />
<el-option label="SINA CMDB" value="cmdb" />
<el-option label="阿里云同步" value="aliyun" />
<el-option label="AWS 同步" value="aws" />
<el-option label="七牛同步" value="qiniu" />
</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-input placeholder="搜索 hostname / asset_number / IP" class="search-input" />
</div>
<div class="panel">
<div class="panel-head">
<h3>资源台账 · 物理机 / 虚机</h3>
@@ -78,31 +55,120 @@
<th>数据来源</th>
<th>状态</th>
</tr>
<tr class="filter-row">
<th>
<el-input
v-model="filters.hostname"
clearable
size="small"
placeholder="筛选主机名"
@clear="reloadFirstPage"
@keyup.enter="reloadFirstPage"
/>
</th>
<th>
<el-input
v-model="filters.assetNumber"
clearable
size="small"
placeholder="筛选资产编号"
@clear="reloadFirstPage"
@keyup.enter="reloadFirstPage"
/>
</th>
<th>
<el-select v-model="filters.type" clearable size="small" placeholder="全部" @change="reloadFirstPage">
<el-option label="物理机" value="physical" />
<el-option label="虚机" value="vm" />
</el-select>
</th>
<th>
<el-input
v-model="filters.location"
clearable
size="small"
placeholder="筛选机房"
@clear="reloadFirstPage"
@keyup.enter="reloadFirstPage"
/>
</th>
<th>
<el-input
v-model="filters.ip"
clearable
size="small"
placeholder="筛选 IP"
@clear="reloadFirstPage"
@keyup.enter="reloadFirstPage"
/>
</th>
<th>
<el-input
v-model="filters.spec"
clearable
size="small"
placeholder="筛选规格"
@clear="reloadFirstPage"
@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" />
<el-option label="阿里云同步" value="aliyun" />
<el-option label="AWS 同步" value="aws" />
<el-option label="七牛同步" value="qiniu" />
</el-select>
</th>
<th>
<el-select v-model="filters.status" clearable size="small" placeholder="全部" @change="reloadFirstPage">
<el-option label="production" value="production" />
<el-option label="stock" value="stock" />
<el-option label="SREshutdown" value="SREshutdown" />
<el-option label="unmon" value="unmon" />
<el-option label="ignore" value="ignore" />
</el-select>
</th>
</tr>
</thead>
<tbody>
<tr v-for="item in filteredResources" :key="item.hostname" class="tr-hover">
<tr v-for="item in resources" :key="`${item.hostname}-${item.ip}`" class="tr-hover">
<td class="strong mono">{{ item.hostname }}</td>
<td class="mono text-xs">{{ item.asset_number }}</td>
<td class="mono text-xs">{{ item.assetNumber }}</td>
<td><span class="tag" :class="item.type === '物理机' ? '' : 'vm'">{{ item.type }}</span></td>
<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.business_line }}</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>
</tr>
</tbody>
</table>
<div class="pagination">
<span>共 {{ filteredResources.length }} 条 · 当前业务线:{{ currentName }}</span>
<span>共 {{ total }} 条</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">94</span>
<span class="pg-btn">›</span>
<el-pagination
v-model:current-page="page"
v-model:page-size="size"
background
layout="prev, pager, next"
:total="total"
@current-change="loadResources"
/>
</div>
</div>
</div>
@@ -111,24 +177,81 @@
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useBusinessLineMockProfile } from '@/utils/businessLineMock'
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { emptyMachineOverview, machineApi, type MachineResource } from '@/api/machine'
const { currentName } = useBusinessLineMockProfile()
const overview = ref(emptyMachineOverview)
const resources = ref<MachineResource[]>([])
const total = ref(0)
const page = ref(1)
const size = ref(20)
const loading = ref(false)
const syncing = ref(false)
const filters = reactive({
hostname: '',
assetNumber: '',
type: '',
location: '',
ip: '',
spec: '',
businessLine: '',
source: '',
status: '',
})
const resources = ref([
{ hostname: 'xs291', asset_number: 'SERV00003502', type: '物理机', location: '华东·杭州下沙', ip: '10.34.37.52', spec: '20C/192G/13.52T', business_line: 'kodo', source: 'SINA CMDB', status: 'production' },
{ hostname: 'yzh-las-014', asset_number: 'SERV00004187', type: '虚机', location: '华北·YZH', ip: '10.21.4.214', spec: '32C/128G/4.0T', business_line: 'las', source: 'SINA CMDB', status: 'production' },
{ hostname: 'jf-bm-118', asset_number: 'SERV00004290', type: '物理机', location: '华南·JF', ip: '10.45.2.18', spec: '16C/64G/2.0T', business_line: 'linxi', source: 'SINA CMDB', status: 'production' },
{ hostname: 'ali-ecs-sz-0231', asset_number: '—', type: '虚机', location: '阿里云·华南', ip: '172.18.4.31', spec: '16C/64G/500G', business_line: 'xinfra', source: '阿里云同步', status: 'production' },
{ hostname: 'aws-us-i-0a13fe2', asset_number: '—', type: '虚机', location: 'AWS · 美国', ip: '10.66.2.12', spec: '8C/32G/200G', business_line: 'las', source: 'AWS 同步', status: 'production' },
{ hostname: 'sg-las-007', asset_number: 'SERV00004511', type: '虚机', location: '七牛 · 新加坡', ip: '10.88.1.07', spec: '16C/64G/2.0T', business_line: 'las', source: '七牛同步', status: 'idle' },
{ hostname: 'hk-bm-001', asset_number: 'SERV00004602', type: '物理机', location: '香港 IDC', ip: '10.90.0.11', spec: '8C/32G/1.0T', business_line: 'xinfra', source: 'SINA CMDB', status: 'production' },
])
const syncStatusText = computed(() => {
if (overview.value.lastSync.status === 'normal') return '正常'
if (overview.value.lastSync.status === 'failed') return '失败'
return '未知'
})
const filteredResources = computed(() => resources.value.filter((item) => item.business_line === currentName.value))
const physicalCount = computed(() => filteredResources.value.filter((item) => item.type === '物理机').length)
const vmCount = computed(() => filteredResources.value.filter((item) => item.type === '虚机').length)
async function loadOverview() {
overview.value = await machineApi.getOverview()
}
async function loadResources() {
loading.value = true
try {
const data = await machineApi.listResources({
page: page.value,
size: size.value,
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,
})
resources.value = data.items
total.value = data.total
} finally {
loading.value = false
}
}
async function reloadFirstPage() {
page.value = 1
await loadResources()
}
async function handleSync() {
syncing.value = true
try {
await machineApi.sync()
await Promise.all([loadOverview(), loadResources()])
ElMessage.success('机器数据同步完成')
} finally {
syncing.value = false
}
}
onMounted(async () => {
await Promise.all([loadOverview(), loadResources()])
})
</script>
<style scoped>
@@ -145,4 +268,20 @@ const vmCount = computed(() => filteredResources.value.filter((item) => item.typ
border-color: var(--tag-green-border);
background: var(--tag-green-bg);
}
.empty-cell {
color: var(--text-tertiary);
padding: 28px 0;
text-align: center;
}
.filter-row th {
background: var(--bg-elevated);
padding: 8px 10px;
}
.filter-row :deep(.el-input),
.filter-row :deep(.el-select) {
width: 100%;
}
</style>
+77 -20
View File
@@ -3,16 +3,16 @@
<div class="page-head">
<div>
<h1>服务管理</h1>
<p>同步全部机房 Consul 注册中心服务目录,统一查看服务名 / 服务 IP / 业务标签 / 实例数</p>
<p>同步基础服务实例台账,统一查看服务名 / 服务 IP / 业务标签 / 实例数</p>
</div>
<el-button type="primary">同步</el-button>
<el-button type="primary" :loading="serviceLoading" @click="loadServiceLedger">同步</el-button>
</div>
<div class="stat-row">
<div class="stat-card">
<div class="label">接入 Consul Datacenter</div>
<div class="value">7</div>
<div class="delta">国内 3 · 海外 4</div>
<div class="label">接入 Datacenter</div>
<div class="value">{{ serviceDatacenters }}</div>
<div class="delta">来自基础服务实例台账</div>
</div>
<div class="stat-card">
<div class="label">服务总数</div>
@@ -22,17 +22,17 @@
<div class="stat-card">
<div class="label">服务实例总数</div>
<div class="value">{{ serviceInstances }}</div>
<div class="delta">所有机房注册 IP 汇总</div>
<div class="delta">当前业务线实例汇总</div>
</div>
<div class="stat-card">
<div class="label">健康实例占比</div>
<div class="value accent">98.6%</div>
<div class="delta warn">12 个实例 critical</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">● 正常</div>
<div class="delta">07:41:05 · 间隔 30s</div>
<div class="value accent-pill">● {{ serviceError ? '异常' : '正常' }}</div>
<div class="delta">{{ serviceMetaText }}</div>
</div>
</div>
@@ -66,7 +66,7 @@
<div class="panel">
<div class="panel-head">
<h3>服务台账 · 按 Consul Datacenter 同步</h3>
<span class="meta">数据源:Consul Catalog API</span>
<span class="meta">{{ serviceLoading ? '同步中...' : serviceMetaText }}</span>
</div>
<div class="panel-body">
<table>
@@ -82,6 +82,9 @@
</tr>
</thead>
<tbody>
<tr v-if="!serviceLoading && filteredServices.length === 0">
<td colspan="7" 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>
@@ -154,28 +157,45 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { containerServiceApi, emptyContainerServiceSummary, type ContainerServiceSummary, type ContainerWorkload } from '@/api/containerService'
import { deliveryApi, type MySQLServiceLedgerItem } from '@/api/delivery'
import { useBusinessLineStore } from '@/stores/businessLine'
import { useBusinessLineMockProfile } from '@/utils/businessLineMock'
const { currentName } = useBusinessLineMockProfile()
const businessLineStore = useBusinessLineStore()
const services = ref([
{ name: 'kodo-gateway-svc', dc: 'YZH', dcClass: 'zone-a', biz: 'kodo', instances: 12, healthy: '12 / 12', ip: '10.21.4.51', status: '健康', statusClass: 'ok' },
{ name: 'kodo-upload-svc', dc: 'XS', dcClass: 'zone-b', biz: 'kodo', instances: 10, healthy: '10 / 10', ip: '10.34.37.66', status: '健康', statusClass: 'ok' },
{ name: 'linxi-render-svc', dc: 'JF', dcClass: 'zone-c', biz: 'linxi', instances: 6, healthy: '6 / 6', ip: '10.45.2.30', status: '健康', statusClass: 'ok' },
{ name: 'xinfra-portal-svc', dc: 'YZH', dcClass: 'zone-a', biz: 'xinfra', instances: 8, healthy: '8 / 8', ip: '10.21.4.88', status: '健康', statusClass: 'ok' },
{ name: 'las-search-api', dc: 'XS', dcClass: 'zone-b', biz: 'las', instances: 18, healthy: '17 / 18', ip: '10.34.37.20', status: '部分异常', statusClass: 'warn' },
{ name: 'las-order-svc', dc: '达拉斯 IDC', dcClass: '', biz: 'las', instances: 5, healthy: '5 / 5', ip: '10.66.2.20', status: '健康', statusClass: 'ok' },
])
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 containerServices = ref<ContainerWorkload[]>([])
const containerSummary = ref<ContainerServiceSummary>({ ...emptyContainerServiceSummary })
const containerLoading = ref(false)
const containerError = ref('')
const filteredServices = computed(() => services.value.filter((service) => service.biz === currentName.value))
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
@@ -184,6 +204,42 @@ const containerMetaText = computed(() => {
return errors > 0 ? `数据源:Kubernetes Workload API · ${errors} 项同步失败` : '数据源:Kubernetes Workload API'
})
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) {
@@ -209,6 +265,7 @@ const loadContainerServices = async () => {
watch(
() => businessLineStore.current?.id,
() => {
loadServiceLedger()
loadContainerServices()
},
{ immediate: true },