本文最后更新于 2025-12-31,文章内容可能已经过时。

优化后效果

image.png

  1. 不同状态的告警以不同颜色显示,方便区分;
  2. 优化为中文通知文案,简单易懂,符合中国宝宝体质;
  3. 完美适配飞书通知体,支持@需要提醒的用户,这样就可以屏蔽通知群里其他信息,也不会错过重要通知(因为会@你);
  4. 支持配置点击提示消息自动跳转到网站查看。
    想实现和我一样的效果,就和我一起继续吧。当然如果你觉得这篇文章对你有帮助,欢迎帮忙点赞、转发、评论、打赏,你的支持就是我更新的动力!

简介

UptimeKuma 是一个开源的自托管监控工具,详情可见 Github: github.com/louislam/uptime-kuma 下文简称 UK。

image.png

它可以帮助我们定时监控目标域名或中间件服务的运行状态,同时提供 SSL 证书到期提示的能力。本站的监测站网络状态便是基于 UK 容器来进行部署的。
他的功能也很多,支持把网站的异常状态通过邮件、钉钉、飞书等方式发送,尤其是飞书、钉钉这种国内常用办公软件。但是 UK 的通知默认是英文的,且不提供通知样式的修改,有些丑陋。本文基于飞书的通知美化改造,抛砖引玉给大家提供一种新的思路。比如你可以用同样的思路修改,webhook、钉钉、企微等的通知内容。

1. 监控配置与安装(UK)

1.1 UK安装与配置(docker 安装)

关于 UK 的安装及配置,网上有很多教程,鱼鱼就不在赘述了。需要的朋友可以参考四个步骤使用UptimeKuma搭建状态监测站 - ElysiumStack|不会摄影的设计师不是优秀的旅行家

2. 配置通知

配置好监控站后,点击 编辑->设置通知 进行通知配置。

image.png

image.png

下拉列表中选择飞书

image.png

配置名称、WebHook URL 、开启并应用到所有监控,测试成功后保存

image.png

  • 飞书的 webhook url 获取参考:在群组中使用机器人
  • 进入下面前,可手动停止/恢复下不重要的配置了监控服务,用来测试下监控及通知已经可以正常工作。

3. 告警通知优化

至此你已经可以监控网站状态,并进行飞书通知了。下面开始优化通知效果。

3.1 前置准备

  1. /app/server/ 拷贝到 /app/data/server
docker exec -it uptime-kuma bash
cd /app
cp server/ ./data/ -r
exit

3.2 重启重新添加映射后的 docker。

docker stop uptime-kuma
docker run -d --restart=always -p 3003:3001 -v /data/uptime-kuma:/app/data -v /data/uptime-kuma/server:/app/server --name uptime-kuma louislam/uptime-kuma

3 .3 修改内容后,替换 /data/uptime-kuma/server/notification-providers 下的 feushu.js 文件。

需要修改的地方:

image.png

2. 第 82 行 const atTag = '<at id="aabbccddxxxx"></at>'; 中的 aabbccddxxxx,替换为你自己的 id。

image.png

const NotificationProvider = require("./notification-provider");
const axios = require("axios");
const { DOWN, UP } = require("../../src/util");

class Feishu extends NotificationProvider {
    name = "Feishu";

    /**
     * @inheritdoc
     */
    async send(notification, msg, monitorJSON = null, heartbeatJSON = null) {
        const okMsg = "Sent Successfully.";

        try {
            let config = this.getAxiosConfigWithProxy({});

            // 处理测试消息(不包含 heartbeatJSON 的情况)
            if (heartbeatJSON == null) {
                let testdata = {
                    msg_type: "text",
                    content: {
                        text: msg,
                    },
                };
                await axios.post(notification.feishuWebHookUrl, testdata, config);
                return okMsg;
            }

            // ==========================================
            // 2. 构建飞书卡片数据
            // ==========================================
            const isDown = heartbeatJSON["status"] === DOWN;
            const statusColor = isDown ? "red" : "green";
            const statusText = isDown ? "故障" : "正常";

            // 获取处理后的文本内容(包含 @人 和 错误详情逻辑)
            const contentText = this.getContent(heartbeatJSON);

            let data = {
                msg_type: "interactive",
                card: {
                    config: {
                        wide_screen_mode: true
                    },
                    header: {
                        title: {
                            tag: "plain_text",
                            content: `${monitorJSON["name"]}`
                        },
                        template: statusColor
                    },
                    elements: [
                        {
                            tag: "div",
                            text: {
                                tag: "lark_md",
                                content: contentText
                            }
                        }
                    ],
                    // 添加状态页跳转链接
                    card_link: {
                        url: "https://status.freuan.top/status/web"
                    }
                }
            };

            await axios.post(notification.feishuWebHookUrl, data, config);
            return okMsg;

        } catch (error) {
            this.throwGeneralAxiosError(error);
        }
    }

    /**
     * 生成卡片内容,包含 @人 和 错误详情判断逻辑
     * @param {object} heartbeatJSON 
     * @returns {string}
     */
    getContent(heartbeatJSON) {
        const atTag = '<at id="aabbccddxxxx"></at>';
        
        // ==========================================
        // 1. 核心时区修复:强制将输入时间视为 UTC
        // ==========================================
        if (heartbeatJSON && heartbeatJSON.time) {
            let timeStr = heartbeatJSON.time;

            // 关键修复:如果时间字符串末尾没有 Z (UTC标识) 或 + (偏移量),手动加上 Z
            // 这强制 JS 将 "2025-12-29 04:28:17" 解析为 "2025-12-29 04:28:17 UTC"
            if (!timeStr.endsWith('Z') && !timeStr.includes('+')) {
                timeStr += 'Z';
            }

            const originalDate = new Date(timeStr);
            const options = {
                timeZone: 'Asia/Shanghai',
                year: 'numeric',
                month: '2-digit',
                day: '2-digit',
                hour: '2-digit',
                minute: '2-digit',
                second: '2-digit',
                hour12: false
            };
            // 格式化为 "2023-10-27 10:00:00"
            heartbeatJSON.time = originalDate.toLocaleString('zh-CN', options).replace(/\//g, '-');
        }

        const time = heartbeatJSON["time"];
        const isDown = heartbeatJSON["status"] === DOWN;

        let content = "";

        if (isDown) {
            content += `${atTag}⚠️ **服务状态:故障**\n`;
            content += `**检测时间**:${time}\n`;
            
            // 错误详情逻辑判断
            let errMsg = heartbeatJSON["msg"] || "未知错误";
            
            if (errMsg.includes("status code")) {
                content += "**错误详情**:服务不可用。";
            } else if (errMsg.includes("timeout")) {
                content += "**错误详情**:连接超时";
            } else if (errMsg.includes("refused")) {
                content += "**错误详情**:连接被拒绝";
            } else {
                content += `**错误详情**:${errMsg}`;
            }

        } else if (heartbeatJSON["status"] === UP) {
            content += `${atTag}✅ **服务状态:正常**\n`;
            content += `**检测时间**:${time}\n`;
            content += "您的服务已恢复正常访问。";
        } else {
            content += `${atTag}📋 **状态更新**`;
        }

        return content;
    }
}

module.exports = Feishu;