告警模块

This commit is contained in:
guanyuankai 2025-11-04 14:52:53 +08:00
parent 8b554876c2
commit a92faf98ec
14 changed files with 1223 additions and 146 deletions

6
.gitignore vendored
View File

@ -22,3 +22,9 @@ dist-ssr
*.njsproj
*.sln
*.sw?
dist/
build/
config/
tmp/
test-data/

View File

@ -19,6 +19,7 @@ function createWindow() {
}
});
win.maximize();
win.webContents.openDevTools();
win.webContents.on("did-finish-load", () => {
win == null ? void 0 : win.webContents.send("main-process-message", (/* @__PURE__ */ new Date()).toLocaleString());
});

View File

@ -37,6 +37,7 @@ function createWindow() {
},
});
win.maximize();
win.webContents.openDevTools();
// Test active push message to Renderer-process.
win.webContents.on("did-finish-load", () => {

187
package-lock.json generated
View File

@ -8,10 +8,12 @@
"name": "edge-proxy-manager",
"version": "0.0.0",
"dependencies": {
"@codemirror/lang-json": "^6.0.2",
"@element-plus/icons-vue": "^2.3.2",
"axios": "^1.13.0",
"element-plus": "^2.11.5",
"vue": "^3.4.21",
"vue-codemirror": "^6.1.1",
"vue-router": "^4.6.3"
},
"devDependencies": {
@ -71,6 +73,100 @@
"node": ">=6.9.0"
}
},
"node_modules/@codemirror/autocomplete": {
"version": "6.19.1",
"resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.19.1.tgz",
"integrity": "sha512-q6NenYkEy2fn9+JyjIxMWcNjzTL/IhwqfzOut1/G3PrIFkrbl4AL7Wkse5tLrQUUyqGoAKU5+Pi5jnnXxH5HGw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0"
}
},
"node_modules/@codemirror/commands": {
"version": "6.10.0",
"resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.0.tgz",
"integrity": "sha512-2xUIc5mHXQzT16JnyOFkh8PvfeXuIut3pslWGfsGOhxP/lpgRm9HOl/mpzLErgt5mXDovqA0d11P21gofRLb9w==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.4.0",
"@codemirror/view": "^6.27.0",
"@lezer/common": "^1.1.0"
}
},
"node_modules/@codemirror/lang-json": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/@codemirror/lang-json/-/lang-json-6.0.2.tgz",
"integrity": "sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@lezer/json": "^1.0.0"
}
},
"node_modules/@codemirror/language": {
"version": "6.11.3",
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.11.3.tgz",
"integrity": "sha512-9HBM2XnwDj7fnu0551HkGdrUrrqmYq/WC5iv6nbY2WdicXdGbhR/gfbZOH73Aqj4351alY1+aoG9rCNfiwS1RA==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.23.0",
"@lezer/common": "^1.1.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0",
"style-mod": "^4.0.0"
}
},
"node_modules/@codemirror/lint": {
"version": "6.9.2",
"resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.2.tgz",
"integrity": "sha512-sv3DylBiIyi+xKwRCJAAsBZZZWo82shJ/RTMymLabAdtbkV5cSKwWDeCgtUq3v8flTaXS2y1kKkICuRYtUswyQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.35.0",
"crelt": "^1.0.5"
}
},
"node_modules/@codemirror/search": {
"version": "6.5.11",
"resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.5.11.tgz",
"integrity": "sha512-KmWepDE6jUdL6n8cAAqIpRmLPBZ5ZKnicE8oGU/s3QrAVID+0VhLFrzUucVKHG5035/BSykhExDL/Xm7dHthiA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0",
"crelt": "^1.0.5"
}
},
"node_modules/@codemirror/state": {
"version": "6.5.2",
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.2.tgz",
"integrity": "sha512-FVqsPqtPWKVVL3dPSxy8wEF/ymIEuVzF1PK3VbUgrxXpJUSHQWWZz4JMToquRxnkw+36LTamCZG2iua2Ptq0fA==",
"license": "MIT",
"dependencies": {
"@marijn/find-cluster-break": "^1.0.0"
}
},
"node_modules/@codemirror/view": {
"version": "6.38.6",
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.38.6.tgz",
"integrity": "sha512-qiS0z1bKs5WOvHIAC0Cybmv4AJSkAXgX5aD6Mqd2epSLlVJsQl8NG23jCVouIgkh4All/mrbdsf2UOLFnJw0tw==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.5.0",
"crelt": "^1.0.6",
"style-mod": "^4.1.0",
"w3c-keyname": "^2.2.4"
}
},
"node_modules/@ctrl/tinycolor": {
"version": "3.6.1",
"resolved": "https://registry.npmjs.org/@ctrl/tinycolor/-/tinycolor-3.6.1.tgz",
@ -905,6 +1001,41 @@
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"license": "MIT"
},
"node_modules/@lezer/common": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.3.0.tgz",
"integrity": "sha512-L9X8uHCYU310o99L3/MpJKYxPzXPOS7S0NmBaM7UO/x2Kb2WbmMLSkfvdr1KxRIFYOpbY0Jhn7CfLSUDzL8arQ==",
"license": "MIT"
},
"node_modules/@lezer/highlight": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz",
"integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.3.0"
}
},
"node_modules/@lezer/json": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@lezer/json/-/json-1.0.3.tgz",
"integrity": "sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@lezer/lr": {
"version": "1.4.3",
"resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.3.tgz",
"integrity": "sha512-yenN5SqAxAPv/qMnpWW0AT7l+SxVrgG+u0tNsRQWqbrz66HIl8DnEbBObvy21J5K7+I1v7gsAnlE2VQ5yYVSeA==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.0.0"
}
},
"node_modules/@malept/cross-spawn-promise": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@malept/cross-spawn-promise/-/cross-spawn-promise-1.1.1.tgz",
@ -983,6 +1114,12 @@
"node": ">= 10.0.0"
}
},
"node_modules/@marijn/find-cluster-break": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz",
"integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==",
"license": "MIT"
},
"node_modules/@pkgjs/parseargs": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
@ -2470,6 +2607,22 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/codemirror": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz",
"integrity": "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/commands": "^6.0.0",
"@codemirror/language": "^6.0.0",
"@codemirror/lint": "^6.0.0",
"@codemirror/search": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@ -2651,6 +2804,12 @@
"node": ">= 10"
}
},
"node_modules/crelt": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
"integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==",
"license": "MIT"
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@ -5138,6 +5297,12 @@
"node": ">=8"
}
},
"node_modules/style-mod": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz",
"integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==",
"license": "MIT"
},
"node_modules/sumchecker": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz",
@ -5475,6 +5640,22 @@
}
}
},
"node_modules/vue-codemirror": {
"version": "6.1.1",
"resolved": "https://registry.npmjs.org/vue-codemirror/-/vue-codemirror-6.1.1.tgz",
"integrity": "sha512-rTAYo44owd282yVxKtJtnOi7ERAcXTeviwoPXjIc6K/IQYUsoDkzPvw/JDFtSP6T7Cz/2g3EHaEyeyaQCKoDMg==",
"license": "MIT",
"dependencies": {
"@codemirror/commands": "6.x",
"@codemirror/language": "6.x",
"@codemirror/state": "6.x",
"@codemirror/view": "6.x"
},
"peerDependencies": {
"codemirror": "6.x",
"vue": "3.x"
}
},
"node_modules/vue-router": {
"version": "4.6.3",
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.3.tgz",
@ -5507,6 +5688,12 @@
"typescript": ">=5.0.0"
}
},
"node_modules/w3c-keyname": {
"version": "2.2.8",
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
"license": "MIT"
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",

View File

@ -9,10 +9,12 @@
"preview": "vite preview"
},
"dependencies": {
"@codemirror/lang-json": "^6.0.2",
"@element-plus/icons-vue": "^2.3.2",
"axios": "^1.13.0",
"element-plus": "^2.11.5",
"vue": "^3.4.21",
"vue-codemirror": "^6.1.1",
"vue-router": "^4.6.3"
},
"devDependencies": {

View File

@ -1,8 +1,11 @@
<script setup lang="ts">
import Sidebar from "./components/Sidebar.vue";
import { RouterView } from "vue-router";
import { onMounted } from "vue";
onMounted(() => {
document.documentElement.classList.add("dark");
});
</script>
<template>
@ -10,28 +13,49 @@ import { RouterView } from "vue-router";
<Sidebar />
<div class="main-content">
<RouterView />
<div class="content-wrapper">
<RouterView />
</div>
</div>
</div>
</template>
<style>
html.dark {
background-color: var(--el-bg-color);
color-scheme: dark;
}
html.dark .sidebar {
background-color: var(--el-bg-color-page) !important;
}
html.dark .el-table {
--el-table-bg-color: var(--el-bg-color-overlay);
}
</style>
<style scoped>
.app-layout {
display: flex;
height: 100%;
}
.main-content {
margin-left: 210px;
/* 让内容区自动填满剩余宽度 */
flex-grow: 1;
/* (可选) 设置一个背景色,和侧边栏区分开 */
background-color: #f4f7f6;
min-height: 100vh;
height: 100%;
padding: 0;
display: flex;
flex-direction: column;
}
padding: 24px;
box-sizing: border-box; /* 确保 padding 不会撑破布局 */
.content-wrapper {
flex-grow: 1; /* <== 占据 .main-content 的所有空间 */
min-height: 0; /* <== 一个关键的 flex 技巧,防止溢出 */
padding: 20px;
box-sizing: border-box;
overflow-y: auto;
}
</style>

113
src/api/alarms.ts Normal file
View File

@ -0,0 +1,113 @@
import axios from "axios";
const API_BASE_URL = "http://192.168.0.33:8080";
const apiClient = axios.create({
baseURL: API_BASE_URL,
timeout: 5000,
});
export interface AlarmRule {
rule_id: string;
device_id: string;
data_point_name: string;
compare_type: "GT" | "LT" | "EQ"; // 限制为已知类型
threshold: number;
level: "INFO" | "WARNING" | "CRITICAL"; // 限制为已知类型
debounce_seconds: number;
alarm_mqtt_topic?: string; // 可选
message_template: string;
clear_message_template?: string; // 可选
// [重要] 用于前端的额外状态
_isNew?: boolean; // 标记这是不是一条新添加的规则
}
export interface AlarmEvent {
event_id?: number;
rule_id: string;
device_id: string;
status: "ACTIVE" | "CLEARED";
level: "INFO" | "WARNING" | "CRITICAL"; // 假设的级别
message: string;
trigger_value: number;
timestamp_ms: number;
}
export const getActiveAlarms = async (): Promise<AlarmEvent[]> => {
try {
const response = await apiClient.get<AlarmEvent[]>("/api/alarms/active");
return response.data;
} catch (error) {
console.error("API 请求失败 (getActiveAlarms):", error);
throw new Error("无法获取活动告警");
}
};
export const getAlarmHistory = async (
limit: number = 200
): Promise<AlarmEvent[]> => {
try {
const response = await apiClient.get<AlarmEvent[]>(
`/api/alarms/history?limit=${limit}`
);
return response.data;
} catch (error) {
console.error("API 请求失败 (getAlarmHistory):", error);
throw new Error("无法获取告警历史");
}
};
export const postClearAlarm = async (
rule_id: string,
device_id: string
): Promise<any> => {
try {
const response = await apiClient.post("/api/alarms/clear", {
rule_id,
device_id,
});
return response.data;
} catch (error) {
console.error("API 请求失败 (postClearAlarm):", error);
throw new Error("无法清除告警");
}
};
export const postReloadRules = async (): Promise<any> => {
try {
const response = await apiClient.post("/api/alarms/reload");
return response.data;
} catch (error) {
console.error("API 请求失败 (postReloadRules):", error);
throw new Error("无法重载规则");
}
};
export const getAlarmConfig = async (): Promise<string> => {
try {
const response = await apiClient.get<string>("/api/alarms/config");
if (typeof response.data === 'object') {
return JSON.stringify(response.data, null, 2);
}
return response.data;
} catch (error) {
console.error("API 请求失败 (getAlarmConfig):", error);
throw new Error("无法获取告警配置");
}
};
export const postAlarmConfig = async (jsonContent: string): Promise<any> => {
try {
const response = await apiClient.post("/api/alarms/config", jsonContent, {
headers: { 'Content-Type': 'application/json' }
});
return response.data;
} catch (error) {
console.error("API 请求失败 (postAlarmConfig):", error);
if (axios.isAxiosError(error) && error.response?.status === 400) {
throw new Error("保存失败JSON 格式或规则 schema 无效。请检查 C++ 服务日志。");
}
throw new Error("无法保存告警配置");
}
};

View File

@ -1,11 +1,24 @@
import axios from "axios";
// ---------------------------------------------
// 接口类型定义 (您已有的)
// ---------------------------------------------
export interface SystemStatus {
cpu_usage_percentage: number;
memory_total_kb: number;
memory_free_kb: number;
memory_usage_percentage: number;
}
export interface SystemId {
deviceID: string;
}
export interface Device {
id: string;
type: string;
is_running: boolean;
connection_details: Record<string, any>;
}
export type DevicesResponse = Device[];
const API_BASE_URL = "http://192.168.0.33:8080";
@ -19,7 +32,50 @@ export const getSystemStatus = async (): Promise<SystemStatus> => {
const response = await apiClient.get<SystemStatus>("/api/system/status");
return response.data;
} catch (error) {
console.error("API 请求失败:", error);
console.error("API 请求失败 (getSystemStatus):", error);
throw new Error("无法连接到边缘代理");
}
};
export const getSystemId = async (): Promise<SystemId> => {
try {
const response = await apiClient.get<SystemId>("/api/system/id");
if (
!response.data ||
typeof response.data.deviceID !== "string" ||
response.data.deviceID.trim() === ""
) {
console.error("API 响应格式错误: ", response.data);
throw new Error("从API获取的ID数据格式不正确");
}
return response.data;
} catch (error) {
if (axios.isAxiosError(error)) {
console.error("API 请求失败 (getSystemId):", error);
throw new Error("无法连接到边缘代理");
} else {
throw error;
}
}
};
export const getDevices = async (): Promise<DevicesResponse> => {
try {
const response = await apiClient.get<DevicesResponse>("/api/devices");
if (!Array.isArray(response.data)) {
console.error("API 响应格式错误 (getDevices):", response.data);
throw new Error("从API获取的设备数据格式不正确");
}
return response.data;
} catch (error) {
if (axios.isAxiosError(error)) {
console.error("API 请求失败 (getDevices):", error);
throw new Error("无法连接到边缘代理");
} else {
throw error;
}
}
};

View File

@ -25,9 +25,16 @@ import {
</li>
<li>
<RouterLink to="/tasks">
<RouterLink to="/alarms">
<Files class="nav-icon" />
<span>任务管理</span>
<span>告警展示</span>
</RouterLink>
</li>
<li>
<RouterLink to="/alarmsManager">
<Files class="nav-icon" />
<span>告警管理</span>
</RouterLink>
</li>
@ -61,26 +68,35 @@ import {
<style scoped>
.sidebar {
width: 210px; /* 侧边栏宽度 */
height: 100vh; /* 占满全屏高度 */
position: fixed; /* 固定在左侧 */
width: 210px;
height: 100vh;
position: fixed;
left: 0;
top: 0;
background-color: #304156; /* 深色背景 (来自您截图) */
color: #bfcbd9;
/* [修改] 使用 Element Plus 页面背景色变量 */
background-color: var(--el-bg-color-page);
/* [修改] 使用 Element Plus 常规文本颜色变量 */
color: var(--el-text-color-regular);
display: flex;
flex-direction: column;
/* [修改] 使用 Element Plus 边框颜色变量 */
border-right: 1px solid var(--el-border-color-light);
}
.sidebar-header {
padding: 20px;
text-align: center;
border-bottom: 1px solid #4a5e75;
/* [修改] 使用 Element Plus 边框颜色变量 */
border-bottom: 1px solid var(--el-border-color-light);
}
.sidebar-header h3 {
margin: 0;
color: #ffffff;
/* [修改] 使用 Element Plus 主要文本颜色变量 */
color: var(--el-text-color-primary);
}
.nav-list {
@ -94,15 +110,14 @@ import {
margin: 0;
}
/* (关键) 链接的样式 */
.nav-list li a,
.nav-list li :deep(a) {
/* :deep() 穿透 RouterLink */
display: flex;
align-items: center;
padding: 16px 20px;
text-decoration: none;
color: #bfcbd9;
/* [修改] 使用 Element Plus 常规文本颜色变量 */
color: var(--el-text-color-regular);
transition: background-color 0.2s;
}
@ -112,22 +127,23 @@ import {
margin-right: 12px;
}
/* 鼠标悬停效果 */
.nav-list li a:hover,
.nav-list li :deep(a:hover) {
background-color: #263445;
/* [修改] 使用 Element Plus 浮动层背景色 */
background-color: var(--el-bg-color-overlay);
}
/* (重要) 当前激活链接的样式 */
/* vue-router 会自动为激活的链接添加 .router-link-active 类 */
/* [修改] router-link-active (当前激活的路由) */
.nav-list li :deep(a.router-link-active) {
color: #409eff; /* 高亮颜色 */
background-color: #1f2d3d;
/* [修改] 使用 Element Plus 主题色 */
color: var(--el-color-primary);
/* [修改] 使用 Element Plus 浮动层背景色 */
background-color: var(--el-bg-color-overlay);
}
/* 占位符链接的样式 */
.nav-list li.disabled a {
color: #7c8796;
/* [修改] 使用 Element Plus 禁用文本颜色 */
color: var(--el-text-color-disabled);
cursor: not-allowed;
}
</style>

View File

@ -3,7 +3,8 @@ import "./style.css";
import App from "./App.vue";
import ElementPlus from "element-plus";
import router from "./router";
import "element-plus/dist/index.css";
import "element-plus/theme-chalk/dark/css-vars.css";
const app = createApp(App);
app.use(router);

View File

@ -2,7 +2,8 @@
import { createRouter, createWebHashHistory } from "vue-router";
import StatusPage from "../views/StatusPage.vue";
import TasksPage from "../views/TasksPage.vue";
import AlarmPage from "../views/Alarms.vue";
import AlarmManager from "../views/AlarmManager.vue";
const routes = [
{
@ -11,9 +12,14 @@ const routes = [
component: StatusPage,
},
{
path: "/tasks",
name: "Tasks",
component: TasksPage,
path: "/alarms",
name: "Alarms",
component: AlarmPage,
},
{
path: "/alarmsManager",
name: "AlarmManager",
component: AlarmManager,
},
// ... 您未来可以从这里添加更多路由
];

357
src/views/AlarmManager.vue Normal file
View File

@ -0,0 +1,357 @@
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import {
getAlarmConfig, // string
postAlarmConfig, // string
postReloadRules,
type AlarmRule, //
} from "../api/alarms";
import { Plus, Delete, RefreshLeft } from "@element-plus/icons-vue";
const originalRules = ref<AlarmRule[]>([]); //
const editableRules = ref<AlarmRule[]>([]); //
const isLoading = ref(false);
const isSaving = ref(false);
const isReloading = ref(false);
//
const compareTypes = ["GT", "LT", "EQ"];
const levels = ["INFO", "WARNING", "CRITICAL"];
// --- 1. ---
const handleRefresh = async (confirm = false) => {
if (confirm) {
try {
await ElMessageBox.confirm(
"刷新将从服务器重新加载配置,并覆盖您在表格中的任何未保存更改。",
"确认刷新?",
{ type: "warning" }
);
} catch (error) {
return; //
}
}
isLoading.value = true;
try {
const configString = await getAlarmConfig();
const parsedRules: AlarmRule[] = JSON.parse(configString);
//
originalRules.value = parsedRules;
// [] 使
editableRules.value = JSON.parse(JSON.stringify(parsedRules));
ElMessage.success("告警配置已刷新");
} catch (error: any) {
ElMessage.error(`刷新失败: ${error.message}`);
} finally {
isLoading.value = false;
}
};
onMounted(() => {
handleRefresh(false); //
});
// --- 2. () ---
const isCellDirty = (index: number, field: keyof AlarmRule) => {
const editableRow = editableRules.value[index];
//
if (editableRow._isNew) {
return true;
}
const originalRow = originalRules.value[index];
// ()
if (!originalRow || editableRow[field] !== originalRow[field]) {
return true;
}
return false;
};
// --- 3. CRUD ( / ) ---
const addNewRule = () => {
editableRules.value.unshift({
//
rule_id: "NEW_RULE_ID",
device_id: "*",
data_point_name: "temperature",
compare_type: "GT",
threshold: 0,
level: "WARNING",
debounce_seconds: 0,
message_template: "New alarm triggered!",
_isNew: true, //
});
ElMessage.success("已添加新规则在顶部,请修改。");
};
const deleteRule = (index: number) => {
//
editableRules.value.splice(index, 1);
//
//
};
// --- 4. ---
const handleSaveAndApply = async () => {
isSaving.value = true;
try {
// 1:
// '_isNew'
const dataToSend = editableRules.value.map((rule) => {
const { _isNew, ...rest } = rule;
return rest;
});
// C++ API
const jsonString = JSON.stringify(dataToSend, null, 2); // JSON
// 2: (POST /config)
await postAlarmConfig(jsonString);
ElMessage.success("配置保存成功!");
// 3: (POST /reload)
await postReloadRules();
ElMessage.success("配置已在服务器端成功应用!");
// [] ""
originalRules.value = JSON.parse(JSON.stringify(editableRules.value));
} catch (error: any) {
ElMessage.error(`操作失败: ${error.message}`);
} finally {
isSaving.value = false;
}
};
const handleApplyOnly = async () => {
isReloading.value = true;
try {
await postReloadRules();
ElMessage.success("“应用”命令已发送,服务器已重载规则");
} catch (error: any) {
ElMessage.error(`应用失败: ${error.message}`);
} finally {
isReloading.value = false;
}
};
</script>
<template>
<div class="config-manager-page">
<header class="page-header">
<h1>告警配置管理</h1>
<div class="button-group">
<el-button @click="addNewRule" type="success" :icon="Plus">
添加规则
</el-button>
<el-button
@click="handleRefresh(true)"
:loading="isLoading"
:icon="RefreshLeft"
>
刷新
</el-button>
<el-button
@click="handleApplyOnly"
:loading="isReloading"
type="warning"
>
仅应用 (实时重载)
</el-button>
<el-button
type="primary"
@click="handleSaveAndApply"
:loading="isSaving"
>
保存并应用
</el-button>
</div>
</header>
<div class="table-container" v-loading="isLoading">
<ElTable :data="editableRules" stripe border height="calc(100vh - 160px)">
<ElTableColumn label="规则ID (rule_id)" width="200" fixed>
<template #default="scope">
<el-input
v-model="scope.row.rule_id"
:class="{ 'is-dirty': isCellDirty(scope.$index, 'rule_id') }"
/>
</template>
</ElTableColumn>
<ElTableColumn label="设备ID (device_id)" width="200">
<template #default="scope">
<el-input
v-model="scope.row.device_id"
:class="{ 'is-dirty': isCellDirty(scope.$index, 'device_id') }"
/>
</template>
</ElTableColumn>
<ElTableColumn label="数据点 (data_point_name)" width="180">
<template #default="scope">
<el-input
v-model="scope.row.data_point_name"
:class="{
'is-dirty': isCellDirty(scope.$index, 'data_point_name'),
}"
/>
</template>
</ElTableColumn>
<ElTableColumn label="比较 (compare_type)" width="120">
<template #default="scope">
<el-select
v-model="scope.row.compare_type"
:class="{ 'is-dirty': isCellDirty(scope.$index, 'compare_type') }"
>
<el-option
v-for="item in compareTypes"
:key="item"
:label="item"
:value="item"
/>
</el-select>
</template>
</ElTableColumn>
<ElTableColumn label="阈值 (threshold)" width="120">
<template #default="scope">
<el-input-number
v-model="scope.row.threshold"
controls-position="right"
:class="{ 'is-dirty': isCellDirty(scope.$index, 'threshold') }"
/>
</template>
</ElTableColumn>
<ElTableColumn label="级别 (level)" width="120">
<template #default="scope">
<el-select
v-model="scope.row.level"
:class="{ 'is-dirty': isCellDirty(scope.$index, 'level') }"
>
<el-option
v-for="item in levels"
:key="item"
:label="item"
:value="item"
/>
</el-select>
</template>
</ElTableColumn>
<ElTableColumn label="防抖(s) (debounce_seconds)" width="120">
<template #default="scope">
<el-input-number
v-model="scope.row.debounce_seconds"
:min="0"
controls-position="right"
:class="{
'is-dirty': isCellDirty(scope.$index, 'debounce_seconds'),
}"
/>
</template>
</ElTableColumn>
<ElTableColumn label="告警模板 (message_template)" width="400">
<template #default="scope">
<el-input
v-model="scope.row.message_template"
type="textarea"
:rows="2"
:class="{
'is-dirty': isCellDirty(scope.$index, 'message_template'),
}"
/>
</template>
</ElTableColumn>
<ElTableColumn label="解除模板 (clear_message_template)" width="400">
<template #default="scope">
<el-input
v-model="scope.row.clear_message_template"
type="textarea"
:rows="2"
:class="{
'is-dirty': isCellDirty(scope.$index, 'clear_message_template'),
}"
/>
</template>
</ElTableColumn>
<ElTableColumn label="MQTT 主题 (alarm_mqtt_topic)" width="200">
<template #default="scope">
<el-input
v-model="scope.row.alarm_mqtt_topic"
:class="{
'is-dirty': isCellDirty(scope.$index, 'alarm_mqtt_topic'),
}"
/>
</template>
</ElTableColumn>
<ElTableColumn label="操作" width="100" fixed="right">
<template #default="scope">
<el-button
type="danger"
:icon="Delete"
circle
@click="deleteRule(scope.$index)"
/>
</template>
</ElTableColumn>
</ElTable>
</div>
</div>
</template>
<style scoped>
.config-manager-page {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
color: var(--el-text-color-primary);
}
h1 {
font-size: 1.5em;
font-weight: 600;
margin: 0;
}
.table-container {
flex-grow: 1;
overflow: hidden; /* 确保表格在容器内 */
}
/* [关键] "标红" 逻辑 */
:deep(.el-input.is-dirty .el-input__wrapper),
:deep(.el-input-number.is-dirty .el-input__wrapper),
:deep(.el-select.is-dirty .el-select__wrapper) {
/* 使用 Element Plus 的危险色变量 */
box-shadow: 0 0 0 1px var(--el-color-danger) inset !important;
}
:deep(.el-textarea.is-dirty .el-textarea__inner) {
box-shadow: 0 0 0 1px var(--el-color-danger) inset !important;
}
/* 确保表格内的组件占满单元格 */
:deep(.el-table .el-input),
:deep(.el-table .el-input-number),
:deep(.el-table .el-select) {
width: 100%;
}
</style>

