告警模块
This commit is contained in:
parent
8b554876c2
commit
a92faf98ec
|
|
@ -22,3 +22,9 @@ dist-ssr
|
|||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
dist/
|
||||
build/
|
||||
config/
|
||||
tmp/
|
||||
test-data/
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
});
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ function createWindow() {
|
|||
},
|
||||
});
|
||||
win.maximize();
|
||||
win.webContents.openDevTools();
|
||||
|
||||
// Test active push message to Renderer-process.
|
||||
win.webContents.on("did-finish-load", () => {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
46
src/App.vue
46
src/App.vue
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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("无法保存告警配置");
|
||||
}
|
||||
};
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
// ... 您未来可以从这里添加更多路由
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue