Merge pull request #48 from 1024XEngineer/prototype

feat(frontend): 实现 Vue 3 前端 MVP 完整功能
This commit is contained in:
Wonder
2026-07-14 16:02:28 +08:00
committed by GitHub
53 changed files with 6933 additions and 64 deletions
+9
View File
@@ -0,0 +1,9 @@
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// Generated by unplugin-auto-import
export {}
declare global {
}
+25
View File
@@ -0,0 +1,25 @@
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
export {}
declare module 'vue' {
export interface GlobalComponents {
AppHeader: typeof import('./src/components/Layout/AppHeader.vue')['default']
AppMain: typeof import('./src/components/Layout/AppMain.vue')['default']
AppSidebar: typeof import('./src/components/Layout/AppSidebar.vue')['default']
AuditLogTable: typeof import('./src/components/AuditLogTable.vue')['default']
ElButton: typeof import('element-plus/es')['ElButton']
ElForm: typeof import('element-plus/es')['ElForm']
ElFormItem: typeof import('element-plus/es')['ElFormItem']
ElIcon: typeof import('element-plus/es')['ElIcon']
ElInput: typeof import('element-plus/es')['ElInput']
ElOption: typeof import('element-plus/es')['ElOption']
ElSelect: typeof import('element-plus/es')['ElSelect']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
SubsystemCard: typeof import('./src/components/SubsystemCard.vue')['default']
}
}
+14
View File
@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>xinfra · 统一基础设施平台</title>
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600;700&display=swap" rel="stylesheet">
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+2370
View File
File diff suppressed because it is too large Load Diff
+27
View File
@@ -0,0 +1,27 @@
{
"name": "xinfra-frontend",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.3.4",
"vue-router": "^4.2.4",
"pinia": "^2.1.4",
"element-plus": "^2.3.12",
"@element-plus/icons-vue": "^2.1.0",
"axios": "^1.5.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^4.3.4",
"typescript": "^5.2.2",
"vite": "^4.4.9",
"vue-tsc": "^1.8.11",
"unplugin-auto-import": "^0.16.6",
"unplugin-vue-components": "^0.25.2"
}
}
+11
View File
@@ -0,0 +1,11 @@
<template>
<router-view />
</template>
<script setup lang="ts">
// 根组件
</script>
<style>
/* 全局样式已在 global.css 中定义 */
</style>
-7
View File
@@ -1,7 +0,0 @@
# API 请求模块
存放封装后端 API 请求的文件:
- request.ts: Axios 实例封装,请求拦截器(Token 注入)、响应拦截器(错误处理)
- auth.ts: 认证相关接口:登录、登出、获取用户信息
- audit.ts: 审计相关接口:查询登录审计、运维操作审计
- subsystem.ts: 子系统相关接口:获取子系统列表、SSO 跳转 URL
+72
View File
@@ -0,0 +1,72 @@
import request from './request'
export interface LoginAudit {
id: number
user_id: number
username: string
login_time: string
source_ip: string
target_system: string
status: 'success' | 'failed'
}
export interface OpsAudit {
id: number
user_id: number
username: string
operation_type: string
operation: string
target: string
status: 'success' | 'failed' | 'running'
created_at: string
}
// Mock 数据
const mockLoginAudits: LoginAudit[] = [
{ id: 1, user_id: 1, username: 'zhangsan', login_time: '2024-01-15T10:30:00Z', source_ip: '10.0.0.1', target_system: 'Wayne', status: 'success' },
{ id: 2, user_id: 2, username: 'lisi', login_time: '2024-01-15T10:25:00Z', source_ip: '10.0.0.2', target_system: 'CloudDM', status: 'success' },
{ id: 3, user_id: 3, username: 'wangwu', login_time: '2024-01-15T10:20:00Z', source_ip: '10.0.0.3', target_system: 'CacheCloud', status: 'failed' },
{ id: 4, user_id: 1, username: 'zhangsan', login_time: '2024-01-15T09:15:00Z', source_ip: '10.0.0.1', target_system: 'Grafana', status: 'success' },
{ id: 5, user_id: 4, username: 'zhaoliu', login_time: '2024-01-15T09:00:00Z', source_ip: '10.0.0.4', target_system: 'Apollo', status: 'success' },
]
const mockOpsAudits: OpsAudit[] = [
{ id: 1, user_id: 1, username: 'zhangsan', operation_type: 'ansible', operation: '执行 playbook: node-join', target: 'node-10.0.0.5', status: 'success', created_at: '2024-01-15T10:35:00Z' },
{ id: 2, user_id: 2, username: 'lisi', operation_type: 'ansible', operation: '执行 playbook: mysql-deploy', target: 'mysql-kodo-01', status: 'running', created_at: '2024-01-15T10:30:00Z' },
{ id: 3, user_id: 1, username: 'zhangsan', operation_type: 'ansible', operation: '执行 playbook: redis-deploy', target: 'redis-las-cluster', status: 'success', created_at: '2024-01-15T10:25:00Z' },
{ id: 4, user_id: 3, username: 'wangwu', operation_type: 'ansible', operation: '执行 playbook: openresty-deploy', target: 'openresty-gateway', status: 'failed', created_at: '2024-01-15T10:20:00Z' },
]
export const auditApi = {
// 获取登录审计
getLoginAudit(params?: any): Promise<any> {
return new Promise((resolve) => {
setTimeout(() => {
resolve({
code: 0,
message: 'success',
data: {
total: mockLoginAudits.length,
items: mockLoginAudits,
},
})
}, 300)
})
},
// 获取运维操作审计
getOpsAudit(params?: any): Promise<any> {
return new Promise((resolve) => {
setTimeout(() => {
resolve({
code: 0,
message: 'success',
data: {
total: mockOpsAudits.length,
items: mockOpsAudits,
},
})
}, 300)
})
},
}
+71
View File
@@ -0,0 +1,71 @@
import request from './request'
export interface LoginRequest {
username: string
password: string
}
export interface UserInfo {
id: number
username: string
display_name: string
email: string
business_line: string
}
export interface LoginResponse {
token: string
expires_in: number
user: UserInfo
}
// Mock 数据
const mockUser: UserInfo = {
id: 1,
username: 'admin',
display_name: '王晓东',
email: 'admin@qiniu.com',
business_line: 'kodo',
}
export const authApi = {
// 登录
login(data: LoginRequest): Promise<any> {
// MVP 阶段使用 Mock 数据
return new Promise((resolve) => {
setTimeout(() => {
resolve({
code: 0,
message: 'success',
data: {
token: 'mock-jwt-token-' + Date.now(),
expires_in: 3600,
user: mockUser,
},
})
}, 500)
})
},
// 登出
logout(): Promise<any> {
return new Promise((resolve) => {
setTimeout(() => {
resolve({ code: 0, message: 'success' })
}, 200)
})
},
// 获取用户信息
getUserInfo(): Promise<any> {
return new Promise((resolve) => {
setTimeout(() => {
resolve({
code: 0,
message: 'success',
data: mockUser,
})
}, 200)
})
},
}
+44
View File
@@ -0,0 +1,44 @@
import axios from 'axios'
import { getToken, removeToken } from '@/utils/auth'
import { ElMessage } from 'element-plus'
const request = axios.create({
baseURL: '/api/v1',
timeout: 10000,
})
// 请求拦截器
request.interceptors.request.use(
(config) => {
const token = getToken()
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
},
(error) => {
return Promise.reject(error)
}
)
// 响应拦截器
request.interceptors.response.use(
(response) => {
const { data } = response
if (data.code !== 0) {
ElMessage.error(data.message || '请求失败')
return Promise.reject(new Error(data.message))
}
return data
},
(error) => {
if (error.response?.status === 401) {
removeToken()
window.location.href = '/login'
}
ElMessage.error(error.message || '网络错误')
return Promise.reject(error)
}
)
export default request
+101
View File
@@ -0,0 +1,101 @@
import request from './request'
export interface Subsystem {
id: number
name: string
description: string
icon: string
url: string
status: 'integrated' | 'integrating'
sso_enabled: boolean
}
// Mock 数据
const mockSubsystems: Subsystem[] = [
{
id: 1,
name: 'Wayne',
description: '多集群容器管理平台',
icon: 'W',
url: 'https://wayne.qiniu.com',
status: 'integrated',
sso_enabled: true,
},
{
id: 2,
name: 'CloudDM',
description: '数据库管理与SQL审核',
icon: 'DM',
url: 'https://clouddm.qiniu.com',
status: 'integrated',
sso_enabled: true,
},
{
id: 3,
name: 'CacheCloud',
description: 'Redis 云管理平台',
icon: 'CC',
url: 'https://cachecloud.qiniu.com',
status: 'integrated',
sso_enabled: true,
},
{
id: 4,
name: 'Apollo',
description: '配置中心',
icon: 'AP',
url: 'https://apollo.xinfra.internal',
status: 'integrated',
sso_enabled: true,
},
{
id: 5,
name: 'qpass',
description: '密码管理平台',
icon: 'QP',
url: 'https://qpass.xinfra.internal',
status: 'integrated',
sso_enabled: true,
},
{
id: 6,
name: 'Grafana',
description: '监控可视化平台',
icon: 'GF',
url: 'https://grafana.xinfra.internal',
status: 'integrated',
sso_enabled: true,
},
]
export const subsystemApi = {
// 获取子系统列表
getSubsystems(): Promise<any> {
return new Promise((resolve) => {
setTimeout(() => {
resolve({
code: 0,
message: 'success',
data: mockSubsystems,
})
}, 300)
})
},
// 获取 SSO 跳转 URL
getSSOUrl(id: number): Promise<any> {
return new Promise((resolve) => {
setTimeout(() => {
const subsystem = mockSubsystems.find((s) => s.id === id)
resolve({
code: 0,
message: 'success',
data: {
sso_url: subsystem?.url + '/sso/callback?code=mock-code&state=mock-state',
expires_in: 300,
},
})
}, 200)
})
},
}
-6
View File
@@ -1,6 +0,0 @@
# 可复用 UI 组件
存放按功能模块组织的可复用组件:
- Layout/: 页面布局组件(AppHeader、AppSidebar、AppMain)
- SubsystemCard.vue: 子系统卡片组件
- AuditLogTable.vue: 审计日志表格组件
+207
View File
@@ -0,0 +1,207 @@
<template>
<div class="panel">
<div class="panel-head">
<h3>{{ title }}</h3>
<span class="meta">共 {{ total }} 条</span>
</div>
<div class="panel-body">
<table>
<thead>
<tr>
<th v-for="col in columns" :key="col.key">{{ col.label }}</th>
</tr>
</thead>
<tbody>
<tr v-for="row in data" :key="row.id" class="tr-hover">
<td v-for="col in columns" :key="col.key" :class="col.class">
<template v-if="col.key === 'status'">
<span :class="['status-text', row.status]">
● {{ statusMap[row.status] || row.status }}
</span>
</template>
<template v-else-if="col.key === 'source_ip' || col.key === 'username'">
<span class="mono">{{ row[col.key] }}</span>
</template>
<template v-else>
{{ row[col.key] }}
</template>
</td>
</tr>
</tbody>
</table>
<div class="pagination">
<span>共 {{ total }} 条 · 每页 {{ pageSize }} 条</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">{{ Math.ceil(total / pageSize) }}</span>
<span class="pg-btn">›</span>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
interface Column {
key: string
label: string
class?: string
}
defineProps<{
title: string
columns: Column[]
data: any[]
total: number
pageSize?: number
}>()
const statusMap: Record<string, string> = {
success: '成功',
failed: '失败',
running: '执行中',
}
</script>
<style scoped>
.panel {
background: var(--bg-panel);
border: 1px solid var(--line);
border-radius: 8px;
margin-bottom: 18px;
}
.panel-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 13px 16px;
border-bottom: 1px solid var(--line-soft);
}
.panel-head h3 {
margin: 0;
font-size: 13.5px;
font-weight: 600;
}
.panel-head .meta {
font-size: 11.5px;
color: var(--text-dim);
font-family: var(--mono);
}
.panel-body {
padding: 4px 0;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 12.5px;
}
th {
text-align: left;
color: var(--text-dim);
font-weight: 500;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.4px;
padding: 9px 16px;
border-bottom: 1px solid var(--line-soft);
}
td {
padding: 11px 16px;
border-bottom: 1px solid var(--line-soft);
color: var(--text-mid);
}
tr:last-child td {
border-bottom: none;
}
tr.tr-hover:hover td {
background: var(--bg-panel-2);
}
.mono {
font-family: var(--mono);
}
.status-text {
font-size: 12px;
display: flex;
align-items: center;
}
.status-text.success {
color: var(--accent);
}
.status-text.failed {
color: var(--err);
}
.status-text.running {
color: var(--warn);
}
.pagination {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 16px;
border-top: 1px solid var(--line-soft);
font-size: 11.5px;
color: var(--text-dim);
}
.pg-btns {
display: flex;
gap: 4px;
align-items: center;
}
.pg-btn {
min-width: 26px;
height: 26px;
padding: 0 6px;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid var(--line);
border-radius: 5px;
color: var(--text-mid);
font-family: var(--mono);
font-size: 11px;
background: var(--bg-panel-2);
cursor: pointer;
}
.pg-btn.active {
background: var(--accent-dim);
border-color: var(--accent-dim);
color: #CFFCE9;
}
.pg-btn:hover {
border-color: #3A4356;
color: var(--text-hi);
}
.pg-btn.disabled {
opacity: 0.35;
cursor: not-allowed;
}
.pg-sep {
color: var(--text-dim);
padding: 0 2px;
}
</style>
-6
View File
@@ -1,6 +0,0 @@
# 布局组件
存放页面布局相关的组件:
- AppHeader.vue: 顶部导航栏,显示 Logo、用户名、退出登录
- AppSidebar.vue: 侧边菜单栏,导航菜单项(仪表盘、子系统、审计)
- AppMain.vue: 主内容区,包裹路由视图的容器
@@ -0,0 +1,140 @@
<template>
<header class="topbar">
<div class="brand">
<div class="mark">xi</div>
xinfra <span class="sub">统一基础设施平台</span>
</div>
<span class="env-pill">● 生产环境</span>
<div class="spacer"></div>
<div class="search-box">
<el-icon><Search /></el-icon>
搜索集群 / 节点 / 服务实例
<kbd>⌘K</kbd>
</div>
<div class="identity">
<div class="avatar">{{ userInitial }}</div>
<div>
<div>{{ user?.display_name || '未登录' }}</div>
<div class="ldap-tag">LDAP · {{ user?.business_line || '' }}业务线</div>
</div>
</div>
</header>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useAuthStore } from '@/stores/auth'
const authStore = useAuthStore()
const user = computed(() => authStore.user)
const userInitial = computed(() => {
return user.value?.display_name?.charAt(0) || 'U'
})
</script>
<style scoped>
.topbar {
grid-column: 1/3;
display: flex;
align-items: center;
padding: 0 18px 0 16px;
background: var(--bg-panel);
border-bottom: 1px solid var(--line);
gap: 14px;
}
.brand {
display: flex;
align-items: center;
gap: 9px;
font-weight: 600;
letter-spacing: 0.3px;
font-size: 15px;
}
.brand .mark {
width: 22px;
height: 22px;
border-radius: 5px;
background: linear-gradient(135deg, var(--accent), #1C8F69);
display: flex;
align-items: center;
justify-content: center;
font-family: var(--mono);
font-size: 11px;
font-weight: 600;
color: #08140F;
}
.brand .sub {
color: var(--text-dim);
font-weight: 400;
font-size: 12px;
margin-left: 4px;
}
.spacer {
flex: 1;
}
.env-pill {
font-family: var(--mono);
font-size: 11px;
color: var(--accent);
border: 1px solid var(--accent-dim);
background: #0F261D;
padding: 3px 9px;
border-radius: 20px;
letter-spacing: 0.4px;
}
.search-box {
display: flex;
align-items: center;
gap: 7px;
background: var(--bg-panel-2);
border: 1px solid var(--line);
border-radius: 6px;
padding: 6px 10px;
color: var(--text-dim);
font-size: 12.5px;
width: 240px;
}
.search-box kbd {
margin-left: auto;
font-family: var(--mono);
font-size: 10px;
background: #262C38;
padding: 1px 5px;
border-radius: 3px;
color: var(--text-dim);
}
.identity {
display: flex;
align-items: center;
gap: 8px;
font-size: 12.5px;
color: var(--text-mid);
}
.identity .avatar {
width: 26px;
height: 26px;
border-radius: 50%;
background: #2A3142;
display: flex;
align-items: center;
justify-content: center;
font-family: var(--mono);
font-size: 11px;
color: var(--text-hi);
}
.identity .ldap-tag {
font-family: var(--mono);
font-size: 10px;
color: var(--text-dim);
}
</style>
@@ -0,0 +1,15 @@
<template>
<main class="main">
<router-view />
</main>
</template>
<script setup lang="ts">
</script>
<style scoped>
.main {
overflow-y: auto;
padding: 22px 26px 60px;
}
</style>
@@ -0,0 +1,149 @@
<template>
<aside class="sidebar">
<div class="nav-group">
<div class="nav-label">概览</div>
<router-link to="/dashboard" class="nav-item" active-class="active">
<span class="ic">▣</span>资源大盘
</router-link>
</div>
<div class="nav-group">
<div class="nav-label">机房 &amp; 网络</div>
<router-link to="/resource/status" class="nav-item" active-class="active">
<span class="ic">⬡</span>资源状态看板
</router-link>
<router-link to="/cluster" class="nav-item" active-class="active">
<span class="ic">◆</span>集群 / 节点<span class="badge">7</span>
</router-link>
</div>
<div class="nav-group">
<div class="nav-label">资源管理</div>
<router-link to="/resource/mgmt" class="nav-item" active-class="active">
<span class="ic">▥</span>资源管理<span class="badge">CMDB</span>
</router-link>
<router-link to="/service/mgmt" class="nav-item" active-class="active">
<span class="ic">◈</span>服务管理<span class="badge">Consul</span>
</router-link>
</div>
<div class="nav-group">
<div class="nav-label">服务交付</div>
<router-link to="/service/catalog" class="nav-item" active-class="active">
<span class="ic">▤</span>基础服务目录
</router-link>
<router-link to="/tasks" class="nav-item" active-class="active">
<span class="ic">▧</span>任务中心<span class="badge">2</span>
</router-link>
<router-link to="/config" class="nav-item" active-class="active">
<span class="ic">⚙</span>配置中心管理<span class="badge">Apollo</span>
</router-link>
</div>
<div class="nav-group">
<div class="nav-label">可观测性</div>
<router-link to="/monitor" class="nav-item" active-class="active">
<span class="ic">◎</span>监控 / 日志 / 告警
</router-link>
</div>
<div class="nav-group">
<div class="nav-label">统一入口</div>
<router-link to="/subsystem" class="nav-item" active-class="active">
<span class="ic">↗</span>子系统导航
</router-link>
</div>
<div class="nav-group">
<div class="nav-label">审计</div>
<router-link to="/audit/login" class="nav-item" active-class="active">
<span class="ic">📋</span>登录审计
</router-link>
<router-link to="/audit/ops" class="nav-item" active-class="active">
<span class="ic">📝</span>运维操作审计
</router-link>
</div>
<div class="sidebar-foot">
xinfra v3 一期 · 七机房容器化<br>RKE2 · Calico BGP · Ceph · LAS<br>AWS TGW + WireGuard · LDAP 认证
</div>
</aside>
</template>
<script setup lang="ts">
</script>
<style scoped>
.sidebar {
background: var(--bg-panel);
border-right: 1px solid var(--line);
padding: 14px 10px;
display: flex;
flex-direction: column;
overflow-y: auto;
}
.nav-group {
margin-bottom: 18px;
}
.nav-label {
font-size: 10.5px;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 1px;
padding: 0 10px;
margin-bottom: 6px;
font-weight: 600;
}
.nav-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
border-radius: 6px;
color: var(--text-mid);
font-size: 13px;
font-weight: 500;
cursor: pointer;
margin-bottom: 2px;
border: 1px solid transparent;
text-decoration: none;
}
.nav-item .ic {
width: 16px;
text-align: center;
font-size: 13px;
opacity: 0.85;
}
.nav-item:hover {
background: var(--bg-panel-2);
color: var(--text-hi);
}
.nav-item.active {
background: #13261F;
color: var(--accent);
border-color: var(--accent-dim);
}
.nav-item .badge {
margin-left: auto;
font-family: var(--mono);
font-size: 10px;
background: #262C38;
color: var(--text-dim);
padding: 1px 6px;
border-radius: 10px;
}
.nav-item.active .badge {
background: var(--accent-dim);
color: #CFFCE9;
}
.sidebar-foot {
margin-top: auto;
padding: 10px;
border-top: 1px solid var(--line-soft);
font-size: 11px;
color: var(--text-dim);
line-height: 1.6;
}
</style>
+146
View File
@@ -0,0 +1,146 @@
<template>
<div class="ext-card" @click="handleClick">
<div class="ext-top">
<div class="ext-logo" :style="{ background: bgColor, color: iconColor }">
{{ system.icon }}
</div>
<div>
<h4>{{ system.name }}</h4>
<div class="ext-sub">{{ system.description }}</div>
</div>
</div>
<p>{{ description }}</p>
<div class="sso-row">
<span class="sso-dot" :class="{ warn: system.status === 'integrating' }"></span>
{{ system.status === 'integrated' ? 'LDAP 原生配置接入 · 零代码' : 'LDAP 接入改造中' }}
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { subsystemApi, type Subsystem } from '@/api/subsystem'
const props = defineProps<{
system: Subsystem
}>()
const bgColor = computed(() => {
const colors: Record<string, string> = {
W: '#1C2A3A',
DM: '#1C3A2E',
CC: '#3A1C28',
AP: '#1C2A3A',
QP: '#241B0A',
GF: '#1B202B',
}
return colors[props.system.icon] || '#262C38'
})
const iconColor = computed(() => {
const colors: Record<string, string> = {
W: '#7FB8FF',
DM: '#7FFFC2',
CC: '#FF8E9C',
AP: '#8EC8FF',
QP: '#FFC97A',
GF: '#F2B544',
}
return colors[props.system.icon] || '#9AA3B5'
})
const description = computed(() => {
const descs: Record<string, string> = {
Wayne: '业务容器发布、命名空间与配额管理,复用 Wayne 原生多租户能力。',
CloudDM: '数据库 SQL 上线统一审核,支持 LDAP 用户组到角色的自动映射。',
CacheCloud: 'Redis 实例全生命周期管理,登录与监控运维统一入口。',
Apollo: '统一配置管理,按机房 Cluster 隔离,支持灰度发布、版本回滚与变更审计。',
qpass: '夜莺、Zabbix、VictoriaMetrics 告警统一收敛与值班通知,按 P0/P1 分级推送。',
Grafana: '对接 VictoriaMetrics 数据源,K8s / 容器 / 业务指标统一仪表盘展示。',
}
return descs[props.system.name] || ''
})
const handleClick = async () => {
if (props.system.sso_enabled) {
const { data } = await subsystemApi.getSSOUrl(props.system.id)
window.open(data.sso_url, '_blank')
} else {
window.open(props.system.url, '_blank')
}
}
</script>
<style scoped>
.ext-card {
background: var(--bg-panel);
border: 1px solid var(--line);
border-radius: 8px;
padding: 18px;
display: flex;
flex-direction: column;
gap: 10px;
cursor: pointer;
transition: border-color 0.15s;
}
.ext-card:hover {
border-color: #3A4356;
}
.ext-top {
display: flex;
align-items: center;
gap: 10px;
}
.ext-logo {
width: 38px;
height: 38px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-family: var(--mono);
font-weight: 700;
font-size: 13px;
}
h4 {
margin: 0;
font-size: 14px;
}
.ext-sub {
font-size: 11px;
color: var(--text-dim);
font-family: var(--mono);
}
p {
font-size: 12px;
color: var(--text-mid);
line-height: 1.6;
margin: 0;
}
.sso-row {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
color: var(--text-dim);
margin-top: auto;
}
.sso-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--accent);
}
.sso-dot.warn {
background: var(--warn);
}
</style>
-4
View File
@@ -1,4 +0,0 @@
# Vue 3 组合式函数
存放可复用的业务逻辑:
- useAuth.ts: 认证状态管理,包括登录、登出、Token 校验、路由守卫
+35
View File
@@ -0,0 +1,35 @@
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { authApi } from '@/api/auth'
export function useAuth() {
const router = useRouter()
const authStore = useAuthStore()
const login = async (username: string, password: string) => {
const response = await authApi.login({ username, password })
const { token, user } = response.data
authStore.setAuth(token, user)
return response
}
const logout = async () => {
await authApi.logout()
authStore.clearAuth()
router.push('/login')
}
const checkAuth = () => {
if (!authStore.isLoggedIn()) {
router.push('/login')
return false
}
return true
}
return {
login,
logout,
checkAuth,
}
}
+7
View File
@@ -0,0 +1,7 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}
-4
View File
@@ -1,4 +0,0 @@
# 页面布局定义
存放不同页面的布局组件:
- DefaultLayout.vue: 默认布局,包含顶栏 + 侧边栏 + 内容区
+22
View File
@@ -0,0 +1,22 @@
<template>
<div class="shell">
<AppHeader />
<AppSidebar />
<AppMain />
</div>
</template>
<script setup lang="ts">
import AppHeader from '@/components/Layout/AppHeader.vue'
import AppSidebar from '@/components/Layout/AppSidebar.vue'
import AppMain from '@/components/Layout/AppMain.vue'
</script>
<style scoped>
.shell {
display: grid;
grid-template-columns: 226px 1fr;
grid-template-rows: 56px 1fr;
height: 100vh;
}
</style>
+22
View File
@@ -0,0 +1,22 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import App from './App.vue'
import router from './router'
import './styles/global.css'
const app = createApp(App)
// 注册 Element Plus 图标
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
app.use(createPinia())
app.use(router)
app.use(ElementPlus)
app.mount('#app')
-4
View File
@@ -1,4 +0,0 @@
# 前端路由配置
存放路由相关配置:
- index.ts: 路由实例,定义路由表、路由守卫(未登录重定向)
+112
View File
@@ -0,0 +1,112 @@
import { createRouter, createWebHistory } from 'vue-router'
import { getToken } from '@/utils/auth'
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/login',
name: 'Login',
component: () => import('@/views/auth/Login.vue'),
meta: { requiresAuth: false },
},
{
path: '/',
component: () => import('@/layouts/DefaultLayout.vue'),
meta: { requiresAuth: true },
children: [
{
path: '',
redirect: '/dashboard',
},
{
path: 'dashboard',
name: 'Dashboard',
component: () => import('@/views/dashboard/Index.vue'),
meta: { title: '资源大盘' },
},
{
path: 'subsystem',
name: 'Subsystem',
component: () => import('@/views/subsystem/Navigation.vue'),
meta: { title: '子系统导航' },
},
{
path: 'audit/login',
name: 'LoginAudit',
component: () => import('@/views/audit/LoginAudit.vue'),
meta: { title: '登录审计' },
},
{
path: 'audit/ops',
name: 'OpsAudit',
component: () => import('@/views/audit/OpsAudit.vue'),
meta: { title: '运维操作审计' },
},
{
path: 'resource/status',
name: 'ResourceStatus',
component: () => import('@/views/resource/StatusBoard.vue'),
meta: { title: '资源状态看板' },
},
{
path: 'resource/mgmt',
name: 'ResourceMgmt',
component: () => import('@/views/resource/Management.vue'),
meta: { title: '资源管理' },
},
{
path: 'cluster',
name: 'Cluster',
component: () => import('@/views/cluster/ClusterList.vue'),
meta: { title: '集群 / 节点' },
},
{
path: 'service/catalog',
name: 'ServiceCatalog',
component: () => import('@/views/service/Catalog.vue'),
meta: { title: '基础服务目录' },
},
{
path: 'service/mgmt',
name: 'ServiceMgmt',
component: () => import('@/views/service/Management.vue'),
meta: { title: '服务管理' },
},
{
path: 'tasks',
name: 'Tasks',
component: () => import('@/views/task/TaskCenter.vue'),
meta: { title: '任务中心' },
},
{
path: 'config',
name: 'ConfigCenter',
component: () => import('@/views/config/ConfigCenter.vue'),
meta: { title: '配置中心管理' },
},
{
path: 'monitor',
name: 'Monitor',
component: () => import('@/views/monitor/Monitor.vue'),
meta: { title: '监控 / 日志 / 告警' },
},
],
},
],
})
// 路由守卫
router.beforeEach((to, from, next) => {
const token = getToken()
if (to.meta.requiresAuth !== false && !token) {
next('/login')
} else if (to.path === '/login' && token) {
next('/')
} else {
next()
}
})
export default router
-5
View File
@@ -1,5 +0,0 @@
# Pinia 状态管理
存放全局状态管理模块:
- auth.ts: 认证状态,存储 Token、用户信息,提供登录/登出 actions
- user.ts: 用户信息状态,缓存当前登录用户的详细信息
+34
View File
@@ -0,0 +1,34 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { getToken, setToken, removeToken, getUser, setUser, removeUser } from '@/utils/auth'
export const useAuthStore = defineStore('auth', () => {
const token = ref<string | null>(getToken())
const user = ref<any>(getUser())
function setAuth(newToken: string, newUser: any) {
token.value = newToken
user.value = newUser
setToken(newToken)
setUser(newUser)
}
function clearAuth() {
token.value = null
user.value = null
removeToken()
removeUser()
}
function isLoggedIn() {
return !!token.value
}
return {
token,
user,
setAuth,
clearAuth,
isLoggedIn,
}
})
+132
View File
@@ -0,0 +1,132 @@
@import './variables.css';
* {
box-sizing: border-box;
}
html, body {
margin: 0;
padding: 0;
background: var(--bg-deep);
color: var(--text-hi);
font-family: var(--sans);
font-size: 14px;
}
/* 滚动条样式 */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-thumb {
background: #2A3140;
border-radius: 4px;
}
::-webkit-scrollbar-track {
background: transparent;
}
a {
color: inherit;
text-decoration: none;
}
button {
font-family: var(--sans);
cursor: pointer;
}
/* 通用样式类 */
.mono {
font-family: var(--mono);
}
/* 状态指示器 */
.dot {
width: 7px;
height: 7px;
border-radius: 50%;
display: inline-block;
margin-right: 6px;
}
.dot.ok {
background: var(--accent);
box-shadow: 0 0 6px #3FE0A599;
}
.dot.warn {
background: var(--warn);
}
.dot.err {
background: var(--err);
}
.dot.idle {
background: var(--text-dim);
}
/* 状态文本 */
.status-text {
font-size: 12px;
display: flex;
align-items: center;
}
.status-text.ok {
color: var(--accent);
}
.status-text.warn {
color: var(--warn);
}
.status-text.err {
color: var(--err);
}
.status-text.idle {
color: var(--text-dim);
}
/* 标签 */
.tag {
display: inline-flex;
align-items: center;
gap: 5px;
font-family: var(--mono);
font-size: 10.5px;
padding: 2px 8px;
border-radius: 4px;
border: 1px solid var(--line);
color: var(--text-mid);
}
/* 进度条 */
.bar-wrap {
width: 90px;
height: 5px;
background: #262C38;
border-radius: 3px;
overflow: hidden;
display: inline-block;
vertical-align: middle;
margin-right: 8px;
}
.bar-fill {
height: 100%;
border-radius: 3px;
background: var(--accent);
}
.bar-fill.warn {
background: var(--warn);
}
.bar-fill.err {
background: var(--err);
}
+31
View File
@@ -0,0 +1,31 @@
:root {
/* 背景色 */
--bg-deep: #0E1117;
--bg-panel: #151922;
--bg-panel-2: #1B202B;
/* 边框色 */
--line: #262C38;
--line-soft: #1E2330;
/* 文字色 */
--text-hi: #E8EBF1;
--text-mid: #9AA3B5;
--text-dim: #5C657A;
/* 强调色 */
--accent: #3FE0A5;
--accent-dim: #1F6B52;
/* 状态色 */
--warn: #F2B544;
--err: #F2554F;
--info: #5B9DF2;
/* 圆角 */
--radius: 6px;
/* 字体 */
--mono: 'IBM Plex Mono', monospace;
--sans: 'IBM Plex Sans', sans-serif;
}
-4
View File
@@ -1,4 +0,0 @@
# 通用工具函数
存放不依赖业务逻辑的通用工具:
- auth.ts: 认证工具,Token 解析、过期校验、本地存储读写
+36
View File
@@ -0,0 +1,36 @@
const TOKEN_KEY = 'xinfra_token'
const USER_KEY = 'xinfra_user'
export function getToken(): string | null {
return localStorage.getItem(TOKEN_KEY)
}
export function setToken(token: string): void {
localStorage.setItem(TOKEN_KEY, token)
}
export function removeToken(): void {
localStorage.removeItem(TOKEN_KEY)
}
export function getUser(): any {
const userStr = localStorage.getItem(USER_KEY)
return userStr ? JSON.parse(userStr) : null
}
export function setUser(user: any): void {
localStorage.setItem(USER_KEY, JSON.stringify(user))
}
export function removeUser(): void {
localStorage.removeItem(USER_KEY)
}
export function isTokenExpired(token: string): boolean {
try {
const payload = JSON.parse(atob(token.split('.')[1]))
return payload.exp * 1000 < Date.now()
} catch {
return true
}
}
-7
View File
@@ -1,7 +0,0 @@
# 页面视图组件
存放页面级组件,一个文件对应一个完整页面:
- auth/: 认证相关页面(Login.vue)
- dashboard/: 仪表盘页面(Index.vue)
- subsystem/: 子系统导航页面(Navigation.vue)
- audit/: 审计面板页面(LoginAudit.vue、OpsAudit.vue)
-5
View File
@@ -1,5 +0,0 @@
# 审计面板页面
存放审计相关的视图组件:
- LoginAudit.vue: 登录审计页,查询登录记录、按时间/IP/结果筛选
- OpsAudit.vue: 运维操作审计页,查询操作记录、按类型/状态筛选
+70
View File
@@ -0,0 +1,70 @@
<template>
<div>
<div class="page-head">
<div>
<h1>登录审计</h1>
<p>查询所有用户的登录记录,包括时间、IP、目标系统、结果</p>
</div>
<el-button @click="refresh">⟳ 刷新</el-button>
</div>
<AuditLogTable
title="登录审计记录"
:columns="columns"
:data="auditData"
:total="total"
/>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { auditApi, type LoginAudit } from '@/api/audit'
import AuditLogTable from '@/components/AuditLogTable.vue'
const columns = [
{ key: 'username', label: '用户名', class: 'mono' },
{ key: 'login_time', label: '登录时间' },
{ key: 'source_ip', label: '来源 IP', class: 'mono' },
{ key: 'target_system', label: '目标系统' },
{ key: 'status', label: '状态' },
]
const auditData = ref<LoginAudit[]>([])
const total = ref(0)
const fetchData = async () => {
const { data } = await auditApi.getLoginAudit()
auditData.value = data.items
total.value = data.total
}
const refresh = () => {
fetchData()
}
onMounted(() => {
fetchData()
})
</script>
<style scoped>
.page-head {
display: flex;
align-items: flex-end;
justify-content: space-between;
margin-bottom: 18px;
}
.page-head h1 {
font-size: 19px;
margin: 0 0 4px;
font-weight: 700;
letter-spacing: 0.2px;
}
.page-head p {
margin: 0;
color: var(--text-dim);
font-size: 12.5px;
}
</style>
+71
View File
@@ -0,0 +1,71 @@
<template>
<div>
<div class="page-head">
<div>
<h1>运维操作审计</h1>
<p>查询运维操作记录,包括操作类型、目标、结果</p>
</div>
<el-button @click="refresh">⟳ 刷新</el-button>
</div>
<AuditLogTable
title="运维操作审计记录"
:columns="columns"
:data="auditData"
:total="total"
/>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { auditApi, type OpsAudit } from '@/api/audit'
import AuditLogTable from '@/components/AuditLogTable.vue'
const columns = [
{ key: 'username', label: '用户名', class: 'mono' },
{ key: 'operation_type', label: '操作类型' },
{ key: 'operation', label: '操作内容' },
{ key: 'target', label: '目标' },
{ key: 'status', label: '状态' },
{ key: 'created_at', label: '时间' },
]
const auditData = ref<OpsAudit[]>([])
const total = ref(0)
const fetchData = async () => {
const { data } = await auditApi.getOpsAudit()
auditData.value = data.items
total.value = data.total
}
const refresh = () => {
fetchData()
}
onMounted(() => {
fetchData()
})
</script>
<style scoped>
.page-head {
display: flex;
align-items: flex-end;
justify-content: space-between;
margin-bottom: 18px;
}
.page-head h1 {
font-size: 19px;
margin: 0 0 4px;
font-weight: 700;
letter-spacing: 0.2px;
}
.page-head p {
margin: 0;
color: var(--text-dim);
font-size: 12.5px;
}
</style>
-4
View File
@@ -1,4 +0,0 @@
# 认证相关页面
存放认证相关的视图组件:
- Login.vue: 登录页,包含用户名密码表单、LDAP 认证入口
+198
View File
@@ -0,0 +1,198 @@
<template>
<div class="login-container">
<div class="login-card">
<div class="login-header">
<div class="brand">
<div class="mark">xi</div>
xinfra
</div>
<h2>统一基础设施平台</h2>
<p>LDAP 账号登录</p>
</div>
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-position="top"
@submit.prevent="handleLogin"
>
<el-form-item label="用户名" prop="username">
<el-input
v-model="form.username"
placeholder="请输入用户名"
prefix-icon="User"
size="large"
/>
</el-form-item>
<el-form-item label="密码" prop="password">
<el-input
v-model="form.password"
type="password"
placeholder="请输入密码"
prefix-icon="Lock"
size="large"
show-password
/>
</el-form-item>
<el-form-item>
<el-button
type="primary"
size="large"
:loading="loading"
@click="handleLogin"
style="width: 100%"
>
登录
</el-button>
</el-form-item>
</el-form>
<div class="login-footer">
<p>统一 LDAP 账号,同账号同密码</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue'
import { useRouter } from 'vue-router'
import { useAuth } from '@/composables/useAuth'
import { ElMessage } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
const router = useRouter()
const { login } = useAuth()
const formRef = ref<FormInstance>()
const loading = ref(false)
const form = reactive({
username: '',
password: '',
})
const rules: FormRules = {
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
}
const handleLogin = async () => {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
loading.value = true
try {
await login(form.username, form.password)
ElMessage.success('登录成功')
router.push('/')
} catch (error: any) {
ElMessage.error(error.message || '登录失败')
} finally {
loading.value = false
}
}
</script>
<style scoped>
.login-container {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-deep);
}
.login-card {
width: 400px;
background: var(--bg-panel);
border: 1px solid var(--line);
border-radius: 12px;
padding: 40px;
}
.login-header {
text-align: center;
margin-bottom: 32px;
}
.login-header .brand {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
font-size: 24px;
font-weight: 700;
margin-bottom: 12px;
}
.login-header .brand .mark {
width: 36px;
height: 36px;
border-radius: 8px;
background: linear-gradient(135deg, var(--accent), #1C8F69);
display: flex;
align-items: center;
justify-content: center;
font-family: var(--mono);
font-size: 16px;
font-weight: 600;
color: #08140F;
}
.login-header h2 {
margin: 0 0 8px;
font-size: 18px;
font-weight: 600;
}
.login-header p {
margin: 0;
color: var(--text-dim);
font-size: 14px;
}
.login-footer {
text-align: center;
margin-top: 24px;
padding-top: 16px;
border-top: 1px solid var(--line-soft);
}
.login-footer p {
margin: 0;
color: var(--text-dim);
font-size: 12px;
}
:deep(.el-form-item__label) {
color: var(--text-mid);
}
:deep(.el-input__wrapper) {
background: var(--bg-panel-2);
border: 1px solid var(--line);
box-shadow: none;
}
:deep(.el-input__wrapper:hover) {
border-color: #3A4356;
}
:deep(.el-input__wrapper.is-focus) {
border-color: var(--accent);
}
:deep(.el-input__inner) {
color: var(--text-hi);
}
:deep(.el-button--primary) {
background: var(--accent);
border-color: var(--accent);
color: #06150F;
font-weight: 600;
}
:deep(.el-button--primary:hover) {
background: #55E8B3;
}
</style>
+255
View File
@@ -0,0 +1,255 @@
<template>
<div>
<div class="page-head">
<div>
<h1>集群 / 节点</h1>
<p>按机房切分的 RKE2 集群,Calico BGP 扁平网络</p>
</div>
<el-button type="primary">+ 加入新节点</el-button>
</div>
<div class="panel">
<div class="panel-body">
<table>
<thead>
<tr>
<th>集群</th>
<th>机房 / 区域</th>
<th>状态</th>
<th>节点</th>
<th>CPU 使用</th>
<th>RKE2 版本</th>
<th>Calico</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="cluster in clusters" :key="cluster.name" class="tr-hover">
<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>
<td class="mono">{{ cluster.nodes }}</td>
<td>
<span class="bar-wrap"><span class="bar-fill" :class="cluster.cpuClass" :style="{ width: cluster.cpu + '%' }"></span></span>{{ cluster.cpu }}%
</td>
<td class="mono">{{ cluster.version }}</td>
<td class="mono">{{ cluster.calico }}</td>
<td><el-button text>详情</el-button></td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="panel">
<div class="panel-head">
<h3>rke2-bj-prod-01 · 节点列表(节选)</h3>
<span class="meta">node-label 多租户隔离</span>
</div>
<div class="panel-body">
<table>
<thead>
<tr>
<th>节点</th>
<th>IP</th>
<th>业务线标签</th>
<th>污点 Taint</th>
<th>规格</th>
<th>CPU / Mem</th>
<th>状态</th>
</tr>
</thead>
<tbody>
<tr v-for="node in nodes" :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 class="mono" style="font-size: 11px">{{ 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 :class="['status-text', node.statusClass]">● {{ node.status }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
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: '#8EC8FF', labelBorder: '#1C3A5C', 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=kodo', labelColor: '#8EC8FF', labelBorder: '#1C3A5C', taint: 'kodo:NoSchedule', spec: '64C/256G', cpu: 62, cpuClass: '', status: 'Ready', statusClass: 'ok' },
{ name: 'bj-node-031', ip: '10.21.4.31', label: 'business-line=las', labelColor: '#C9A6FF', labelBorder: '#3A2C5C', taint: 'las:NoSchedule', spec: '32C/128G', cpu: 88, cpuClass: 'warn', status: '资源告警', statusClass: 'warn' },
{ name: 'bj-node-048', ip: '10.21.4.48', label: '未分配', labelColor: 'var(--text-dim)', labelBorder: 'var(--line)', taint: '—', spec: '32C/128G', cpu: 4, cpuClass: '', status: 'Ready · 空闲', statusClass: 'ok' },
])
</script>
<style scoped>
.page-head {
display: flex;
align-items: flex-end;
justify-content: space-between;
margin-bottom: 18px;
}
.page-head h1 {
font-size: 19px;
margin: 0 0 4px;
font-weight: 700;
}
.page-head p {
margin: 0;
color: var(--text-dim);
font-size: 12.5px;
}
.panel {
background: var(--bg-panel);
border: 1px solid var(--line);
border-radius: 8px;
margin-bottom: 18px;
}
.panel-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 13px 16px;
border-bottom: 1px solid var(--line-soft);
}
.panel-head h3 {
margin: 0;
font-size: 13.5px;
font-weight: 600;
}
.panel-head .meta {
font-size: 11.5px;
color: var(--text-dim);
font-family: var(--mono);
}
.panel-body {
padding: 4px 0;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 12.5px;
}
th {
text-align: left;
color: var(--text-dim);
font-weight: 500;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.4px;
padding: 9px 16px;
border-bottom: 1px solid var(--line-soft);
}
td {
padding: 11px 16px;
border-bottom: 1px solid var(--line-soft);
color: var(--text-mid);
}
tr:last-child td {
border-bottom: none;
}
tr.tr-hover:hover td {
background: var(--bg-panel-2);
}
.strong {
color: var(--text-hi);
font-weight: 500;
}
.mono {
font-family: var(--mono);
}
.tag {
display: inline-flex;
align-items: center;
gap: 5px;
font-family: var(--mono);
font-size: 10.5px;
padding: 2px 8px;
border-radius: 4px;
border: 1px solid var(--line);
color: var(--text-mid);
}
.tag.zone-a {
color: #8EC8FF;
border-color: #1C3A5C;
background: #0E1C2C;
}
.tag.zone-b {
color: #C9A6FF;
border-color: #3A2C5C;
background: #1A1430;
}
.tag.zone-c {
color: #FFC97A;
border-color: #5C4A1C;
background: #241B0A;
}
.tag.biz {
color: var(--text-mid);
}
.bar-wrap {
width: 90px;
height: 5px;
background: #262C38;
border-radius: 3px;
overflow: hidden;
display: inline-block;
vertical-align: middle;
margin-right: 8px;
}
.bar-fill {
height: 100%;
border-radius: 3px;
background: var(--accent);
}
.bar-fill.warn {
background: var(--warn);
}
.status-text {
font-size: 12px;
display: flex;
align-items: center;
}
.status-text.ok {
color: var(--accent);
}
.status-text.warn {
color: var(--warn);
}
</style>
+349
View File
@@ -0,0 +1,349 @@
<template>
<div>
<div class="page-head">
<div>
<h1>配置中心管理</h1>
<p>Apollo 配置中心(携程开源)· 按机房部署 Config Service Cluster,Portal 统一入口管理</p>
</div>
<span class="env-pill">● 运行中</span>
</div>
<div class="stat-row">
<div class="stat-card">
<div class="label">接入机房数</div>
<div class="value">3 / 7</div>
<div class="delta">国内 3 已接入 · 海外 4 建设中</div>
</div>
<div class="stat-card">
<div class="label">Apollo Cluster</div>
<div class="value">7</div>
<div class="delta">按机房 1:1 划分</div>
</div>
<div class="stat-card">
<div class="label">配置项总数</div>
<div class="value">1,260</div>
<div class="delta">Namespace 38 个</div>
</div>
<div class="stat-card">
<div class="label">接入业务线</div>
<div class="value">2 / 5</div>
<div class="delta">kodo · las 已接入</div>
</div>
<div class="stat-card">
<div class="label">同步状态</div>
<div class="value" style="color: var(--accent); font-size: 16px">● 正常</div>
<div class="delta">最近发布 09:12:30</div>
</div>
</div>
<div class="panel">
<div class="panel-head">
<h3>统一入口</h3>
<span class="meta">Apollo Portal · LDAP 单点登录</span>
</div>
<div class="panel-body" style="padding: 16px">
<div class="ext-card">
<div class="ext-top">
<div class="ext-logo" style="background: #1C2A3A; color: #8EC8FF">AP</div>
<div>
<h4>Apollo Portal</h4>
<div class="ext-sub">apollo.xinfra.internal</div>
</div>
</div>
<p>统一管理 7 个机房 Config Service Cluster 的配置发布、灰度、回滚与变更审计,所有机房配置项均可在此单一入口检索与编辑,发布后由各机房 Config Service 就近下发。</p>
<div class="sso-row">
<span class="sso-dot"></span>LDAP 原生配置接入 · 已上线
</div>
<el-button type="primary" style="margin-top: 8px">打开 Apollo Portal ↗</el-button>
</div>
</div>
</div>
<div class="panel">
<div class="panel-head">
<h3>机房配置中心列表</h3>
<span class="meta">Cluster 维度 · Config Service 部署明细</span>
</div>
<div class="panel-body">
<table>
<thead>
<tr>
<th>机房 / 区域</th>
<th>Apollo Cluster</th>
<th>Config Service 地址</th>
<th>承载方式</th>
<th>接入业务线</th>
<th>配置项数</th>
<th>状态</th>
</tr>
</thead>
<tbody>
<tr v-for="cluster in clusters" :key="cluster.name" class="tr-hover">
<td><span class="tag" :class="cluster.zoneClass">{{ cluster.name }}</span></td>
<td class="mono">cluster={{ cluster.cluster }}</td>
<td class="mono" style="font-size: 11px">{{ cluster.address }}</td>
<td class="mono">{{ cluster.type }}</td>
<td class="mono">{{ cluster.biz }}</td>
<td class="mono">{{ cluster.items }}</td>
<td :class="['status-text', cluster.statusClass]">● {{ cluster.status }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const clusters = ref([
{ name: 'YZH 机房', zoneClass: 'zone-a', cluster: 'yzh', address: 'config-yzh.xinfra.internal:8080', type: '容器化 · K8s Service', biz: 'kodo', items: 486, status: '运行中', statusClass: 'ok' },
{ name: 'XS 机房', zoneClass: 'zone-b', cluster: 'xs', address: 'config-xs.xinfra.internal:8080', type: '容器化 · K8s Service', biz: 'las', items: 398, status: '运行中', statusClass: 'ok' },
{ name: 'JF 机房', zoneClass: 'zone-c', cluster: 'jf', address: 'config-jf.xinfra.internal:8080', type: '容器化 · K8s Service', biz: '灵矽', items: 376, status: '灰度接入中', statusClass: 'warn' },
{ name: '达拉斯 IDC', zoneClass: '', cluster: 'dallas', address: '—', type: '容器化 · K8s Service', biz: '—', items: 0, status: '建设中', statusClass: 'idle' },
{ name: '新加坡 IDC', zoneClass: '', cluster: 'singapore', address: '—', type: '容器化 · K8s Service', biz: '—', items: 0, status: '建设中', statusClass: 'idle' },
{ name: '香港 IDC', zoneClass: '', cluster: 'hk', address: '—', type: '容器化 · K8s Service', biz: '—', items: 0, status: '待启动', statusClass: 'idle' },
{ name: '东南亚 IDC', zoneClass: '', cluster: 'sea', address: '—', type: '容器化 · K8s Service', biz: '—', items: 0, status: '待启动', statusClass: 'idle' },
])
</script>
<style scoped>
.page-head {
display: flex;
align-items: flex-end;
justify-content: space-between;
margin-bottom: 18px;
}
.page-head h1 {
font-size: 19px;
margin: 0 0 4px;
font-weight: 700;
}
.page-head p {
margin: 0;
color: var(--text-dim);
font-size: 12.5px;
}
.env-pill {
font-family: var(--mono);
font-size: 11px;
color: var(--accent);
border: 1px solid var(--accent-dim);
background: #0F261D;
padding: 3px 9px;
border-radius: 20px;
letter-spacing: 0.4px;
}
.stat-row {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 12px;
margin-bottom: 20px;
}
.stat-card {
background: var(--bg-panel);
border: 1px solid var(--line);
border-radius: 8px;
padding: 14px 16px;
}
.stat-card .label {
font-size: 11px;
color: var(--text-dim);
margin-bottom: 8px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.stat-card .value {
font-family: var(--mono);
font-size: 24px;
font-weight: 600;
}
.stat-card .delta {
font-size: 11px;
color: var(--text-dim);
margin-top: 4px;
}
.panel {
background: var(--bg-panel);
border: 1px solid var(--line);
border-radius: 8px;
margin-bottom: 18px;
}
.panel-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 13px 16px;
border-bottom: 1px solid var(--line-soft);
}
.panel-head h3 {
margin: 0;
font-size: 13.5px;
font-weight: 600;
}
.panel-head .meta {
font-size: 11.5px;
color: var(--text-dim);
font-family: var(--mono);
}
.panel-body {
padding: 4px 0;
}
.ext-card {
background: var(--bg-panel);
border: 1px solid var(--line);
border-radius: 8px;
padding: 18px;
max-width: 560px;
}
.ext-top {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 10px;
}
.ext-logo {
width: 38px;
height: 38px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-family: var(--mono);
font-weight: 700;
font-size: 13px;
}
h4 {
margin: 0;
font-size: 14px;
}
.ext-sub {
font-size: 11px;
color: var(--text-dim);
font-family: var(--mono);
}
p {
font-size: 12px;
color: var(--text-mid);
line-height: 1.6;
margin: 0 0 10px;
}
.sso-row {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
color: var(--text-dim);
}
.sso-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--accent);
}
table {
width: 100%;
border-collapse: collapse;
font-size: 12.5px;
}
th {
text-align: left;
color: var(--text-dim);
font-weight: 500;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.4px;
padding: 9px 16px;
border-bottom: 1px solid var(--line-soft);
}
td {
padding: 11px 16px;
border-bottom: 1px solid var(--line-soft);
color: var(--text-mid);
}
tr:last-child td {
border-bottom: none;
}
tr.tr-hover:hover td {
background: var(--bg-panel-2);
}
.mono {
font-family: var(--mono);
}
.tag {
display: inline-flex;
align-items: center;
gap: 5px;
font-family: var(--mono);
font-size: 10.5px;
padding: 2px 8px;
border-radius: 4px;
border: 1px solid var(--line);
color: var(--text-mid);
}
.tag.zone-a {
color: #8EC8FF;
border-color: #1C3A5C;
background: #0E1C2C;
}
.tag.zone-b {
color: #C9A6FF;
border-color: #3A2C5C;
background: #1A1430;
}
.tag.zone-c {
color: #FFC97A;
border-color: #5C4A1C;
background: #241B0A;
}
.status-text {
font-size: 12px;
display: flex;
align-items: center;
}
.status-text.ok {
color: var(--accent);
}
.status-text.warn {
color: var(--warn);
}
.status-text.idle {
color: var(--text-dim);
}
</style>
-4
View File
@@ -1,4 +0,0 @@
# 仪表盘页面
存放仪表盘相关的视图组件:
- Index.vue: 首页仪表盘,展示系统概览、快捷入口
+387
View File
@@ -0,0 +1,387 @@
<template>
<div>
<div class="page-head">
<div>
<h1>资源大盘</h1>
<p>跨机房 / 多云容器资源统一视图 · 最近更新于 12 秒前</p>
</div>
<el-button @click="refresh">⟳ 刷新</el-button>
</div>
<div class="stat-row">
<div class="stat-card">
<div class="label">RKE2 集群</div>
<div class="value">3</div>
<div class="delta">3 机房在线</div>
</div>
<div class="stat-card">
<div class="label">节点总数</div>
<div class="value">128</div>
<div class="delta up">↑ 6 本周新增</div>
</div>
<div class="stat-card">
<div class="label">CPU 已分配</div>
<div class="value">61%</div>
<div class="delta">2,048 / 3,360 核</div>
</div>
<div class="stat-card">
<div class="label">组件实例</div>
<div class="value">214</div>
<div class="delta">MySQL 38 · Redis 92 · 其他 84</div>
</div>
<div class="stat-card">
<div class="label">进行中任务</div>
<div class="value" style="color: var(--warn)">2</div>
<div class="delta warn">1 个待关注</div>
</div>
</div>
<div class="cols-2">
<div class="panel">
<div class="panel-head">
<h3>集群拓扑 · 按机房 / 业务线标签</h3>
<span class="meta">node-label 调度视图</span>
</div>
<div class="topo">
<div class="topo-cluster">
<div class="tname">
<span class="tag zone-a">IDC-华北机房</span>
<span class="mono" style="color: var(--text-dim)">46节点</span>
</div>
<div class="node-grid">
<div v-for="i in 24" :key="i" :class="['node-cell', i <= 18 ? 'a' : '']"></div>
</div>
</div>
<div class="topo-cluster">
<div class="tname">
<span class="tag zone-b">IDC-华东机房</span>
<span class="mono" style="color: var(--text-dim)">52节点</span>
</div>
<div class="node-grid">
<div v-for="i in 24" :key="i" :class="['node-cell', i <= 20 ? 'b' : '']"></div>
</div>
</div>
<div class="topo-cluster">
<div class="tname">
<span class="tag zone-c">阿里云-华南</span>
<span class="mono" style="color: var(--text-dim)">30节点</span>
</div>
<div class="node-grid">
<div v-for="i in 18" :key="i" :class="['node-cell', i <= 12 ? 'c' : '']"></div>
</div>
</div>
</div>
<div class="legend">
<span><span class="node-cell a inline"></span>Kodo业务线</span>
<span><span class="node-cell b inline"></span>LAS业务线</span>
<span><span class="node-cell c inline"></span>灵矽业务线</span>
<span><span class="node-cell empty inline"></span>空闲</span>
</div>
</div>
<div class="panel">
<div class="panel-head">
<h3>最近任务</h3>
<router-link to="/tasks" class="meta">查看全部 →</router-link>
</div>
<div class="panel-body">
<table>
<tr class="tr-hover">
<td class="status-text ok">● 成功</td>
<td class="strong">MySQL 主从部署</td>
<td class="mono">2m14s</td>
</tr>
<tr class="tr-hover">
<td class="status-text warn">● 执行中</td>
<td class="strong">RKE2 节点加入</td>
<td class="mono">38s</td>
</tr>
<tr class="tr-hover">
<td class="status-text ok">● 成功</td>
<td class="strong">Redis Cluster 部署</td>
<td class="mono">3m02s</td>
</tr>
<tr class="tr-hover">
<td class="status-text err">● 失败</td>
<td class="strong">openresty 网关部署</td>
<td class="mono">41s</td>
</tr>
<tr class="tr-hover">
<td class="status-text ok">● 成功</td>
<td class="strong">业务线标签同步</td>
<td class="mono">5s</td>
</tr>
</table>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
const refresh = () => {
// 刷新数据
}
</script>
<style scoped>
.page-head {
display: flex;
align-items: flex-end;
justify-content: space-between;
margin-bottom: 18px;
}
.page-head h1 {
font-size: 19px;
margin: 0 0 4px;
font-weight: 700;
letter-spacing: 0.2px;
}
.page-head p {
margin: 0;
color: var(--text-dim);
font-size: 12.5px;
}
.stat-row {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 12px;
margin-bottom: 20px;
}
.stat-card {
background: var(--bg-panel);
border: 1px solid var(--line);
border-radius: 8px;
padding: 14px 16px;
position: relative;
overflow: hidden;
}
.stat-card .label {
font-size: 11px;
color: var(--text-dim);
margin-bottom: 8px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.stat-card .value {
font-family: var(--mono);
font-size: 24px;
font-weight: 600;
}
.stat-card .delta {
font-size: 11px;
color: var(--text-dim);
margin-top: 4px;
}
.stat-card .delta.up {
color: var(--accent);
}
.stat-card .delta.warn {
color: var(--warn);
}
.cols-2 {
display: grid;
grid-template-columns: 1.4fr 1fr;
gap: 18px;
}
.panel {
background: var(--bg-panel);
border: 1px solid var(--line);
border-radius: 8px;
margin-bottom: 18px;
}
.panel-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 13px 16px;
border-bottom: 1px solid var(--line-soft);
}
.panel-head h3 {
margin: 0;
font-size: 13.5px;
font-weight: 600;
}
.panel-head .meta {
font-size: 11.5px;
color: var(--text-dim);
font-family: var(--mono);
text-decoration: none;
}
.panel-body {
padding: 4px 0;
}
.topo {
padding: 18px 16px;
display: flex;
gap: 14px;
}
.topo-cluster {
flex: 1;
border: 1px dashed var(--line);
border-radius: 8px;
padding: 12px;
}
.topo-cluster .tname {
font-size: 12px;
font-weight: 600;
margin-bottom: 8px;
display: flex;
align-items: center;
justify-content: space-between;
}
.node-grid {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 5px;
}
.node-cell {
aspect-ratio: 1;
border-radius: 3px;
background: #1E2532;
border: 1px solid var(--line-soft);
}
.node-cell.a {
background: #13261F;
border-color: var(--accent-dim);
}
.node-cell.b {
background: #1A1430;
border-color: #3A2C5C;
}
.node-cell.c {
background: #241B0A;
border-color: #5C4A1C;
}
.node-cell.empty {
background: transparent;
border-style: dashed;
}
.node-cell.inline {
display: inline-block;
width: 9px;
height: 9px;
margin-right: 5px;
}
.legend {
padding: 0 16px 14px;
display: flex;
gap: 16px;
font-size: 11px;
color: var(--text-dim);
}
table {
width: 100%;
border-collapse: collapse;
font-size: 12.5px;
}
th {
text-align: left;
color: var(--text-dim);
font-weight: 500;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.4px;
padding: 9px 16px;
border-bottom: 1px solid var(--line-soft);
}
td {
padding: 11px 16px;
border-bottom: 1px solid var(--line-soft);
color: var(--text-mid);
}
tr:last-child td {
border-bottom: none;
}
tr.tr-hover:hover td {
background: var(--bg-panel-2);
}
td.strong {
color: var(--text-hi);
font-weight: 500;
}
.mono {
font-family: var(--mono);
}
.tag {
display: inline-flex;
align-items: center;
gap: 5px;
font-family: var(--mono);
font-size: 10.5px;
padding: 2px 8px;
border-radius: 4px;
border: 1px solid var(--line);
color: var(--text-mid);
}
.tag.zone-a {
color: #8EC8FF;
border-color: #1C3A5C;
background: #0E1C2C;
}
.tag.zone-b {
color: #C9A6FF;
border-color: #3A2C5C;
background: #1A1430;
}
.tag.zone-c {
color: #FFC97A;
border-color: #5C4A1C;
background: #241B0A;
}
.status-text {
font-size: 12px;
display: flex;
align-items: center;
}
.status-text.ok {
color: var(--accent);
}
.status-text.warn {
color: var(--warn);
}
.status-text.err {
color: var(--err);
}
</style>
+212
View File
@@ -0,0 +1,212 @@
<template>
<div>
<div class="page-head">
<div>
<h1>监控 / 日志 / 告警</h1>
<p>VictoriaMetrics 指标 · Zabbix 硬件状态 · ELK 日志中心 · 夜莺(Nightingale)统一告警引擎对接 qpass</p>
</div>
</div>
<div class="cols-2">
<div class="panel">
<div class="panel-head">
<h3>VictoriaMetrics · 指标概览</h3>
<span class="meta">K8s / 容器 / 业务指标</span>
</div>
<div class="panel-body">
<table>
<tr class="tr-hover">
<td>七机房采集节点数</td>
<td class="mono strong">128</td>
</tr>
<tr class="tr-hover">
<td>活跃时序数量</td>
<td class="mono strong">42.6M</td>
</tr>
<tr class="tr-hover">
<td>Prometheus 接口接入</td>
<td class="status-text ok">● 正常</td>
</tr>
<tr class="tr-hover">
<td>Grafana 仪表盘数</td>
<td class="mono strong">37</td>
</tr>
</table>
</div>
</div>
<div class="panel">
<div class="panel-head">
<h3>Zabbix · 硬件健康</h3>
<span class="meta">IPMI / 温度 / 电源 / 风扇</span>
</div>
<div class="panel-body">
<table>
<tr class="tr-hover">
<td>物理机监控覆盖</td>
<td class="mono strong">186 / 186</td>
</tr>
<tr class="tr-hover">
<td>当前告警</td>
<td class="status-text warn">● 2 条(风扇转速异常)</td>
</tr>
<tr class="tr-hover">
<td>网络设备健康</td>
<td class="status-text ok">● 正常</td>
</tr>
<tr class="tr-hover">
<td>存储健康度</td>
<td class="status-text ok">● 正常</td>
</tr>
</table>
</div>
</div>
</div>
<div class="panel">
<div class="panel-head">
<h3>日志 + 统一告警</h3>
<span class="meta">Filebeat → Kafka → ES → Kibana · VM + Zabbix → qpass</span>
</div>
<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>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const logs = ref([
{ time: '07:39:10', message: 'CMDB 采集器同步完成:xs291 · 华东-杭州下沙机房', class: '' },
{ time: '07:38:55', message: '[Zabbix] 告警:yzh-las-014 风扇转速低于阈值(FAN_6: 1920 RPM)', class: '' },
{ time: '07:38:02', message: '[VictoriaMetrics] 七机房抓取任务全部正常', class: 'ok' },
{ time: '07:35:41', message: '[ELK] sg-las-007 应用日志接入 Kafka topic:las-sg-app', class: '' },
{ time: '07:30:00', message: '[qpass] 告警已合并推送:1 条硬件告警 + 0 条业务告警', class: '' },
])
</script>
<style scoped>
.page-head {
display: flex;
align-items: flex-end;
justify-content: space-between;
margin-bottom: 18px;
}
.page-head h1 {
font-size: 19px;
margin: 0 0 4px;
font-weight: 700;
}
.page-head p {
margin: 0;
color: var(--text-dim);
font-size: 12.5px;
}
.cols-2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 18px;
margin-bottom: 18px;
}
.panel {
background: var(--bg-panel);
border: 1px solid var(--line);
border-radius: 8px;
margin-bottom: 18px;
}
.panel-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 13px 16px;
border-bottom: 1px solid var(--line-soft);
}
.panel-head h3 {
margin: 0;
font-size: 13.5px;
font-weight: 600;
}
.panel-head .meta {
font-size: 11.5px;
color: var(--text-dim);
font-family: var(--mono);
}
.panel-body {
padding: 4px 0;
}
.log-stream {
padding: 14px 16px;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 12.5px;
}
td {
padding: 11px 16px;
border-bottom: 1px solid var(--line-soft);
color: var(--text-mid);
}
tr:last-child td {
border-bottom: none;
}
tr.tr-hover:hover td {
background: var(--bg-panel-2);
}
.strong {
color: var(--text-hi);
font-weight: 500;
}
.mono {
font-family: var(--mono);
}
.status-text {
font-size: 12px;
display: flex;
align-items: center;
}
.status-text.ok {
color: var(--accent);
}
.status-text.warn {
color: var(--warn);
}
.task-log-line {
font-family: var(--mono);
font-size: 11.5px;
padding: 2px 0;
color: var(--text-mid);
}
.task-log-line .t {
color: var(--text-dim);
margin-right: 10px;
}
.task-log-line.ok {
color: var(--accent);
}
</style>
+360
View File
@@ -0,0 +1,360 @@
<template>
<div>
<div class="page-head">
<div>
<h1>资源管理</h1>
<p>统一纳管物理机与虚机资源 · 基础数据来自 SINA CMDB</p>
</div>
<el-button type="primary">+ 创建虚机</el-button>
</div>
<div class="stat-row">
<div class="stat-card">
<div class="label">资源总数</div>
<div class="value">658</div>
<div class="delta">物理机 146 · 虚机 512</div>
</div>
<div class="stat-card">
<div class="label">SINA CMDB</div>
<div class="value">368</div>
<div class="delta">物理机 146 · 虚机 222</div>
</div>
<div class="stat-card">
<div class="label">阿里云同步</div>
<div class="value">154</div>
<div class="delta">ECS · 增量同步</div>
</div>
<div class="stat-card">
<div class="label">AWS / 七牛 LAS 同步</div>
<div class="value">136</div>
<div class="delta">AWS 58 · 七牛 LAS 78</div>
</div>
<div class="stat-card">
<div class="label">最近一次同步</div>
<div class="value" style="color: var(--accent); font-size: 16px">● 正常</div>
<div class="delta">07:39:10 · 新增 3 条</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="七牛 LAS 同步" value="qiniu" />
</el-select>
<el-select placeholder="全部业务线">
<el-option label="全部业务线" value="" />
<el-option label="kodo" value="kodo" />
<el-option label="las" value="las" />
<el-option label="lingxi" value="lingxi" />
<el-option label="ltoken" value="ltoken" />
<el-option label="maas" value="maas" />
<el-option label="未分配" value="unassigned" />
</el-select>
<el-input placeholder="搜索 hostname / asset_number / IP" style="flex: 1" />
</div>
<div class="panel">
<div class="panel-head">
<h3>资源台账 · 物理机 / 虚机</h3>
<span class="meta">SINA CMDB asset items + 云厂商同步增量</span>
</div>
<div class="panel-body">
<table>
<thead>
<tr>
<th>主机名</th>
<th>资产编号</th>
<th>类型</th>
<th>机房 / 区域</th>
<th>内网 IP</th>
<th>规格</th>
<th>业务线</th>
<th>数据来源</th>
<th>状态</th>
</tr>
</thead>
<tbody>
<tr v-for="item in resources" :key="item.hostname" class="tr-hover">
<td class="strong mono">{{ item.hostname }}</td>
<td class="mono" style="font-size: 11px">{{ item.asset_number }}</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" style="font-size: 11px">{{ item.spec }}</td>
<td class="mono">{{ item.business_line }}</td>
<td><span class="tag src-cmdb">{{ item.source }}</span></td>
<td class="status-text ok">● {{ item.status }}</td>
</tr>
</tbody>
</table>
<div class="pagination">
<span>共 658 条 · 每页 7 条</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>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
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: 'lingxi', 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: 'ltoken', source: '阿里云同步', status: 'production' },
{ hostname: 'aws-us-i-0a13fe2', asset_number: '—', type: '虚机', location: 'AWS · 美国', ip: '10.66.2.12', spec: '8C/32G/200G', business_line: 'maas', source: 'AWS 同步', status: 'production' },
{ hostname: 'sg-las-007', asset_number: 'SERV00004511', type: '虚机', location: '七牛 LAS · 新加坡', ip: '10.88.1.07', spec: '16C/64G/2.0T', business_line: 'las', source: '七牛 LAS 同步', status: 'idle' },
{ hostname: 'hk-bm-001', asset_number: 'SERV00004602', type: '物理机', location: '香港 IDC', ip: '10.90.0.11', spec: '8C/32G/1.0T', business_line: '未分配', source: 'SINA CMDB', status: 'production' },
])
</script>
<style scoped>
.page-head {
display: flex;
align-items: flex-end;
justify-content: space-between;
margin-bottom: 18px;
}
.page-head h1 {
font-size: 19px;
margin: 0 0 4px;
font-weight: 700;
}
.page-head p {
margin: 0;
color: var(--text-dim);
font-size: 12.5px;
}
.stat-row {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 12px;
margin-bottom: 20px;
}
.stat-card {
background: var(--bg-panel);
border: 1px solid var(--line);
border-radius: 8px;
padding: 14px 16px;
}
.stat-card .label {
font-size: 11px;
color: var(--text-dim);
margin-bottom: 8px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.stat-card .value {
font-family: var(--mono);
font-size: 24px;
font-weight: 600;
}
.stat-card .delta {
font-size: 11px;
color: var(--text-dim);
margin-top: 4px;
}
.stat-card .delta.up {
color: var(--accent);
}
.stat-card .delta.warn {
color: var(--warn);
}
.las-toolbar {
display: flex;
gap: 10px;
margin-bottom: 14px;
align-items: center;
}
.panel {
background: var(--bg-panel);
border: 1px solid var(--line);
border-radius: 8px;
}
.panel-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 13px 16px;
border-bottom: 1px solid var(--line-soft);
}
.panel-head h3 {
margin: 0;
font-size: 13.5px;
font-weight: 600;
}
.panel-head .meta {
font-size: 11.5px;
color: var(--text-dim);
font-family: var(--mono);
}
.panel-body {
padding: 4px 0;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 12.5px;
}
th {
text-align: left;
color: var(--text-dim);
font-weight: 500;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.4px;
padding: 9px 16px;
border-bottom: 1px solid var(--line-soft);
}
td {
padding: 11px 16px;
border-bottom: 1px solid var(--line-soft);
color: var(--text-mid);
}
tr:last-child td {
border-bottom: none;
}
tr.tr-hover:hover td {
background: var(--bg-panel-2);
}
.strong {
color: var(--text-hi);
font-weight: 500;
}
.mono {
font-family: var(--mono);
}
.tag {
display: inline-flex;
align-items: center;
gap: 5px;
font-family: var(--mono);
font-size: 10.5px;
padding: 2px 8px;
border-radius: 4px;
border: 1px solid var(--line);
color: var(--text-mid);
}
.tag.vm {
color: #8EC8FF;
border-color: #1C3A5C;
background: #0E1C2C;
}
.tag.zone-a {
color: #8EC8FF;
border-color: #1C3A5C;
background: #0E1C2C;
}
.tag.src-cmdb {
color: #7FFFC2;
border-color: var(--accent-dim);
background: #0F261D;
}
.status-text {
font-size: 12px;
display: flex;
align-items: center;
}
.status-text.ok {
color: var(--accent);
}
.pagination {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 16px;
border-top: 1px solid var(--line-soft);
font-size: 11.5px;
color: var(--text-dim);
}
.pg-btns {
display: flex;
gap: 4px;
align-items: center;
}
.pg-btn {
min-width: 26px;
height: 26px;
padding: 0 6px;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid var(--line);
border-radius: 5px;
color: var(--text-mid);
font-family: var(--mono);
font-size: 11px;
background: var(--bg-panel-2);
cursor: pointer;
}
.pg-btn.active {
background: var(--accent-dim);
border-color: var(--accent-dim);
color: #CFFCE9;
}
.pg-btn:hover {
border-color: #3A4356;
color: var(--text-hi);
}
.pg-btn.disabled {
opacity: 0.35;
cursor: not-allowed;
}
.pg-sep {
color: var(--text-dim);
padding: 0 2px;
}
</style>
+306
View File
@@ -0,0 +1,306 @@
<template>
<div>
<div class="page-head">
<div>
<h1>资源状态看板</h1>
<p>物理机 / 虚机 / 基础服务状态汇总</p>
</div>
<el-button @click="refresh">⟳ 刷新</el-button>
</div>
<div class="stat-row">
<div class="stat-card">
<div class="label">物理机总数</div>
<div class="value">186</div>
<div class="delta">186 / 186 监控覆盖</div>
</div>
<div class="stat-card">
<div class="label">虚机总数(LAS)</div>
<div class="value">512</div>
<div class="delta up">↑ 14 本周新增</div>
</div>
<div class="stat-card">
<div class="label">基础组件实例</div>
<div class="value">214</div>
<div class="delta">MySQL 38 · Redis 92 · 其他 84</div>
</div>
<div class="stat-card">
<div class="label">P0 / Disaster 告警</div>
<div class="value" style="color: var(--err)">1</div>
<div class="delta" style="color: var(--err)">来自 VictoriaMetrics</div>
</div>
<div class="stat-card">
<div class="label">P1 / High 告警</div>
<div class="value" style="color: var(--warn)">5</div>
<div class="delta warn">来自 Zabbix · 4 条 / VM · 1 条</div>
</div>
</div>
<div class="cols-2">
<div class="panel">
<div class="panel-head">
<h3>物理机状态 · 按机房</h3>
<span class="meta">数据源:Zabbix</span>
</div>
<div class="panel-body">
<table>
<tr><th>机房</th><th>总数</th><th>在线</th><th>健康率</th><th>状态</th></tr>
<tr class="tr-hover">
<td class="strong mono">YZH 机房</td>
<td class="mono">78</td>
<td class="mono">78</td>
<td><span class="bar-wrap"><span class="bar-fill" style="width: 98%"></span></span>98%</td>
<td class="status-text ok">● 正常</td>
</tr>
<tr class="tr-hover">
<td class="strong mono">XS 机房</td>
<td class="mono">64</td>
<td class="mono">63</td>
<td><span class="bar-wrap"><span class="bar-fill warn" style="width: 91%"></span></span>91%</td>
<td class="status-text warn">● 1 条告警</td>
</tr>
<tr class="tr-hover">
<td class="strong mono">JF 机房</td>
<td class="mono">22</td>
<td class="mono">22</td>
<td><span class="bar-wrap"><span class="bar-fill" style="width: 100%"></span></span>100%</td>
<td class="status-text ok">● 正常</td>
</tr>
<tr class="tr-hover">
<td class="strong mono">海外 IDC(4)</td>
<td class="mono">22</td>
<td class="mono">21</td>
<td><span class="bar-wrap"><span class="bar-fill err" style="width: 86%"></span></span>86%</td>
<td class="status-text err">● 1 条严重</td>
</tr>
</table>
</div>
</div>
<div class="panel">
<div class="panel-head">
<h3>虚机状态 · LAS 资源池</h3>
<span class="meta">数据源:VictoriaMetrics</span>
</div>
<div class="panel-body">
<table>
<tr><th>业务线</th><th>虚机数</th><th>CPU 均值</th><th>状态</th></tr>
<tr class="tr-hover">
<td class="strong mono">Kodo</td>
<td class="mono">218</td>
<td><span class="bar-wrap"><span class="bar-fill" style="width: 64%"></span></span>64%</td>
<td class="status-text ok">● 正常</td>
</tr>
<tr class="tr-hover">
<td class="strong mono">LAS</td>
<td class="mono">164</td>
<td><span class="bar-wrap"><span class="bar-fill warn" style="width: 82%"></span></span>82%</td>
<td class="status-text warn">● 1 条告警</td>
</tr>
<tr class="tr-hover">
<td class="strong mono">灵矽</td>
<td class="mono">96</td>
<td><span class="bar-wrap"><span class="bar-fill" style="width: 57%"></span></span>57%</td>
<td class="status-text ok">● 正常</td>
</tr>
<tr class="tr-hover">
<td class="strong mono">共享池 / 未分配</td>
<td class="mono">34</td>
<td><span class="bar-wrap"><span class="bar-fill" style="width: 31%"></span></span>31%</td>
<td class="status-text ok">● 正常</td>
</tr>
</table>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
const refresh = () => {
// 刷新数据
}
</script>
<style scoped>
.page-head {
display: flex;
align-items: flex-end;
justify-content: space-between;
margin-bottom: 18px;
}
.page-head h1 {
font-size: 19px;
margin: 0 0 4px;
font-weight: 700;
letter-spacing: 0.2px;
}
.page-head p {
margin: 0;
color: var(--text-dim);
font-size: 12.5px;
}
.stat-row {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 12px;
margin-bottom: 20px;
}
.stat-card {
background: var(--bg-panel);
border: 1px solid var(--line);
border-radius: 8px;
padding: 14px 16px;
}
.stat-card .label {
font-size: 11px;
color: var(--text-dim);
margin-bottom: 8px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.stat-card .value {
font-family: var(--mono);
font-size: 24px;
font-weight: 600;
}
.stat-card .delta {
font-size: 11px;
color: var(--text-dim);
margin-top: 4px;
}
.stat-card .delta.up {
color: var(--accent);
}
.stat-card .delta.warn {
color: var(--warn);
}
.cols-2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 18px;
}
.panel {
background: var(--bg-panel);
border: 1px solid var(--line);
border-radius: 8px;
}
.panel-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 13px 16px;
border-bottom: 1px solid var(--line-soft);
}
.panel-head h3 {
margin: 0;
font-size: 13.5px;
font-weight: 600;
}
.panel-head .meta {
font-size: 11.5px;
color: var(--text-dim);
font-family: var(--mono);
}
.panel-body {
padding: 4px 0;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 12.5px;
}
th {
text-align: left;
color: var(--text-dim);
font-weight: 500;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.4px;
padding: 9px 16px;
border-bottom: 1px solid var(--line-soft);
}
td {
padding: 11px 16px;
border-bottom: 1px solid var(--line-soft);
color: var(--text-mid);
}
tr:last-child td {
border-bottom: none;
}
tr.tr-hover:hover td {
background: var(--bg-panel-2);
}
.strong {
color: var(--text-hi);
font-weight: 500;
}
.mono {
font-family: var(--mono);
}
.bar-wrap {
width: 90px;
height: 5px;
background: #262C38;
border-radius: 3px;
overflow: hidden;
display: inline-block;
vertical-align: middle;
margin-right: 8px;
}
.bar-fill {
height: 100%;
border-radius: 3px;
background: var(--accent);
}
.bar-fill.warn {
background: var(--warn);
}
.bar-fill.err {
background: var(--err);
}
.status-text {
font-size: 12px;
display: flex;
align-items: center;
}
.status-text.ok {
color: var(--accent);
}
.status-text.warn {
color: var(--warn);
}
.status-text.err {
color: var(--err);
}
</style>
+128
View File
@@ -0,0 +1,128 @@
<template>
<div>
<div class="page-head">
<div>
<h1>基础服务目录</h1>
<p>点击卡片,通过封装好的 ansible-playbook 一键部署标准化基础服务</p>
</div>
</div>
<div class="svc-grid">
<div v-for="service in services" :key="service.name" class="svc-card" @click="handleDeploy(service)">
<div class="svc-top">
<div class="svc-ic" :style="{ background: service.bgColor, color: service.iconColor }">
{{ service.icon }}
</div>
<h4>{{ service.name }}</h4>
</div>
<div class="svc-desc">{{ service.description }}</div>
<div class="svc-foot">
<span class="playbook">{{ service.playbook }}</span>
<span>{{ service.version }}</span>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { ElMessage } from 'element-plus'
const services = ref([
{ name: 'MySQL', icon: 'My', bgColor: '#1C2E4A', iconColor: '#7FB8FF', description: '一主多从,自动注册进 open-cdm 数据源,支持 LDAP 账号统一审核 SQL 上线。', playbook: 'roles/mysql-deploy', version: 'v8.0.36' },
{ name: 'Redis', icon: 'Rd', bgColor: '#3A1C28', iconColor: '#FF8E9C', description: '支持 Standalone / Sentinel / Cluster,部署完成后自动注册进 CacheCloud。', playbook: 'roles/redis-deploy', version: 'v7.2' },
{ name: 'openresty', icon: 'Or', bgColor: '#1C3A2E', iconColor: '#7FFFC2', description: '容器化部署在 K8s 内,作为业务边缘网关,支持灰度路由配置。', playbook: 'roles/openresty-deploy', version: 'v1.25' },
{ name: 'dpvs', icon: 'Dp', bgColor: '#3A2E1C', iconColor: '#FFC97A', description: '四层负载均衡,直接管理宿主机网络命名空间,不纳入容器编排。', playbook: 'roles/dpvs-deploy', version: 'v1.9' },
{ name: 'PgSQL', icon: 'Pg', bgColor: '#2E1C3A', iconColor: '#C9A6FF', description: '流复制主从架构,规划中 · 第二期接入 open-cdm 统一审核体系。', playbook: 'roles/pgsql-deploy', version: '规划中' },
])
const handleDeploy = (service: any) => {
ElMessage.success(`${service.name} 部署任务已提交`)
}
</script>
<style scoped>
.page-head {
display: flex;
align-items: flex-end;
justify-content: space-between;
margin-bottom: 18px;
}
.page-head h1 {
font-size: 19px;
margin: 0 0 4px;
font-weight: 700;
}
.page-head p {
margin: 0;
color: var(--text-dim);
font-size: 12.5px;
}
.svc-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 14px;
}
.svc-card {
background: var(--bg-panel);
border: 1px solid var(--line);
border-radius: 8px;
padding: 16px;
cursor: pointer;
transition: border-color 0.15s;
}
.svc-card:hover {
border-color: #3A4356;
}
.svc-top {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 10px;
}
.svc-ic {
width: 34px;
height: 34px;
border-radius: 7px;
display: flex;
align-items: center;
justify-content: center;
font-family: var(--mono);
font-weight: 600;
font-size: 13px;
}
h4 {
margin: 0;
font-size: 13.5px;
}
.svc-desc {
font-size: 12px;
color: var(--text-dim);
line-height: 1.5;
margin-bottom: 12px;
min-height: 36px;
}
.svc-foot {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 11px;
color: var(--text-dim);
}
.playbook {
font-family: var(--mono);
color: var(--text-mid);
}
</style>
+359
View File
@@ -0,0 +1,359 @@
<template>
<div>
<div class="page-head">
<div>
<h1>服务管理</h1>
<p>同步全部机房 Consul 注册中心服务目录,统一查看服务名 / 服务 IP / 业务标签 / 实例数</p>
</div>
<el-button>⟳ 立即同步</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>
<div class="stat-card">
<div class="label">服务总数</div>
<div class="value">186</div>
<div class="delta">去重后唯一服务名</div>
</div>
<div class="stat-card">
<div class="label">服务实例总数</div>
<div class="value">842</div>
<div class="delta">所有机房注册 IP 汇总</div>
</div>
<div class="stat-card">
<div class="label">健康实例占比</div>
<div class="value" style="color: var(--accent)">98.6%</div>
<div class="delta warn">12 个实例 critical</div>
</div>
<div class="stat-card">
<div class="label">最近同步</div>
<div class="value" style="color: var(--accent); font-size: 16px">● 正常</div>
<div class="delta">07:41:05 · 间隔 30s</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="las" value="las" />
<el-option label="lingxi" value="lingxi" />
<el-option label="ltoken" value="ltoken" />
<el-option label="maas" value="maas" />
</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" style="flex: 1" />
</div>
<div class="panel">
<div class="panel-head">
<h3>服务台账 · 按 Consul Datacenter 同步</h3>
<span class="meta">数据源:Consul Catalog API</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>
</tr>
</thead>
<tbody>
<tr v-for="service in services" :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 :class="['status-text', service.statusClass]">● {{ service.status }}</td>
</tr>
</tbody>
</table>
<div class="pagination">
<span>共 186 条 · 每页 7 条</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>
</template>
<script setup lang="ts">
import { ref } from 'vue'
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: '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' },
{ name: 'lingxi-render-svc', dc: 'JF', dcClass: 'zone-c', biz: 'lingxi', instances: 6, healthy: '6 / 6', ip: '10.45.2.30', status: '健康', statusClass: 'ok' },
{ name: 'ltoken-wallet-svc', dc: 'YZH', dcClass: 'zone-a', biz: 'ltoken', instances: 8, healthy: '8 / 8', ip: '10.21.4.88', status: '健康', statusClass: 'ok' },
{ name: 'maas-infer-svc', dc: '新加坡 IDC', dcClass: '', biz: 'maas', instances: 4, healthy: '3 / 4', ip: '10.88.1.40', status: '部分异常', statusClass: 'warn' },
])
</script>
<style scoped>
.page-head {
display: flex;
align-items: flex-end;
justify-content: space-between;
margin-bottom: 18px;
}
.page-head h1 {
font-size: 19px;
margin: 0 0 4px;
font-weight: 700;
}
.page-head p {
margin: 0;
color: var(--text-dim);
font-size: 12.5px;
}
.stat-row {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 12px;
margin-bottom: 20px;
}
.stat-card {
background: var(--bg-panel);
border: 1px solid var(--line);
border-radius: 8px;
padding: 14px 16px;
}
.stat-card .label {
font-size: 11px;
color: var(--text-dim);
margin-bottom: 8px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.stat-card .value {
font-family: var(--mono);
font-size: 24px;
font-weight: 600;
}
.stat-card .delta {
font-size: 11px;
color: var(--text-dim);
margin-top: 4px;
}
.stat-card .delta.warn {
color: var(--warn);
}
.las-toolbar {
display: flex;
gap: 10px;
margin-bottom: 14px;
align-items: center;
}
.panel {
background: var(--bg-panel);
border: 1px solid var(--line);
border-radius: 8px;
}
.panel-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 13px 16px;
border-bottom: 1px solid var(--line-soft);
}
.panel-head h3 {
margin: 0;
font-size: 13.5px;
font-weight: 600;
}
.panel-head .meta {
font-size: 11.5px;
color: var(--text-dim);
font-family: var(--mono);
}
.panel-body {
padding: 4px 0;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 12.5px;
}
th {
text-align: left;
color: var(--text-dim);
font-weight: 500;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.4px;
padding: 9px 16px;
border-bottom: 1px solid var(--line-soft);
}
td {
padding: 11px 16px;
border-bottom: 1px solid var(--line-soft);
color: var(--text-mid);
}
tr:last-child td {
border-bottom: none;
}
tr.tr-hover:hover td {
background: var(--bg-panel-2);
}
.strong {
color: var(--text-hi);
font-weight: 500;
}
.mono {
font-family: var(--mono);
}
.tag {
display: inline-flex;
align-items: center;
gap: 5px;
font-family: var(--mono);
font-size: 10.5px;
padding: 2px 8px;
border-radius: 4px;
border: 1px solid var(--line);
color: var(--text-mid);
}
.tag.zone-a {
color: #8EC8FF;
border-color: #1C3A5C;
background: #0E1C2C;
}
.tag.zone-b {
color: #C9A6FF;
border-color: #3A2C5C;
background: #1A1430;
}
.tag.zone-c {
color: #FFC97A;
border-color: #5C4A1C;
background: #241B0A;
}
.status-text {
font-size: 12px;
display: flex;
align-items: center;
}
.status-text.ok {
color: var(--accent);
}
.status-text.warn {
color: var(--warn);
}
.pagination {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 16px;
border-top: 1px solid var(--line-soft);
font-size: 11.5px;
color: var(--text-dim);
}
.pg-btns {
display: flex;
gap: 4px;
align-items: center;
}
.pg-btn {
min-width: 26px;
height: 26px;
padding: 0 6px;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid var(--line);
border-radius: 5px;
color: var(--text-mid);
font-family: var(--mono);
font-size: 11px;
background: var(--bg-panel-2);
cursor: pointer;
}
.pg-btn.active {
background: var(--accent-dim);
border-color: var(--accent-dim);
color: #CFFCE9;
}
.pg-btn:hover {
border-color: #3A4356;
color: var(--text-hi);
}
.pg-btn.disabled {
opacity: 0.35;
cursor: not-allowed;
}
.pg-sep {
color: var(--text-dim);
padding: 0 2px;
}
</style>
-4
View File
@@ -1,4 +0,0 @@
# 子系统导航页面
存放子系统导航相关的视图组件:
- Navigation.vue: 子系统导航页,6 个子系统卡片网格展示
@@ -0,0 +1,58 @@
<template>
<div>
<div class="page-head">
<div>
<h1>子系统导航</h1>
<p>统一 LDAP 账号,点击即用免二次登录(同账号同密码)</p>
</div>
</div>
<div class="ext-grid">
<SubsystemCard
v-for="system in subsystems"
:key="system.id"
:system="system"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { subsystemApi, type Subsystem } from '@/api/subsystem'
import SubsystemCard from '@/components/SubsystemCard.vue'
const subsystems = ref<Subsystem[]>([])
onMounted(async () => {
const { data } = await subsystemApi.getSubsystems()
subsystems.value = data
})
</script>
<style scoped>
.page-head {
display: flex;
align-items: flex-end;
justify-content: space-between;
margin-bottom: 18px;
}
.page-head h1 {
font-size: 19px;
margin: 0 0 4px;
font-weight: 700;
letter-spacing: 0.2px;
}
.page-head p {
margin: 0;
color: var(--text-dim);
font-size: 12.5px;
}
.ext-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 14px;
}
</style>
+277
View File
@@ -0,0 +1,277 @@
<template>
<div>
<div class="page-head">
<div>
<h1>任务中心</h1>
<p>所有 ansible-playbook / Wayne 发布任务的执行记录与实时日志</p>
</div>
</div>
<div class="cols-2">
<div class="panel">
<div class="panel-head">
<h3>任务列表</h3>
</div>
<div class="panel-body">
<table>
<tr v-for="task in tasks" :key="task.id" class="tr-hover" :class="{ active: task.id === selectedTask }" @click="selectedTask = task.id">
<td :class="['status-text', task.statusClass]">● {{ task.status }}</td>
<td class="strong">{{ task.name }}</td>
<td class="mono" style="font-size: 11px; color: var(--text-dim)">{{ task.playbook }}</td>
</tr>
</table>
<div class="pagination">
<span>共 86 条 · 每页 6 条</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">15</span>
<span class="pg-btn">›</span>
</div>
</div>
</div>
</div>
<div class="panel">
<div class="panel-head">
<h3>实时日志 · RKE2 节点加入</h3>
<span class="meta">WebSocket 流式输出</span>
</div>
<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 class="task-log-line">
<span class="t">10:42:33</span>
<span class="blink">▌</span> 等待节点 Ready...
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const selectedTask = ref(1)
const tasks = ref([
{ id: 1, name: 'RKE2 节点加入 · bj-node-061', playbook: 'roles/rke2-node-join', status: '执行中', statusClass: 'warn' },
{ id: 2, name: 'MySQL 主从部署 · kodo', playbook: 'roles/mysql-deploy', status: '成功', statusClass: 'ok' },
{ id: 3, name: 'Redis Cluster 部署 · las', playbook: 'roles/redis-deploy', status: '成功', statusClass: 'ok' },
{ id: 4, name: 'openresty 网关部署', playbook: 'roles/openresty-deploy', status: '失败', statusClass: 'err' },
{ id: 5, name: '业务线标签同步 · LDAP', playbook: 'internal/label-sync', status: '成功', statusClass: 'ok' },
{ id: 6, name: 'MySQL 主从部署 · ltoken', playbook: 'roles/mysql-deploy', status: '成功', statusClass: 'ok' },
])
const logs = ref([
{ time: '10:42:01', message: 'PLAY [rke2-node-join] **********************', class: '' },
{ time: '10:42:02', message: 'TASK [初始化内核参数] ...', class: '' },
{ time: '10:42:04', message: 'ok: [bj-node-061]', class: 'ok' },
{ time: '10:42:05', message: 'TASK [安装 containerd] ...', class: '' },
{ time: '10:42:18', message: 'ok: [bj-node-061]', class: 'ok' },
{ time: '10:42:19', message: 'TASK [写入 RKE2 node-labels: business-line=kodo] ...', class: '' },
{ time: '10:42:20', message: 'changed: [bj-node-061] => labels applied', class: 'tag-ok' },
{ time: '10:42:21', message: 'TASK [加入集群 rke2-bj-prod-01] ...', class: '' },
])
</script>
<style scoped>
.page-head {
display: flex;
align-items: flex-end;
justify-content: space-between;
margin-bottom: 18px;
}
.page-head h1 {
font-size: 19px;
margin: 0 0 4px;
font-weight: 700;
}
.page-head p {
margin: 0;
color: var(--text-dim);
font-size: 12.5px;
}
.cols-2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 18px;
}
.panel {
background: var(--bg-panel);
border: 1px solid var(--line);
border-radius: 8px;
}
.panel-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 13px 16px;
border-bottom: 1px solid var(--line-soft);
}
.panel-head h3 {
margin: 0;
font-size: 13.5px;
font-weight: 600;
}
.panel-head .meta {
font-size: 11.5px;
color: var(--text-dim);
font-family: var(--mono);
}
.panel-body {
padding: 4px 0;
}
.log-stream {
padding: 14px 16px;
max-height: 340px;
overflow-y: auto;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 12.5px;
}
td {
padding: 11px 16px;
border-bottom: 1px solid var(--line-soft);
color: var(--text-mid);
}
tr:last-child td {
border-bottom: none;
}
tr.tr-hover:hover td {
background: var(--bg-panel-2);
}
tr.tr-hover.active td {
background: var(--bg-panel-2);
}
.strong {
color: var(--text-hi);
font-weight: 500;
}
.mono {
font-family: var(--mono);
}
.status-text {
font-size: 12px;
display: flex;
align-items: center;
}
.status-text.ok {
color: var(--accent);
}
.status-text.warn {
color: var(--warn);
}
.status-text.err {
color: var(--err);
}
.task-log-line {
font-family: var(--mono);
font-size: 11.5px;
padding: 2px 0;
color: var(--text-mid);
}
.task-log-line .t {
color: var(--text-dim);
margin-right: 10px;
}
.task-log-line.ok {
color: var(--accent);
}
.task-log-line.tag-ok {
color: #7FB8FF;
}
.blink {
animation: blink 1s step-start infinite;
color: var(--accent);
}
@keyframes blink {
50% { opacity: 0; }
}
.pagination {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 16px;
border-top: 1px solid var(--line-soft);
font-size: 11.5px;
color: var(--text-dim);
}
.pg-btns {
display: flex;
gap: 4px;
align-items: center;
}
.pg-btn {
min-width: 26px;
height: 26px;
padding: 0 6px;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid var(--line);
border-radius: 5px;
color: var(--text-mid);
font-family: var(--mono);
font-size: 11px;
background: var(--bg-panel-2);
cursor: pointer;
}
.pg-btn.active {
background: var(--accent-dim);
border-color: var(--accent-dim);
color: #CFFCE9;
}
.pg-btn:hover {
border-color: #3A4356;
color: var(--text-hi);
}
.pg-btn.disabled {
opacity: 0.35;
cursor: not-allowed;
}
.pg-sep {
color: var(--text-dim);
padding: 0 2px;
}
</style>
+25
View File
@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
"references": [{ "path": "./tsconfig.node.json" }]
}
+10
View File
@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}
+36
View File
@@ -0,0 +1,36 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
export default defineConfig({
plugins: [
vue(),
AutoImport({
resolvers: [ElementPlusResolver()],
}),
Components({
resolvers: [ElementPlusResolver()],
}),
],
resolve: {
alias: {
'@': resolve(__dirname, 'src'),
},
},
server: {
port: 3000,
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
},
},
},
build: {
outDir: 'dist',
sourcemap: false,
},
})