241
src/views/Alarms.vue Normal file
View File

@ -0,0 +1,241 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from "vue";
import {
getActiveAlarms,
getAlarmHistory,
postClearAlarm,
type AlarmEvent,
} from "../api/alarms";
import { ElMessage, ElMessageBox } from "element-plus";
//
const activeAlarms = ref<AlarmEvent[]>([]);
const alarmHistory = ref<AlarmEvent[]>([]);
const activeTab = ref("active");
const isLoadingActive = ref(false);
const isLoadingHistory = ref(false);
let pollInterval: number | undefined;
const loadActiveAlarms = async () => {
isLoadingActive.value = true;
try {
activeAlarms.value = await getActiveAlarms();
} catch (error) {
console.error(error);
//
if (!pollInterval) {
ElMessage.error("加载活动告警失败");
}
} finally {
isLoadingActive.value = false;
}
};
//
const loadAlarmHistory = async () => {
isLoadingHistory.value = true;
try {
alarmHistory.value = await getAlarmHistory(200); // 200
} catch (error) {
console.error(error);
ElMessage.error("加载告警历史失败");
} finally {
isLoadingHistory.value = false;
}
};
// --- ---
//
const handleClearAlarm = async (ruleId: string, deviceId: string) => {
try {
//
await ElMessageBox.confirm(
`您确定要手动清除规则 '${ruleId}' (设备: '${deviceId}') 吗?`,
"确认清除",
{
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning",
}
);
//
await postClearAlarm(ruleId, deviceId);
ElMessage.success("告警已清除");
//
loadActiveAlarms();
} catch (error: any) {
// error 'cancel'
if (error !== "cancel") {
ElMessage.error(`清除失败: ${error.message || "未知错误"}`);
}
}
};
const handleRefresh = async () => {
try {
await Promise.all([loadActiveAlarms(), loadAlarmHistory()]);
ElMessage.success("告警数据已刷新");
} catch (error) {
ElMessage.error("刷新失败");
}
};
const formatTimestamp = (ms: number) => {
if (!ms) return "N/A";
return new Date(ms * 1000).toLocaleString("zh-CN");
};
const getLevelTagType = (level: string) => {
if (level === "CRITICAL") return "danger";
if (level === "WARNING") return "warning";
return "info";
};
// --- ---
onMounted(() => {
//
loadActiveAlarms();
loadAlarmHistory();
// StatusPage.vue
pollInterval = window.setInterval(loadActiveAlarms, 5000);
});
onUnmounted(() => {
//
if (pollInterval) {
clearInterval(pollInterval);
}
});
</script>
<template>
<div class="alarm-management-page">
<header class="page-header">
<h1>告警管理</h1>
<el-button type="primary" @click="handleRefresh"> 刷新 </el-button>
</header>
<ElTabs v-model="activeTab" class="alarm-tabs">
<ElTabPane label="当前告警" name="active">
<ElTable
v-loading="isLoadingActive"
:data="activeAlarms"
stripe
height="calc(100vh - 220px)"
>
<ElTableColumn prop="timestamp" label="最后触发时间" width="200">
<template #default="scope">
{{ formatTimestamp(scope.row.timestamp) }}
</template>
</ElTableColumn>
<ElTableColumn prop="level" label="级别" width="100">
<template #default="scope">
<el-tag :type="getLevelTagType(scope.row.level)">
{{ scope.row.level }}
</el-tag>
</template>
</ElTableColumn>
<ElTableColumn prop="rule_id" label="规则ID" width="180" />
<ElTableColumn prop="device_id" label="设备ID" width="180" />
<ElTableColumn prop="message" label="告警消息" />
<ElTableColumn label="操作" width="120" fixed="right">
<template #default="scope">
<el-button
type="primary"
link
@click="
handleClearAlarm(scope.row.rule_id, scope.row.device_id)
"
>
手动清除
</el-button>
</template>
</ElTableColumn>
</ElTable>
</ElTabPane>
<ElTabPane label="告警历史" name="history">
<ElTable
v-loading="isLoadingHistory"
:data="alarmHistory"
stripe
height="calc(100vh - 220px)"
>
<ElTableColumn prop="timestamp" label="触发时间" width="200">
<template #default="scope">
{{ formatTimestamp(scope.row.timestamp) }}
</template>
</ElTableColumn>
<ElTableColumn prop="status" label="状态" width="100">
<template #default="scope">
<el-tag
:type="scope.row.status === 'ACTIVE' ? 'danger' : 'success'"
>
{{ scope.row.status }}
</el-tag>
</template>
</ElTableColumn>
<ElTableColumn prop="level" label="级别" width="100">
<template #default="scope">
<el-tag :type="getLevelTagType(scope.row.level)" effect="light">
{{ scope.row.level }}
</el-tag>
</template>
</ElTableColumn>
<ElTableColumn prop="rule_id" label="规则ID" width="180" />
<ElTableColumn prop="device_id" label="设备ID" width="180" />
<ElTableColumn prop="message" label="告警消息" />
</ElTable>
</ElTabPane>
</ElTabs>
</div>
</template>
<style scoped>
.alarm-management-page {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
h1 {
font-size: 1.5em;
font-weight: 600;
margin: 0;
}
.alarm-tabs {
flex-grow: 1;
display: flex;
flex-direction: column;
}
/* 确保表格容器占满剩余空间 */
:deep(.el-tabs__content) {
flex-grow: 1;
}
:deep(.el-tab-pane) {
height: 100%;
display: flex;
flex-direction: column;
}
/* 使表格在 Tab 页内占满 */
.el-table {
flex-grow: 1;
}
</style>

View File

@ -1,19 +1,55 @@
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { getSystemStatus, type SystemStatus } from "../api/system";
import {
getSystemStatus,
getSystemId,
getDevices,
type SystemStatus,
type SystemId,
type DevicesResponse,
} from "../api/system";
const status = ref<SystemStatus | null>(null);
const deviceId = ref<string | null>(null);
const connectedDevicesCount = ref<number | null>(null);
const error = ref<string | null>(null);
const isLoading = ref<boolean>(true);
const fetchData = async () => {
const pollStatus = async () => {
try {
const [statusData, devicesData] = await Promise.all([
getSystemStatus(),
getDevices(),
]);
status.value = statusData;
connectedDevicesCount.value = devicesData.length;
if (error.value) error.value = null;
} catch (err) {
console.error("后台轮询失败:", err);
error.value =
err instanceof Error
? `状态更新失败: ${err.message}`
: "状态更新时发生未知错误";
}
};
const initialLoad = async () => {
isLoading.value = true;
error.value = null;
try {
status.value = await getSystemStatus();
const [statusData, idData, devicesData] = await Promise.all([
getSystemStatus(),
getSystemId(),
getDevices(),
]);
status.value = statusData;
deviceId.value = idData.deviceID;
connectedDevicesCount.value = devicesData.length;
} catch (err) {
console.error("获取状态失败:", err);
console.error("初始加载失败:", err);
error.value = err instanceof Error ? err.message : "发生未知错误";
} finally {
isLoading.value = false;
@ -33,18 +69,24 @@ const formatPercentage = (percentage: number | undefined) => {
const getProgressColor = (percentage: number | undefined) => {
if (percentage === undefined) percentage = 0;
if (percentage < 50) {
return "#67C23A"; // 绿 ()
return "#67C23A";
} else if (percentage < 80) {
return "#E6A23C"; // ()
return "#E6A23C";
} else {
return "#F56C6C"; // ()
return "#F56C6C";
}
};
onMounted(() => {
fetchData();
setInterval(fetchData, 5000);
initialLoad();
setInterval(pollStatus, 5000);
});
const handleRefresh = () => {
if (!isLoading.value) {
initialLoad();
}
};
</script>
<template>
@ -58,51 +100,71 @@ onMounted(() => {
<p>请检查边缘代理设备 (192.168.0.33) 是否已启动并在运行 API 服务</p>
</div>
<div v-else-if="status" class="status-grid">
<div class="status-card">
<h2>CPU使用率</h2>
<el-progress
type="circle"
:percentage="status.cpu_usage_percentage"
:width="140"
:stroke-width="12"
:format="formatPercentage"
:color="getProgressColor(status.cpu_usage_percentage)"
/>
<div v-else-if="!isLoading">
<div class="status-card device-id-card">
<h2>设备ID</h2>
<div v-if="deviceId" class="device-id-value">
{{ deviceId }}
</div>
<div v-else-if="!deviceId && !error" class="device-id-placeholder">
ID不可用
</div>
<div v-else-if="error" class="device-id-placeholder">ID获取失败</div>
</div>
<div class="status-card">
<h2>内存占用</h2>
<el-progress
type="circle"
:percentage="status.memory_usage_percentage"
:width="140"
:stroke-width="12"
:format="formatPercentage"
:color="getProgressColor(status.memory_usage_percentage)"
/>
<div class="status-grid">
<div v-if="status" class="status-card">
<h2>CPU使用率</h2>
<el-progress
type="circle"
:percentage="status.cpu_usage_percentage"
:width="140"
:stroke-width="12"
:format="formatPercentage"
:color="getProgressColor(status.cpu_usage_percentage)"
/>
</div>
<div class="memory-details-text">
<span class="used-gb"
>{{
formatKBtoGB(status.memory_total_kb - status.memory_free_kb)
}}
GB</span
>
<span class="total-gb"
>/ {{ formatKBtoGB(status.memory_total_kb) }} GB</span
>
<div v-if="status" class="status-card">
<h2>内存占用</h2>
<el-progress
type="circle"
:percentage="status.memory_usage_percentage"
:width="140"
:stroke-width="12"
:format="formatPercentage"
:color="getProgressColor(status.memory_usage_percentage)"
/>
<div class="memory-details-text">
<span class="used-gb"
>{{
formatKBtoGB(status.memory_total_kb - status.memory_free_kb)
}}
GB</span
>
<span class="total-gb"
>/ {{ formatKBtoGB(status.memory_total_kb) }} GB</span
>
</div>
</div>
<div v-if="connectedDevicesCount !== null" class="status-card">
<h2>当前连接设备数</h2>
<div class="large-value">
{{ connectedDevicesCount }}
</div>
</div>
</div>
</div>
<button @click="fetchData" :disabled="isLoading" class="refresh-btn">
<button @click="handleRefresh" :disabled="isLoading" class="refresh-btn">
{{ isLoading ? "刷新中..." : "立即刷新" }}
</button>
</main>
</template>
<style>
/* ... (全局 #app 样式保持不变) ... */
#app {
max-width: none;
margin: 0;
@ -112,49 +174,46 @@ onMounted(() => {
<style scoped>
.status-monitor {
font-family: "PuhuiFont", -apple-system, BlinkMacSystemFont, "Segoe UI",
Roboto, sans-serif;
padding: 24px;
background-color: #f4f7f6;
min-height: 100vh;
color: #333;
/* [移除] background-color 和 color */
/* [保留] 布局样式 */
width: 100%;
max-width: none;
height: 100%;
padding: 0; /* [修改] App.vue 的 wrapper 会提供 padding */
box-sizing: border-box;
display: flex;
flex-direction: column;
}
h1 {
color: #1a1a1a;
border-bottom: 2px solid #ddd;
padding-top: 1.5vh;
padding-bottom: 1.5vh;
font-size: 2.5vh;
}
.loading-box {
font-size: 1.2em;
color: #555;
.loading-box,
.error-box {
/* [新增] 确保提示文字颜色正确 */
color: var(--el-text-color-primary);
margin: 20px;
}
.error-box {
color: #d8000c;
background-color: #ffd2d2;
border: 1px solid #d8000c;
/* [修改] 使用 Element Plus 的变量 */
color: var(--el-color-danger);
background-color: var(--el-color-danger-light-9);
border: 1px solid var(--el-color-danger-light-5);
padding: 15px;
border-radius: 8px;
margin-top: 20px;
}
.status-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 20px;
margin: 20px 0;
margin-top: 20px;
flex-grow: 1;
min-height: 0;
}
.status-card {
background-color: #ffffff;
border: 1px solid #e0e0e0;
/* [修改] 使用 CSS 变量 */
background-color: var(--el-bg-color-overlay);
border: 1px solid var(--el-border-color-light);
border-radius: 8px;
padding: 20px;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.05);
box-shadow: var(--el-box-shadow-light);
transition: transform 0.2s ease-in-out;
display: flex;
flex-direction: column;
align-items: center;
@ -167,20 +226,14 @@ h1 {
.status-card h2 {
margin: -8px 0 8px 0;
font-size: 1.1em;
color: #555;
/* [修改] 使用 CSS 变量 */
color: var(--el-text-color-secondary);
font-weight: 500;
}
.status-card .value {
font-size: 2.2em;
font-weight: 700;
color: #007aff;
}
.status-card .value span {
font-size: 0.7em;
font-weight: 400;
color: #666;
width: 100%;
text-align: center;
}
.refresh-btn {
/* 蓝色按钮在暗色/亮色下都OK保持不变 */
background-color: #007aff;
color: white;
border: none;
@ -190,6 +243,7 @@ h1 {
border-radius: 6px;
cursor: pointer;
transition: background-color 0.2s;
margin-top: 20px;
}
.refresh-btn:disabled {
background-color: #aaa;
@ -199,49 +253,61 @@ h1 {
background-color: #0056b3;
}
.status-card :deep(.el-progress) {
position: relative !important;
width: 140px !important;
height: 140px !important;
display: inline-block !important;
}
/* [修改] 适配 el-progress 的文字颜色 */
.status-card :deep(.el-progress__text) {
position: absolute !important;
top: 50% !important;
left: 50% !important;
transform: translate(-50%, -50%) !important;
margin: 0 !important;
width: 100%;
text-align: center;
/* ... (布局样式保持不变) ... */
font-size: 1.6em !important;
font-weight: 500;
color: #303133 !important;
/* [修改] 使用 CSS 变量 */
color: var(--el-text-color-primary) !important;
}
.status-card :deep(.el-progress-circle) {
position: absolute !important;
top: 0 !important;
left: 0 !important;
width: 100% !important;
height: 100% !important;
}
.status-card .memory-details-text {
font-size: 1.2em;
font-weight: 500;
color: #606266;
/* [修改] 使用 CSS 变量 */
color: var(--el-text-color-secondary);
}
.memory-details-text .used-gb {
font-weight: 600;
color: #303133; /* “已用”文本更突出 */
/* [修改] 使用 CSS 变量 */
color: var(--el-text-color-primary);
}
.memory-details-text .total-gb {
font-size: 0.9em;
color: #909399; /* “总量”文本更次要 */
/* [修改] 使用 CSS 变量 */
color: var(--el-text-color-disabled);
}
.status-card .large-value {
font-size: 3.5em;
font-weight: 600;
/* [修改] 使用 CSS 变量 */
color: var(--el-text-color-primary);
flex-grow: 1;
display: flex;
align-items: center;
justify-content: center;
}
.device-id-card {
min-height: auto;
justify-content: center;
margin-top: 20px;
}
.device-id-card h2 {
margin: 0 0 10px 0;
}
.device-id-value {
font-size: 1.3em;
font-weight: 600;
/* [修改] 使用 CSS 变量 */
color: var(--el-text-color-primary);
word-break: break-all;
padding: 0 10px;
text-align: center;
}
.device-id-placeholder {
font-size: 1.1em;
/* [修改] 使用 CSS 变量 */
color: var(--el-text-color-disabled);
}
</style>