Compare commits

...
151 Commits
Author SHA1 Message Date
github-actions[bot] dbb9075a52 Merge pull request #281 from komari-monitor/dev
fix: 实现按任务ID分组的记录比例下采样
2025-09-20 16:43:49 +00:00
akizon77 eafcf7fe76 fix: 实现按任务ID分组的记录比例下采样 2025-09-21 00:43:00 +08:00
github-actions[bot] 094ce6c1ba Merge pull request #280 from komari-monitor/dev
feat: 更新通知机制,使用事件消息替代文本消息
2025-09-20 11:41:25 +00:00
akizon77 2eed2cf7ba fix: 测试消息使用事件 2025-09-20 17:37:22 +08:00
akizon77 88b9d3bdac Merge branch 'main' into dev 2025-09-20 17:22:29 +08:00
Akizon 9c87227613 Merge pull request #269 from kdwycz/dev
新增了GPU监控数据接收存储
2025-09-17 16:53:46 +08:00
Akizon77 bc737b1ef9 feat: 更新通知模板,添加表情和消息内容 2025-09-17 13:50:19 +08:00
Akizon77 911d990690 feat: 更新通知机制,使用事件消息替代文本消息 2025-09-17 13:41:17 +08:00
kdwycz 0f7cd5f438 新增了GPU监控数据接收存储
接收agent发送的gpu监控数据.
参考Record实现, 新增GPURecord表存储监控数据和压缩监控数据.
2025-09-15 20:23:16 +08:00
github-actions[bot] 354903b97b Merge pull request #264 from komari-monitor/dev
fix: 修正 WebSocket 升级逻辑
2025-09-14 05:06:52 +00:00
Akizon77 b432396111 fix: 修正 WebSocket 升级逻辑 2025-09-14 13:06:02 +08:00
github-actions[bot] 491c0ad531 Merge pull request #263 from komari-monitor/dev
feat: 延迟显示更多信息
2025-09-14 02:41:08 +00:00
Akizon77 1540b78487 feat: 延迟显示更多信息 2025-09-14 01:17:51 +00:00
Akizon77 11f43b116d fix: 修正Uptime 2025-09-13 20:05:46 +08:00
github-actions[bot] a0760c8e93 Merge pull request #261 from komari-monitor/dev
feat: rpc2最近信息补全延迟、uptime
2025-09-13 09:30:40 +00:00
github-actions[bot] 85ecfc3500 Merge pull request #260 from komari-monitor/dev
feat: 添加 Nezha 兼容 gRPC 服务器的启动和停止功能
2025-09-13 09:29:10 +00:00
Akizon77 4c2308622a feat: rpc2最近信息补全延迟、uptime 2025-09-13 17:28:04 +08:00
Akizon77 25b6a25f9a fix: 更新 OAuthCallback 重定向路径,从 /manage 改为 /admin 2025-09-13 16:19:51 +08:00
Akizon77 7138c22d02 refactor: 移除不必要的 nezhaBoot 变量,更新 StopNezhaCompat 以强制停止服务器 2025-09-13 15:52:57 +08:00
Akizon77 a06a5c9e72 feat: 替换 StartNezhaCompatServer 为 StartNezhaCompat,标记为已弃用 2025-09-13 15:45:20 +08:00
Akizon77 668eb63668 feat: 添加 Nezha 兼容 gRPC 服务器的启动和停止功能 2025-09-13 15:09:34 +08:00
Akizon77 7d75f1ba37 feat: 在客户端自动创建时记录审计日志事件 2025-09-13 15:01:20 +08:00
github-actions[bot] 2c7b59985e Merge pull request #259 from komari-monitor/dev
feat: 添加数据掩码功能以保护敏感信息
2025-09-13 06:36:17 +00:00
Akizon77 b616eeb2d4 feat: 添加数据掩码功能以保护敏感信息 2025-09-13 14:35:42 +08:00
Akizon77 0f0e359c63 wip: 通知 2025-09-12 03:01:42 +00:00
Akizon77 1b83644788 docs: 更新 Sharon 图片 2025-09-10 04:40:13 +00:00
github-actions[bot] 9a9200744e Merge pull request #250 from komari-monitor/dev
feat: 兼容哪吒Agent
2025-09-09 14:41:47 +00:00
Akizon77 e763bc12c4 docs: 添加 SharonNetworks 的赞助信息 2025-09-09 14:39:39 +00:00
Akizon77 8d7b47a7de feat: 兼容哪吒Agent 2025-09-08 13:38:47 +00:00
github-actions[bot] d7b3c3443d Merge pull request #240 from komari-monitor/dev
feat: 未绑定第三方账户时不能关闭密码登录
2025-09-06 04:03:58 +00:00
Akizon77 8559083d66 feat: 未绑定第三方账户时不能关闭密码登录 2025-09-06 04:02:22 +00:00
github-actions[bot] 96ef3c9fd5 Merge pull request #239 from komari-monitor/dev
feat: 添加JSON-RPC2接口
2025-09-06 03:21:32 +00:00
Akizon77 3cace1fce0 feat: 添加 maxCount 参数以限制记录数量并实现分组下采样 2025-09-06 03:16:18 +00:00
Akizon77 f2adfcc052 feat: 添加获取记录的 RPC 方法及相关逻辑 2025-09-06 03:02:11 +00:00
Akizon77 b31bfa8ba9 feat: 添加获取版本和节点最近状态的方法 2025-09-06 02:44:27 +00:00
Akizon77 b098509df4 feat: 添加 getMe 方法 2025-09-02 14:12:00 +00:00
Akizon77 2df23b6ddb feat: 更新 getNodesLatestStatus 支持通过多个 UUID 获取节点最新状态 2025-08-31 20:44:32 +08:00
Akizon77 6dbfdf7f89 feat: 添加获取节点最新状态的 RPC 方法及相关逻辑 2025-08-31 20:37:46 +08:00
Akizon77 5d8b0d9c91 feat: rpc 添加公开信息方法 2025-08-31 13:15:39 +08:00
Akizon77 fa00885d8b feat: 增强 JSON-RPC 支持,添加批量请求处理及权限管理 2025-08-31 12:47:36 +08:00
Akizon77 f3b52b3282 feat: 添加 JSON-RPC2 支持及相关方法注册 2025-08-30 18:43:36 +08:00
svnmoe 10ddde6592 feat:update /api/records/ping API (#232)
* feat:update /api/records/ping API
Request method:GET
parameters:
  uuid | string | client uuid
  task_id | int | ping_task_id
  hours   | int | ping_record_hours
*At least one of uuid or task_id must be provided
examples:
  GET /api/records/ping?uuid= &hours=
  GET /api/records/ping?task_id= &hours=
  GET /api/records/ping?uuid= &task_id= &hours=\

* fix:remove target in /api/records/ping

* feat:add load_type parameters for api/records
load_type parameters can be:
cpu
gpu
ram
swap
load
temp
disk
network
process
connections
all (or not passing a parameter)
example:
GET /api/records/load?uuid= &load_type=cpu&hours=
GET /api/records/load?uuid= &hours=4
2025-08-30 13:05:12 +08:00
github-actions[bot] 9dff938db3 Merge pull request #229 from komari-monitor/dev
refactor: 简化 CFACCESS OAuth 逻辑
2025-08-28 03:18:56 +00:00
Akizon77 44ae7b218f refactor: 简化 CFACCESS OAuth 逻辑 2025-08-28 11:14:30 +08:00
Akizon77 f6fa61fe56 Merge branch 'main' of https://github.com/komari-monitor/komari 2025-08-28 10:39:48 +08:00
xxddff 207c3fa75d Merge pull request #228 from xxddff/cloudflare-access-simply
feat: 添加对 Cloudflare Access 的支持
2025-08-28 10:36:23 +08:00
MoGuQAQ 027c6c8c14 Merge pull request #221 from mogumc/dev
fix: some feature
2025-08-25 12:40:24 +08:00
Akizon 709fafd64a Merge pull request #219 from svnmoe/dev
feat:add GET /api/records/ping?task_id= &hours=
2025-08-24 14:13:17 +08:00
svnmoe 772e43d5b4 feat:add GET /api/records/ping?task_id= &hours= 2025-08-24 14:10:09 +08:00
Akizon 2c0f01be1a Merge pull request #218 from svnmoe/dev
fix:remove target in /api/task/ping
2025-08-24 12:50:55 +08:00
svnmoe f7bec7189c fix:remove target in /api/task/ping 2025-08-24 12:49:25 +08:00
Akizon 1730f849ed Merge pull request #217 from svnmoe/dev
feat:add /api/task/ping to get all PingTask info
2025-08-24 12:43:10 +08:00
svnmoe 1d46194f99 feat:add /api/task/ping to get all PingTask info 2025-08-24 12:40:13 +08:00
Akizon 8d652049af Merge pull request #210 from komari-monitor/copilot/fix-b576a6c5-0ea2-4f16-8483-2eb7767cd8ad
Add PR and manual triggers to binary build workflow
2025-08-22 16:42:44 +08:00
copilot-swe-agent[bot]andAkizon77 7af2a956d2 Add PR and manual triggers to binary build workflow
Co-authored-by: Akizon77 <64686663+Akizon77@users.noreply.github.com>
2025-08-20 14:31:50 +00:00
copilot-swe-agent[bot] 45b23a04c9 Initial plan 2025-08-20 14:26:30 +00:00
github-actions[bot] 1a7c5990ae Merge pull request #209 from komari-monitor/dev
feat: #159 流量提醒
2025-08-20 14:23:53 +00:00
Akizon77 9133dac3d0 feat: #159 流量提醒 2025-08-20 22:09:57 +08:00
Akizon77 2709166a5b feat: 添加流量监控功能,每分钟检查并提醒流量使用情况 2025-08-20 22:01:47 +08:00
github-actions[bot] fcb5284d57 Merge pull request #208 from komari-monitor/dev
feat: 添加登录通知
2025-08-20 11:08:27 +00:00
Akizon77 92d511253f fix: 调整登录通知消息格式 2025-08-20 18:53:55 +08:00
Akizon77 9874c4d339 feat: 添加登录通知 2025-08-20 17:34:28 +08:00
MoGuQAQ 86e97c66b9 Merge pull request #206 from mogumc/main
feat: ws支持请求特定服务器
2025-08-20 16:47:34 +08:00
Akizon f2afed643e ci: Disable auto run 2025-08-18 16:44:06 +08:00
Akizon f502e3df76 Update cleanup-packages.yml 2025-08-18 16:43:10 +08:00
Akizon 7491da96e6 Merge pull request #204 from guodonn01/main
Feat: ws支持快速重连,fix: 精确时间段下发ping task
2025-08-17 20:38:41 +08:00
root 0b887689f0 Feat: ws支持快速重连,fix: 精确时间段下发ping task 2025-08-17 19:59:36 +08:00
Akizon 9cce96c80c Merge pull request #200 from guodonn01/main
Fix: 下发ping task 应该时间更精确得以interval为间隔
2025-08-16 14:49:11 +08:00
gordon 2711d27c03 Fix: 下发ping task 应该时间更精确得以interval为间隔 2025-08-16 13:27:16 +08:00
github-actions[bot] db3f31aba5 Merge pull request #198 from komari-monitor/dev
fix: 确保主题声明回传有值
2025-08-15 15:20:43 +00:00
Akizon77 29a7e3607f fix: 确保主题声明回传有值 2025-08-15 13:46:37 +00:00
Akizon d7f6c2920d Merge pull request #196 from mogumc/main
feat: Agent无ip返回时使用ClientIP
2025-08-14 18:56:09 +08:00
mogumc 6bff7e86f6 feat: Agent无ip返回时使用ClientIP 2025-08-14 18:23:56 +08:00
Akizon a7a78a316e Merge pull request #192 from mogumc/main
fix: 安全数据校验
2025-08-14 15:31:45 +08:00
github-actions[bot] 17ff74854a Merge pull request #195 from komari-monitor/dev
feat: 更新WebSocket连接的跨域检查逻辑
2025-08-14 07:26:38 +00:00
Akizon77 a101601cf4 feat: 更新WebSocket连接的跨域检查逻辑 2025-08-14 07:25:14 +00:00
MoGuQAQ 1904192f55 Merge branch 'komari-monitor:main' into main 2025-08-14 00:57:51 +08:00
github-actions[bot] 7fd49ab55c Merge pull request #193 from komari-monitor/dev
feat: 新增主题配置项、流量限制
2025-08-13 15:10:30 +00:00
Akizon77 3d7138ea30 feat: 管理主题配置
fix: recent鉴权
2025-08-13 15:08:33 +00:00
mogumc 835f8f17ec chore: 删除调试信息 2025-08-13 16:57:31 +08:00
mogumc 488c1afcab fix: 安全数据校验 2025-08-13 16:51:18 +08:00
mogumc 1fd59efe6c fix: 修复未登录可以通过uuid获取流量与ping记录 2025-08-13 15:47:20 +08:00
Akizon 9cf0366aca Merge pull request #191 from mogumc/main
fix: 修复未登录状态可以通过uuid获取隐藏信息
2025-08-13 13:43:01 +08:00
mogumc 15d7480547 fix: 修复未登录状态可以通过uuid获取隐藏信息 2025-08-13 13:37:00 +08:00
Akizon77 819c55d691 feat: 新增流量限制类型字段 2025-08-12 15:02:27 +00:00
Akizon77 67b4c5ebd8 feat: 新增流量限制字段 2025-08-12 13:31:38 +00:00
github-actions[bot] 4bd9fda728 Merge pull request #190 from komari-monitor/dev
feat: 支持隐藏特定服务器
2025-08-12 09:11:10 +00:00
Akizon77 33b8308b3f chore: 更正注释 2025-08-12 09:09:48 +00:00
Akizon77 57cf9c243f feat: 支持隐藏特定服务器
chore: 迁移ws的API接口到api包下
2025-08-12 08:59:01 +00:00
github-actions[bot] f497a1e48b Merge pull request #189 from komari-monitor/dev
fix: 修正createGETRequest方法中的URL替换逻辑
2025-08-12 04:46:17 +00:00
Akizon77 f23fa95a56 fix: 修正createGETRequest方法中的URL替换逻辑 2025-08-12 04:45:20 +00:00
github-actions[bot] 8e8033bb35 Merge pull request #187 from komari-monitor/dev
feat: 支持通过环境变量显式关闭Origin校验
2025-08-10 08:50:53 +00:00
Akizon77 71b0903b0e feat: 支持通过环境变量显式关闭Origin校验 2025-08-10 08:49:23 +00:00
Gil Schneider d0eefe2dd2 feat: 新增聚合登录(QQ)功能 (#185)
* feat:新增单点登录提供商“聚合登录”;
feat:新增集合登录的登录参数设置;

* feat:移除nsmao接口支持。

* 移除调试环境生成的二进制文件
2025-08-10 15:04:49 +08:00
github-actions[bot] 2c547bab68 Merge pull request #186 from komari-monitor/dev
ci: 禁用ghcr缓存
2025-08-10 07:03:45 +00:00
Akizon77 2dc04b5ecf ci: 禁用ghcr缓存 2025-08-10 07:02:48 +00:00
github-actions[bot] e2dae1ec7b Merge pull request #182 from komari-monitor/dev
fix: 修复2FA验证逻辑
2025-08-09 08:36:41 +00:00
Akizon77 59d923e168 fix: 修复2FA验证逻辑 2025-08-09 08:35:46 +00:00
github-actions[bot] 51844c27f1 Merge pull request #181 from komari-monitor/dev
feat: 添加CheckOrigin函数并更新WebSocket连接的源检查逻辑
2025-08-09 07:31:50 +00:00
Akizon77 53171affca feat: 添加CheckOrigin函数并更新WebSocket连接的源检查逻辑 2025-08-09 07:30:44 +00:00
Akizon d31d12e59f Merge commit from fork
fix: WS跨域检查
2025-08-09 14:56:45 +08:00
Akizon77 3715f2b3f9 fix: WS跨域检查 2025-08-09 06:52:39 +00:00
github-actions[bot] cc3d54bff4 Merge pull request #180 from komari-monitor/dev
fix: 修复2FA验证逻辑中的错误处理
2025-08-09 06:40:56 +00:00
Akizon77 ef376a88b8 fix: 修复2FA验证逻辑中的错误处理 2025-08-09 06:39:48 +00:00
github-actions[bot] e639238f0e Merge pull request #179 from komari-monitor/dev
feat: 在删除客户端时同时删除连接的客户端和最新报告
2025-08-09 05:54:31 +00:00
Akizon77 cf8241a77a feat: 在删除客户端时同时删除连接的客户端和最新报告 2025-08-09 05:53:09 +00:00
Akizon77 eb87a4fc33 feat: 计算任务的丢包率 2025-08-09 04:21:34 +00:00
github-actions[bot] 0727fb4343 Merge pull request #178 from komari-monitor/dev
feat: 即使没有ping数据,也返回任务信息
2025-08-09 04:13:08 +00:00
Akizon77 d55d759fec feat: 即使没有ping数据,也返回任务信息 2025-08-09 04:12:10 +00:00
github-actions[bot] aad279ce55 Merge pull request #177 from komari-monitor/dev
feat: 增强数据库备份功能
2025-08-09 04:05:07 +00:00
Akizon77 e9b1a6b2cd feat: 增强数据库备份功能 2025-08-09 04:04:04 +00:00
github-actions[bot] bd5a6934e1 Merge pull request #174 from komari-monitor/dev
feat: 添加自动发现
2025-08-07 05:42:15 +00:00
Akizon77 2f04008453 feat: 添加自动发现 2025-08-07 05:15:21 +00:00
Akizon77 fbaa410fd4 fix: 修改任务结果处理中的时间类型 2025-08-06 16:05:08 +00:00
Akizon77 1d4f6f2814 fix: 移除无用的日志记录以简化API密钥验证 2025-08-06 16:02:53 +00:00
github-actions[bot] ad2f5160fb Merge pull request #173 from komari-monitor/dev
feat: 添加API密钥认证
2025-08-06 15:07:02 +00:00
Akizon77 d284405310 忽略debug文件 2025-08-06 15:05:41 +00:00
Akizon77 ff172ccbf4 feat: 添加API密钥认证 2025-08-06 15:05:27 +00:00
github-actions[bot] 14635948b6 Merge pull request #172 from komari-monitor/dev
fix: GET请求添加占位符
2025-08-06 07:47:48 +00:00
Akizon77 012fd1dcc2 fix: GET请求添加占位符 2025-08-06 07:46:52 +00:00
Akizon77 94fa5862b1 fix: 更新自动合并工作流,移除不必要的注释和步骤 2025-08-06 07:43:30 +00:00
Akizon77 e7ec6c71f7 Merge branch 'main' into dev 2025-08-06 07:43:11 +00:00
Akizon 59b187b2a6 fix: 移除主题更新中的下载链接保存逻辑 (#171) 2025-08-06 07:15:52 +00:00
Akizon77 5e7cb0f9f1 fix: 移除主题更新中的下载链接保存逻辑 2025-08-06 07:13:29 +00:00
Akizon77 dfe51b0dc5 Merge branch 'main' into dev 2025-08-06 07:05:37 +00:00
Akizon 7a4dd4bf66 fix: 安装时多一个嵌套的/data (#170)
* fix: 安装时多一个嵌套的/data

* ci: 添加自动合并

* ci: 申请auto-merge权限
2025-08-06 06:36:49 +00:00
Akizon77 0b34beea59 ci: 申请auto-merge权限 2025-08-06 06:35:50 +00:00
Akizon77 90ee9bc731 ci: 添加自动合并 2025-08-06 06:25:39 +00:00
Akizon77 046f38e7c4 fix: 安装时多一个嵌套的/data 2025-08-06 06:07:23 +00:00
Akizon eb733cea45 Merge pull request #168 from dbedu/main
feat: support theme online update.
2025-08-06 12:39:09 +08:00
Akizon eb68f0c629 Merge pull request #167 from komari-monitor/dev
feat: #162 添加Webhook消息发送功能
2025-08-05 15:36:41 +08:00
Akizon77 1cbcac4a00 fix: 修正拼写错误 2025-08-05 07:35:28 +00:00
Akizon77 2d6d58edab feat: #162 添加Webhook消息发送功能 2025-08-05 07:34:05 +00:00
Akizon77 c01fadbb48 feat: 添加option选项 2025-08-05 07:26:11 +00:00
Akizon77 41ef2e239d fix: 更新任务结果保存逻辑以使用模型中的时间类型 2025-08-05 06:44:24 +00:00
Akizon77 7176180343 fix: 修复任务创建时未包含离线客户端 2025-08-05 06:32:45 +00:00
Akizon77 0b9a01c8ac fix: 更新Exec函数以处理离线客户端并保存任务结果 2025-08-05 05:56:00 +00:00
Akizon 50ef62a64a Merge pull request #163 from imlonghao/fix/oidc
fix(oidc): 修正请求 token 阶段的请求包
2025-08-05 13:17:30 +08:00
Akizon 384fa4bbe1 Merge pull request #165 from JohnsonRan/bark
feat: add Bark notification provider
2025-08-04 22:36:42 +08:00
JohnsonRan 10f84a8052 feat: add Bark notification provider 2025-08-04 22:09:49 +08:00
imlonghao 53bd83af8b fix(oidc): 修正请求 token 阶段的请求包 2025-08-04 21:22:20 +08:00
dbedu 53577e21e7 feat: support update theme from github release 2025-08-04 21:01:27 +08:00
dbedu 46c0cb9bfb feat: support update theme from github release 2025-08-04 20:48:10 +08:00
dbedu ac8ee8f9fa feat: add theme online update 2025-08-04 19:56:43 +08:00
Akizon 787e8bdd80 Merge pull request #160 from imlonghao/fix/oidc-redirect-uri
fix(oidc): 增加 redirect_uri
2025-08-03 21:48:47 +08:00
Akizon 83a39e3143 Merge pull request #161 from imlonghao/fix/oidc-reload-config
fix(oidc): 修复配置无法更新的问题
2025-08-03 21:43:21 +08:00
imlonghao 5e2ed79929 fix(oidc): 修复配置无法更新的问题 2025-08-03 21:40:54 +08:00
imlonghao c8f0ba926d fix(oidc): 增加 redirect_uri 2025-08-03 21:22:37 +08:00
Akizon77 17e0e14aef fix: 初次启动时无法加载oauth 2025-08-03 09:54:07 +00:00
Akizon77 731602a80d Merge branch 'main' of https://github.com/komari-monitor/komari 2025-08-03 09:42:55 +00:00
Akizon77 37f0e449de fix: 优化OIDC提供者加载逻辑,添加空配置处理 2025-08-03 09:42:52 +00:00
101 changed files with 8092 additions and 500 deletions

No files matched your search

@@ -0,0 +1,109 @@
name: Auto Merge Dev to Main
on:
pull_request:
types: [opened, synchronize] # No longer need 'closed' trigger
branches:
- main
paths-ignore:
- '**.md'
- 'docs/**'
jobs:
# Job to enable Auto-Merge for the PR
auto-merge:
runs-on: ubuntu-latest
# Only run for open PRs from dev to main
if: github.head_ref == 'dev' && github.base_ref == 'main'
permissions:
contents: write
pull-requests: write
checks: read
statuses: read
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
token: ${{ secrets.GITHUB_TOKEN }}
fetch-depth: 0 # Required for git history
- name: Configure Git
run: |
git config --local user.email "action@github.com"
git config --local user.name "GitHub Action"
- name: Get PR number
id: get-pr
run: |
echo "pr_number=${{ github.event.number }}" >> $GITHUB_OUTPUT
- name: Check for conflicts
id: check-conflicts
run: |
# Ensure local branches are up-to-date
git fetch origin main
git fetch origin dev
# Use merge-base to find the common ancestor and check for conflicts
if git merge-tree $(git merge-base origin/main origin/dev) origin/main origin/dev | grep -q '<<<<<<<'; then
echo "has_conflicts=true" >> $GITHUB_OUTPUT
echo "❌ Merge conflicts detected"
else
echo "has_conflicts=false" >> $GITHUB_OUTPUT
echo "✅ No merge conflicts"
fi
- name: Add conflict comment
if: steps.check-conflicts.outputs.has_conflicts == 'true'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh pr comment ${{ steps.get-pr.outputs.pr_number }} \
--body "❌ **Merge Conflict Detection**
This PR has merge conflicts and cannot be automatically merged. Please resolve conflicts manually and push to the dev branch.
Steps to resolve conflicts:
1. \`git checkout dev\`
2. \`git merge main\`
3. Resolve conflicts
4. \`git commit\`
5. \`git push origin dev\`"
exit 1 # Exit to prevent further steps
- name: Enable auto-merge
if: steps.check-conflicts.outputs.has_conflicts == 'false'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# Enable auto-merge using a merge commit
gh pr merge ${{ steps.get-pr.outputs.pr_number }} \
--auto \
--merge \
--delete-branch=false
# Add success comment
gh pr comment ${{ steps.get-pr.outputs.pr_number }} \
--body "✅ **Auto-merge Enabled**
This PR has no merge conflicts and auto-merge has been enabled. It will be automatically merged to the main branch once all checks pass.
Merge method: Merge commit
Branch retention: dev branch will be preserved"
- name: Summary
run: |
echo "### 🤖 Auto-merge Summary" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "- 📝 Processing PR #${{ steps.get-pr.outputs.pr_number }} (dev → main)" >> $GITHUB_STEP_SUMMARY
if [ "${{ steps.check-conflicts.outputs.has_conflicts }}" == "true" ]; then
echo "- ❌ Merge conflicts detected, manual resolution required" >> $GITHUB_STEP_SUMMARY
else
echo "- ✅ No merge conflicts, auto-merge enabled" >> $GITHUB_STEP_SUMMARY
fi
echo "" >> $GITHUB_STEP_SUMMARY
echo "**PR Link**: https://github.com/${{ github.repository }}/pull/${{ steps.get-pr.outputs.pr_number }}" >> $GITHUB_STEP_SUMMARY
+5 -1
View File
@@ -1,9 +1,13 @@
name: Build Binaries on Main Push
name: Build Binaries on Main Push and PR
on:
push:
branches:
- main
pull_request:
branches:
- main
workflow_dispatch:
jobs:
build-binaries:
+4 -3
View File
@@ -1,8 +1,9 @@
name: Cleanup Old Packages
on:
schedule:
- cron: '0 0 * * 0' # Run every Sunday at midnight
#schedule:
# - cron: '0 0 * * 0' # Run every Sunday at midnight
# 会清理掉正常的镜像
workflow_dispatch: # Allow manual trigger
jobs:
@@ -57,4 +58,4 @@ jobs:
package_version_id: version.id
});
console.log(`Deleted package version ${version.id} with tags: ${version.metadata.container.tags.join(', ') || 'none'}`);
}
}
+3 -3
View File
@@ -1,9 +1,9 @@
name: Publish Docker Image on Main
on:
push:
branches:
- main
#push:
# branches:
# - main
workflow_dispatch:
env:
+2
View File
@@ -96,3 +96,5 @@ jobs:
platforms: linux/amd64,linux/arm64
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
no-cache: true
provenance: false
+4
View File
@@ -7,6 +7,7 @@
*.dll
*.so
*.dylib
__debug*
# local development data
komari.db
@@ -29,3 +30,6 @@ go.work.sum
# env file
.env
# build
komari
+16 -3
View File
@@ -109,9 +109,22 @@ sudo ./install-komari.sh
Issues and Pull Requests are welcome!
## Acknowledgements
- [DreamCloud - 极高性价比解锁直连亚太高防](https://as211392.com/)
- Thanks to myself for being so idle
- All the developers who submitted PRs and created themes
### DreamCloud
[DreamCloud - 极高性价比解锁直连亚太高防](https://as211392.com/)
### 🚀 Sponsored by SharonNetworks
[![Sharon Networks](https://raw.githubusercontent.com/komari-monitor/public/refs/heads/main/images/sharon-networks.webp)](https://sharon.io)
SharonNetworks 为您的业务起飞保驾护航!
亚太数据中心提供顶级的中国优化网络接入 · 低延时&高带宽&提供Tbps级本地清洗高防服务, 为您的业务保驾护航, 为您的客户提供极致体验. 加入社区 [Telegram群组](https://t.me/SharonNetwork) 可参与公益募捐或群内抽奖免费使用
### The open source software community
All the developers who submitted PRs and created themes
## Star History
+8 -1
View File
@@ -10,7 +10,14 @@ import (
func AdminAuthMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
// API key authentication
apiKey := c.GetHeader("Authorization")
if isApiKeyValid(apiKey) {
c.Set("api_key", apiKey)
c.Next()
return
}
// session-based authentication
session, err := c.Cookie("session_token")
if err != nil {
RespondError(c, http.StatusUnauthorized, "Unauthorized.")
+37 -1
View File
@@ -13,6 +13,7 @@ import (
"strconv"
"github.com/komari-monitor/komari/database/config"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/utils"
@@ -36,6 +37,7 @@ var TerminalSessions = make(map[string]*TerminalSession)
func SaveClientReportToDB() error {
lastMinute := time.Now().Add(-time.Minute).Unix()
var records []models.Record
var gpuRecords []models.GPURecord
// 遍历所有客户端记录
for uuid, x := range Records.Items() {
@@ -64,10 +66,16 @@ func SaveClientReportToDB() error {
if len(filtered) > 0 {
r := utils.AverageReport(uuid, time.Now(), filtered, 0.3)
records = append(records, r)
// 使用与其他数据相同的聚合逻辑处理GPU数据
gpuAggregated := utils.AverageGPUReports(uuid, time.Now(), filtered, 0.3)
gpuRecords = append(gpuRecords, gpuAggregated...)
}
}
// 批量插入数据库前去重(client与time共同构成唯一键)
db := dbcore.GetDBInstance()
if len(records) > 0 {
unique := make(map[string]models.Record)
for _, rec := range records {
@@ -78,13 +86,30 @@ func SaveClientReportToDB() error {
for _, rec := range unique {
deduped = append(deduped, rec)
}
db := dbcore.GetDBInstance()
if err := db.Model(&models.Record{}).Create(&deduped).Error; err != nil {
log.Printf("Failed to save records to database: %v", err)
return err
}
}
// 批量插入GPU记录
if len(gpuRecords) > 0 {
// GPU记录也需要去重,防止重复插入
gpuUnique := make(map[string]models.GPURecord)
for _, rec := range gpuRecords {
key := rec.Client + "_" + strconv.Itoa(rec.DeviceIndex) + "_" + strconv.FormatInt(rec.Time.ToTime().Unix(), 10)
gpuUnique[key] = rec
}
var gpuDeduped []models.GPURecord
for _, rec := range gpuUnique {
gpuDeduped = append(gpuDeduped, rec)
}
if err := db.Model(&models.GPURecord{}).Create(&gpuDeduped).Error; err != nil {
log.Printf("Failed to save GPU records to database: %v", err)
return err
}
}
return nil
}
@@ -119,3 +144,14 @@ func GetVersion(c *gin.Context) {
"hash": utils.VersionHash,
})
}
func isApiKeyValid(apiKey string) bool {
cfg, err := config.Get()
if err != nil {
return false
}
if cfg.ApiKey == "" || len(cfg.ApiKey) < 12 {
return false
}
return apiKey == "Bearer "+cfg.ApiKey
}
+8
View File
@@ -18,6 +18,7 @@ var (
"/api/oauth",
"/api/oauth_callback",
"/api/version",
"/api/recent",
"/api/admin", // 由AdminAuthMiddleware处理
"/api/clients/", // 由TokenAuthMiddleware处理
}
@@ -25,6 +26,13 @@ var (
func PrivateSiteMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
// API key authentication
apiKey := c.GetHeader("Authorization")
if isApiKeyValid(apiKey) {
c.Set("api_key", apiKey)
c.Next()
return
}
// 如果是公开的路径,直接放行
for _, path := range publicPaths {
if len(c.Request.URL.Path) >= len(path) && c.Request.URL.Path[:len(path)] == path {
+8
View File
@@ -16,6 +16,14 @@ import (
// TokenAuthMiddleware creates a Gin middleware that validates a token from query parameters or request body.
func TokenAuthMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
// API key authentication
apiKey := c.GetHeader("Authorization")
if isApiKeyValid(apiKey) {
c.Set("api_key", apiKey)
c.Next()
return
}
var token string
// Step 1: Check query parameter for token
+1
View File
@@ -68,6 +68,7 @@ func RemoveClient(c *gin.Context) {
user_uuid, _ := c.Get("uuid")
auditlog.Log(c.ClientIP(), user_uuid.(string), "delete client:"+uuid, "warn")
c.JSON(200, gin.H{"status": "success"})
ws.DeleteConnectedClients(uuid)
ws.DeleteLatestReport(uuid)
}
+93 -14
View File
@@ -14,18 +14,92 @@ import (
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/cmd/flags"
"github.com/komari-monitor/komari/database/dbcore"
)
// copyDatabaseToTemp 安全地复制数据库文件到临时目录
// 对于SQLite数据库,会使用BACKUP命令确保数据一致性
func copyDatabaseToTemp(dbFilePath, tempDir string) (string, error) {
if dbFilePath == "" {
return "", fmt.Errorf("database file path is empty")
}
// 检查数据库文件是否存在
if _, err := os.Stat(dbFilePath); os.IsNotExist(err) {
return "", fmt.Errorf("database file does not exist: %s", dbFilePath)
}
dbFileName := filepath.Base(dbFilePath)
tempDbPath := filepath.Join(tempDir, dbFileName)
// 如果是SQLite数据库,使用SQLite的BACKUP命令来确保一致性
if flags.DatabaseType == "sqlite" || flags.DatabaseType == "" {
db := dbcore.GetDBInstance()
// 获取底层的SQLite连接
sqlDB, err := db.DB()
if err != nil {
return "", fmt.Errorf("failed to get underlying database connection: %v", err)
}
// 使用SQLite的BACKUP API来创建一致的备份
backupSQL := fmt.Sprintf("BACKUP DATABASE main TO '%s'", tempDbPath)
_, err = sqlDB.Exec(backupSQL)
if err != nil {
// 如果BACKUP命令失败,回退到文件复制方法
log.Printf("SQLite BACKUP command failed, falling back to file copy: %v", err)
return copyFileToTemp(dbFilePath, tempDbPath)
}
log.Printf("Database backed up using SQLite BACKUP to: %s", tempDbPath)
return tempDbPath, nil
} else {
// 对于非SQLite数据库,直接复制文件
return copyFileToTemp(dbFilePath, tempDbPath)
}
}
// copyFileToTemp 简单的文件复制到临时目录
func copyFileToTemp(srcPath, destPath string) (string, error) {
src, err := os.Open(srcPath)
if err != nil {
return "", fmt.Errorf("failed to open source file: %v", err)
}
defer src.Close()
dest, err := os.Create(destPath)
if err != nil {
return "", fmt.Errorf("failed to create destination file: %v", err)
}
defer dest.Close()
_, err = io.Copy(dest, src)
if err != nil {
return "", fmt.Errorf("failed to copy file: %v", err)
}
log.Printf("Database file copied to: %s", destPath)
return destPath, nil
}
// DownloadBackup 用于打包 ./data 目录及数据库文件为 zip 并通过 HTTP 下载
func DownloadBackup(c *gin.Context) {
backupFileName := fmt.Sprintf("backup-%s.zip", time.Now().Format("20060102-150405"))
backupFileName := fmt.Sprintf("backup-%d.zip", time.Now().UnixMicro())
c.Writer.Header().Set("Content-Type", "application/zip")
c.Writer.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", backupFileName))
zipWriter := zip.NewWriter(c.Writer)
defer zipWriter.Close()
err := filepath.Walk("./data", func(filePath string, info os.FileInfo, err error) error {
// 创建临时目录用于存放数据库备份
tempDir, err := os.MkdirTemp("", "komari-backup-*")
if err != nil {
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error creating temporary directory: %v", err))
return
}
defer os.RemoveAll(tempDir) // 清理临时目录
err = filepath.Walk("./data", func(filePath string, info os.FileInfo, err error) error {
if err != nil {
return err
}
@@ -96,27 +170,32 @@ func DownloadBackup(c *gin.Context) {
// 如果数据库文件的绝对路径不是以 ./data 目录的绝对路径开头,则单独添加它
if !strings.HasPrefix(dbAbsPath, dataAbsPath) {
dbFileName := filepath.Base(dbFilePath)
dbInfo, err := os.Stat(dbFilePath)
// 使用安全的数据库复制方法
tempDbPath, err := copyDatabaseToTemp(dbFilePath, tempDir)
if err != nil {
if os.IsNotExist(err) {
log.Printf("Database file '%s' does not exist, skipping.\n", dbFilePath)
return // 数据库不存在是正常情况,直接返回
} else {
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error copying database file: %v", err))
return
}
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error stating database file: %v", err))
return
}
if !dbInfo.IsDir() {
file, err := os.Open(dbFilePath)
} else {
// 将临时数据库文件添加到zip
dbInfo, err := os.Stat(tempDbPath)
if err != nil {
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error opening database file: %v", err))
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error stating temp database file: %v", err))
return
}
file, err := os.Open(tempDbPath)
if err != nil {
api.RespondError(c, http.StatusInternalServerError, fmt.Sprintf("Error opening temp database file: %v", err))
return
}
defer file.Close()
writer, err := zipWriter.CreateHeader(&zip.FileHeader{
Name: dbFileName,
Name: filepath.Base(dbFilePath), // 使用原始数据库文件名
Method: zip.Deflate,
Modified: dbInfo.ModTime(),
})
@@ -136,7 +215,7 @@ func DownloadBackup(c *gin.Context) {
}
// 添加备份标记文件
markupContent := "此文件为 Komari 备份标记文件,请勿删除。\nThis is a Komari backup markup file, please do not delete.\n\n备份时间 / Backup Time: " + time.Now().Format("2006-01-02 15:04:05")
markupContent := "此文件为 Komari 备份标记文件,请勿删除。\nThis is a Komari backup markup file, please do not delete.\n\n备份时间 / Backup Time: " + time.Now().Format(time.RFC3339)
markupWriter, err := zipWriter.CreateHeader(&zip.FileHeader{
Name: "komari-backup-markup",
Method: zip.Deflate,
+32 -12
View File
@@ -2,11 +2,13 @@ package admin
import (
"encoding/json"
"time"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/database/auditlog"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/database/tasks"
"github.com/komari-monitor/komari/utils"
"github.com/komari-monitor/komari/ws"
@@ -21,16 +23,25 @@ func Exec(c *gin.Context) {
Clients []string `json:"clients" binding:"required"`
}
var onlineClients []string
var offlineClients []string
if err := c.ShouldBindJSON(&req); err != nil {
api.RespondError(c, 400, "Invalid or missing request body: "+err.Error())
return
}
for uuid := range ws.GetConnectedClients() {
if contain(req.Clients, uuid) {
// for uuid := range ws.GetConnectedClients() {
// if contain(req.Clients, uuid) {
// onlineClients = append(onlineClients, uuid)
// }
// // else {
// // api.RespondError(c, 400, "Client not connected: "+uuid)
// // return
// // }
// }
for _, uuid := range req.Clients {
if client := ws.GetConnectedClients()[uuid]; client != nil {
onlineClients = append(onlineClients, uuid)
} else {
api.RespondError(c, 400, "Client not connected: "+uuid)
return
offlineClients = append(offlineClients, uuid)
}
}
if len(onlineClients) == 0 {
@@ -38,7 +49,7 @@ func Exec(c *gin.Context) {
return
}
taskId := utils.GenerateRandomString(16)
if err := tasks.CreateTask(taskId, onlineClients, req.Command); err != nil {
if err := tasks.CreateTask(taskId, append(onlineClients, offlineClients...), req.Command); err != nil {
api.RespondError(c, 500, "Failed to create task: "+err.Error())
return
}
@@ -55,7 +66,10 @@ func Exec(c *gin.Context) {
payload, _ := json.Marshal(send)
client := ws.GetConnectedClients()[uuid]
if client != nil {
client.WriteMessage(websocket.TextMessage, payload)
if err := client.WriteMessage(websocket.TextMessage, payload); err != nil {
api.RespondError(c, 400, "Client connection is broke: "+uuid)
return
}
} else {
api.RespondError(c, 400, "Client connection is null: "+uuid)
return
@@ -67,12 +81,18 @@ func Exec(c *gin.Context) {
"task_id": taskId,
"clients": onlineClients,
})
}
func contain(clients []string, uuid string) bool {
for _, client := range clients {
if client == uuid {
return true
if len(offlineClients) > 0 {
for _, uuid := range offlineClients {
tasks.SaveTaskResult(taskId, uuid, "Client offline!", -1, models.FromTime(time.Now()))
}
}
return false
}
// func contain(clients []string, uuid string) bool {
// for _, client := range clients {
// if client == uuid {
// return true
// }
// }
// return false
// }
+1
View File
@@ -75,6 +75,7 @@ func SetOidcProvider(c *gin.Context) {
api.RespondError(c, 404, "Provider not found: "+oidcConfig.Name)
return
}
if err := database.SaveOidcConfig(&oidcConfig); err != nil {
api.RespondError(c, 500, "Failed to save OIDC provider configuration: "+err.Error())
return
+5 -2
View File
@@ -6,13 +6,16 @@ import (
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/database/config"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/utils/geoip"
"github.com/komari-monitor/komari/utils/messageSender"
)
func TestSendMessage(c *gin.Context) {
message := "Komari test message."
err := messageSender.SendTextMessage(message, "Komari Test Notification")
err := messageSender.SendEvent(models.EventMessage{
Event: "Test",
Message: "This is a test message from Komari.",
})
if err != nil {
api.RespondError(c, 500, "Failed to send message: "+err.Error())
return
+336
View File
@@ -3,9 +3,11 @@ package admin
import (
"archive/zip"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
@@ -13,6 +15,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/database/config"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
)
@@ -281,3 +284,336 @@ func isValidThemeShort(short string) bool {
return true
}
// downloadThemeFromURL 从URL下载主题文件
func downloadThemeFromURL(url string) ([]byte, error) {
// 发送HTTP GET请求
resp, err := http.Get(url)
if err != nil {
return nil, fmt.Errorf("下载主题文件失败: %v", err)
}
defer resp.Body.Close()
// 检查响应状态码
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("下载主题文件失败,HTTP状态码: %d", resp.StatusCode)
}
// 读取响应内容
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("读取主题文件内容失败: %v", err)
}
// 检查文件大小
if len(data) == 0 {
return nil, errors.New("下载的主题文件为空")
}
return data, nil
}
// getGitHubReleaseDownloadURL 从GitHub API获取最新release的下载链接
// 该函数通过GitHub API获取指定仓库最新release的资源下载链接
// 参考API: https://api.github.com/repos/{owner}/{repo}/releases/latest
// 参数:
// - owner: GitHub仓库所有者
// - repo: GitHub仓库名称
//
// 返回:
// - 最新release的第一个资源的下载链接
// - 错误信息(如果有)
func getGitHubReleaseDownloadURL(owner, repo string) (string, error) {
if owner == "" || repo == "" {
return "", errors.New("GitHub仓库所有者和仓库名称不能为空")
}
// 构建GitHub API URL
// 使用GitHub API获取最新release信息
apiURL := fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/latest", owner, repo)
// 发送HTTP GET请求
resp, err := http.Get(apiURL)
if err != nil {
return "", fmt.Errorf("获取GitHub release信息失败: %v", err)
}
defer resp.Body.Close()
// 检查响应状态码
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("获取GitHub release信息失败,HTTP状态码: %d", resp.StatusCode)
}
// 解析JSON响应
// GitHub API返回的JSON包含assets数组,每个asset包含browser_download_url字段
var releaseInfo struct {
Assets []struct {
BrowserDownloadURL string `json:"browser_download_url"`
} `json:"assets"`
}
if err := json.NewDecoder(resp.Body).Decode(&releaseInfo); err != nil {
return "", fmt.Errorf("解析GitHub API响应失败: %v", err)
}
// 检查是否有可下载的资源
if len(releaseInfo.Assets) == 0 {
return "", errors.New("GitHub release中没有可下载的资源")
}
// 返回第一个资源的下载链接
// 相当于shell命令: curl -s https://api.github.com/repos/owner/repo/releases/latest | jq -r ".assets[0].browser_download_url"
return releaseInfo.Assets[0].BrowserDownloadURL, nil
}
// isGitHubRepoURL 检查URL是否是GitHub仓库地址
// 支持的格式:
// - https://github.com/owner/repo
// - https://github.com/owner/repo.git
// - https://www.github.com/owner/repo
// - http://github.com/owner/repo
// 返回:
// - 是否是GitHub仓库URL
// - 仓库所有者
// - 仓库名称
func isGitHubRepoURL(urlStr string) (bool, string, string) {
if urlStr == "" {
return false, "", ""
}
// 检查URL是否包含github.com
if !strings.Contains(strings.ToLower(urlStr), "github.com") {
return false, "", ""
}
// 解析URL
parsedURL, err := url.Parse(urlStr)
if err != nil {
return false, "", ""
}
// 检查主机名是否是github.com或www.github.com
hostname := strings.ToLower(parsedURL.Host)
if hostname != "github.com" && hostname != "www.github.com" {
return false, "", ""
}
// 解析路径部分,提取owner和repo
// 路径格式应该是 /owner/repo 或 /owner/repo.git
path := strings.TrimPrefix(parsedURL.Path, "/")
parts := strings.Split(path, "/")
if len(parts) < 2 {
return false, "", ""
}
owner := parts[0]
repo := parts[1]
// 如果repo以.git结尾,去掉这个后缀
repo = strings.TrimSuffix(repo, ".git")
return true, owner, repo
}
// UpdateTheme 更新主题
// 支持四种更新方式:
// 1. 使用主题原有URL下载更新
// 2. 提供新的直接下载URL进行更新
// 3. 提供GitHub仓库信息,从最新release下载更新
// 4. 如果主题URL是GitHub仓库地址,自动获取最新release
func UpdateTheme(c *gin.Context) {
var req struct {
Short string `json:"short" binding:"required"` // 主题短名称
URL string `json:"url"` // 新的URL地址(可选)
GitOwner string `json:"git_owner"` // GitHub仓库所有者(可选)
GitRepo string `json:"git_repo"` // GitHub仓库名称(可选)
}
if err := c.ShouldBindJSON(&req); err != nil {
api.RespondError(c, http.StatusBadRequest, "参数错误: "+err.Error())
return
}
// 检查主题是否存在
themeDir := filepath.Join("./data/theme", req.Short)
themeConfigPath := filepath.Join(themeDir, "komari-theme.json")
if _, err := os.Stat(themeConfigPath); os.IsNotExist(err) {
api.RespondError(c, http.StatusNotFound, "主题不存在")
return
}
// 加载现有主题配置
themeInfo, err := loadThemeConfig(themeConfigPath)
if err != nil {
api.RespondError(c, http.StatusInternalServerError, "读取主题配置失败: "+err.Error())
return
}
// 方式1和方式4: 尝试从原始URL下载主题
// 如果原始URL是GitHub仓库地址,则自动获取最新release
var themeData []byte
// 不保存下载链接,更新后由主题覆盖
//var downloadURL string
// var err2 error
if themeInfo.URL != "" {
// 检查原始URL是否是GitHub仓库地址
// 例如: https://github.com/owner/repo
isGitHub, owner, repo := isGitHubRepoURL(themeInfo.URL)
if isGitHub {
// 方式4: 如果原始URL是GitHub仓库地址,自动获取最新release
// 这是本次需求的核心功能:当主题文件中现有的url地址如果是github仓库的路径,则直接引用该url地址去下载最新的release
gitHubURL, err := getGitHubReleaseDownloadURL(owner, repo)
if err == nil {
// 使用获取到的GitHub release下载链接下载主题
themeData, _ = downloadThemeFromURL(gitHubURL)
//if err2 == nil {
// 注意:这里我们保存的是release的下载链接,而不是GitHub仓库地址
// 这样做是为了在下载成功后,将这个具体的release下载链接保存到主题配置中
// 但在下次更新时,我们仍然会检测到这是一个GitHub仓库,并获取最新的release
// downloadURL = gitHubURL
//}
}
} else {
// 原始URL不是GitHub仓库地址,直接尝试下载(方式1)
themeData, _ = downloadThemeFromURL(themeInfo.URL)
//if err2 == nil {
// downloadURL = themeInfo.URL
//}
}
}
// 如果原始URL下载失败,尝试其他方式下载
if themeData == nil || len(themeData) == 0 {
// 方式3: 如果提供了GitHub仓库信息,尝试从GitHub最新release下载
// 这种方式允许用户只需提供owner和repo信息,系统会自动获取最新release的下载链接
if req.GitOwner != "" && req.GitRepo != "" {
// 从GitHub API获取下载链接
// 相当于: DOWNLOAD_URL=$(curl -s https://api.github.com/repos/owner/repo/releases/latest | jq -r ".assets[0].browser_download_url")
gitHubURL, err := getGitHubReleaseDownloadURL(req.GitOwner, req.GitRepo)
if err != nil {
api.RespondError(c, http.StatusBadRequest, "从GitHub获取下载链接失败: "+err.Error())
return
}
// 使用获取到的链接下载主题
themeData, err = downloadThemeFromURL(gitHubURL)
if err != nil {
api.RespondError(c, http.StatusBadRequest, "从GitHub下载主题失败: "+err.Error())
return
}
// 保存下载链接,稍后更新到主题配置中
// downloadURL = gitHubURL
} else if req.URL != "" {
// 方式2: 如果提供了新URL,尝试从新URL下载
// 检查新URL是否是GitHub仓库地址
isGitHub, owner, repo := isGitHubRepoURL(req.URL)
if isGitHub {
// 如果新URL是GitHub仓库地址,获取最新release
// 这里也应用了自动检测GitHub仓库并下载最新release的功能
gitHubURL, err := getGitHubReleaseDownloadURL(owner, repo)
if err != nil {
api.RespondError(c, http.StatusBadRequest, "从GitHub获取下载链接失败: "+err.Error())
return
}
// 使用获取到的链接下载主题
themeData, err = downloadThemeFromURL(gitHubURL)
if err != nil {
api.RespondError(c, http.StatusBadRequest, "从GitHub下载主题失败: "+err.Error())
return
}
// 保存GitHub仓库URL,而不是release下载链接,以便将来可以获取最新版本
// 这是一个重要的设计决策:我们保存的是GitHub仓库URL,而不是具体的release下载链接
// 这样在下次更新时,系统会再次检测到这是GitHub仓库,并自动获取最新的release
// downloadURL = req.URL
} else {
// 新URL不是GitHub仓库地址,直接尝试下载
themeData, err = downloadThemeFromURL(req.URL)
if err != nil {
api.RespondError(c, http.StatusBadRequest, "从新URL下载主题失败: "+err.Error())
return
}
// downloadURL = req.URL
}
}
}
// 如果没有成功下载主题数据
if themeData == nil || len(themeData) == 0 {
api.RespondError(c, http.StatusBadRequest, "无法下载主题,请提供有效的URL或GitHub仓库信息")
return
}
// 到这里,我们已经成功获取了主题数据,可能是通过以下四种方式之一:
// 1. 原始URL直接下载
// 2. 原始URL是GitHub仓库,自动获取最新release下载
// 3. 用户提供的新URL下载
// 4. 用户提供的GitHub仓库信息,获取最新release下载
// 临时文件名
tempFile := filepath.Join(os.TempDir(), "downloaded_theme.zip")
if err := os.WriteFile(tempFile, themeData, 0644); err != nil {
api.RespondError(c, http.StatusInternalServerError, "保存文件失败: "+err.Error())
return
}
defer os.Remove(tempFile)
// 解压ZIP文件并验证
updatedThemeInfo, err := extractAndValidateTheme(tempFile)
if err != nil {
api.RespondError(c, http.StatusBadRequest, err.Error())
return
}
// 如果下载URL与原始URL不同,更新主题配置中的URL
// if downloadURL != themeInfo.URL {
// updatedThemeInfo.URL = downloadURL
// // 更新主题配置文件
// updatedConfigPath := filepath.Join("./data/theme", updatedThemeInfo.Short, "komari-theme.json")
// updatedConfigData, err := json.MarshalIndent(updatedThemeInfo, "", " ")
// if err != nil {
// api.RespondError(c, http.StatusInternalServerError, "生成主题配置失败: "+err.Error())
// return
// }
// if err := os.WriteFile(updatedConfigPath, updatedConfigData, 0644); err != nil {
// api.RespondError(c, http.StatusInternalServerError, "更新主题配置文件失败: "+err.Error())
// return
// }
// }
api.RespondSuccessMessage(c, "主题更新成功", updatedThemeInfo)
}
func UpdateThemeSettings(c *gin.Context) {
theme := c.Query("theme")
if theme == "" || theme == "default" {
api.RespondError(c, http.StatusBadRequest, "主题名称不能为空或不能是默认主题")
return
}
var req map[string]any
err := c.ShouldBindJSON(&req)
if err != nil {
api.RespondError(c, http.StatusBadRequest, "参数错误: "+err.Error())
return
}
db := dbcore.GetDBInstance()
data, err := json.Marshal(&req)
if err != nil {
api.RespondError(c, http.StatusInternalServerError, "生成主题配置失败: "+err.Error())
return
}
var themeCfg models.ThemeConfiguration
db.Where("short = ?", theme).
Assign(models.ThemeConfiguration{Short: theme, Data: string(data)}).
FirstOrCreate(&themeCfg)
api.RespondSuccess(c, nil)
}
+41
View File
@@ -0,0 +1,41 @@
package client
import (
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/database/clients"
"github.com/komari-monitor/komari/database/config"
"github.com/komari-monitor/komari/utils"
)
func RegisterClient(c *gin.Context) {
auth := c.GetHeader("Authorization")
if auth == "" {
api.RespondError(c, 403, "Invalid AutoDiscovery Key")
return
}
cfg, err := config.Get()
if err != nil {
api.RespondError(c, 500, "Failed to get configuration: "+err.Error())
return
}
if cfg.AutoDiscoveryKey == "" ||
len(cfg.AutoDiscoveryKey) < 12 ||
"Bearer "+cfg.AutoDiscoveryKey != auth {
api.RespondError(c, 403, "Invalid AutoDiscovery Key")
return
}
name := c.Query("name")
if name == "" {
name = utils.GenerateRandomString(8)
}
name = "Auto-" + name
uuid, token, err := clients.CreateClientWithName(name)
if err != nil {
api.RespondError(c, 500, "Failed to create client: "+err.Error())
return
}
api.RespondSuccess(c, gin.H{"uuid": uuid, "token": token})
}
+76 -55
View File
@@ -22,6 +22,12 @@ import (
"github.com/patrickmn/go-cache"
)
const (
// 如果超过这个时间没有收到任何消息,则认为连接已死
// 因为目前server没有存agent的信息上报间隔。只有写一个默认的
readWait = 11 * time.Second
)
func UploadReport(c *gin.Context) {
bodyBytes, err := io.ReadAll(c.Request.Body)
if err != nil {
@@ -51,7 +57,7 @@ func UploadReport(c *gin.Context) {
}
// Update report with method and token
ws.GetLatestReport()[report.UUID] = &report
ws.SetLatestReport(report.UUID, &report)
c.Request.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) // Restore the body for further use
c.JSON(200, gin.H{"status": "success"})
@@ -65,7 +71,7 @@ func WebSocketReport(c *gin.Context) {
}
upgrader := websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
return true
return true // 被控
},
}
// Upgrade the HTTP connection to a WebSocket connection
@@ -109,72 +115,87 @@ func WebSocketReport(c *gin.Context) {
return
}
// 只允许一个客户端的连接
if _, exists := ws.GetConnectedClients()[uuid]; exists {
conn.WriteJSON(gin.H{"status": "error", "error": "Token already in use"})
return
// 接受新连接,并处理旧连接
if oldConn, exists := ws.GetConnectedClients()[uuid]; exists {
log.Printf("Client %s is reconnecting. Closing the old connection.", uuid)
// 强制关闭旧连接。这将导致旧连接的 ReadMessage() 循环出错退出。
go oldConn.Close()
}
ws.SetConnectedClients(uuid, conn)
go notifier.OnlineNotification(uuid)
log.Printf("Client %s is reconnect success, connID: %d", uuid, conn.ID)
go notifier.OnlineNotification(uuid, conn.ID)
defer func() {
ws.DeleteConnectedClients(uuid)
notifier.OfflineNotification(uuid)
ws.DeleteClientConditionally(uuid, conn)
notifier.OfflineNotification(uuid, conn.ID)
}()
// 首先处理第一次ws conn收到的消息
processMessage(conn, message, uuid)
for {
conn.SetReadDeadline(time.Now().Add(readWait))
_, message, err := conn.ReadMessage()
if err != nil {
break
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
log.Printf("Client %s connection error: %v", uuid, err)
}
break // 任何读错误(包括超时)都意味着连接已断开,退出循环
}
processMessage(conn, message, uuid)
}
}
type MessageType struct {
Type string `json:"type"`
}
var msgType MessageType
err = json.Unmarshal(message, &msgType)
// 将消息处理逻辑提取到一个函数中,方便复用
func processMessage(conn *ws.SafeConn, message []byte, uuid string) {
type MessageType struct {
Type string `json:"type"`
}
var msgType MessageType
err := json.Unmarshal(message, &msgType)
if err != nil {
conn.WriteJSON(gin.H{"status": "error", "error": "Invalid JSON"})
return
}
switch msgType.Type {
case "", "report":
report := common.Report{}
err = json.Unmarshal(message, &report)
if err != nil {
conn.WriteJSON(gin.H{"status": "error", "error": "Invalid JSON"})
continue
conn.WriteJSON(gin.H{"status": "error", "error": "Invalid report format"})
return
}
switch msgType.Type {
case "", "report":
report := common.Report{}
err = json.Unmarshal(message, &report)
if err != nil {
conn.WriteJSON(gin.H{"status": "error", "error": "Invalid report format"})
continue
}
report.UpdatedAt = time.Now()
err = SaveClientReport(uuid, report)
if err != nil {
conn.WriteJSON(gin.H{"status": "error", "error": fmt.Sprintf("%v", err)})
continue
}
ws.SetLatestReport(uuid, &report)
case "ping_result":
var reqBody struct {
PingTaskID uint `json:"task_id"`
PingResult int `json:"value"`
PingType string `json:"ping_type"`
FinishedAt time.Time `json:"finished_at"`
}
err = json.Unmarshal(message, &reqBody)
if err != nil {
conn.WriteJSON(gin.H{"status": "error", "error": "Invalid ping result format"})
continue
}
pingResult := models.PingRecord{
Client: uuid,
TaskId: reqBody.PingTaskID,
Value: reqBody.PingResult,
Time: models.FromTime(reqBody.FinishedAt),
}
tasks.SavePingRecord(pingResult)
default:
log.Printf("Unknown message type: %s", msgType.Type)
conn.WriteJSON(gin.H{"status": "error", "error": "Unknown message type"})
report.UpdatedAt = time.Now()
err = SaveClientReport(uuid, report)
if err != nil {
conn.WriteJSON(gin.H{"status": "error", "error": fmt.Sprintf("%v", err)})
return
}
ws.SetLatestReport(uuid, &report)
case "ping_result":
var reqBody struct {
PingTaskID uint `json:"task_id"`
PingResult int `json:"value"`
PingType string `json:"ping_type"`
FinishedAt time.Time `json:"finished_at"`
}
err = json.Unmarshal(message, &reqBody)
if err != nil {
conn.WriteJSON(gin.H{"status": "error", "error": "Invalid ping result format"})
return
}
pingResult := models.PingRecord{
Client: uuid,
TaskId: reqBody.PingTaskID,
Value: reqBody.PingResult,
Time: models.FromTime(reqBody.FinishedAt),
}
tasks.SavePingRecord(pingResult)
default:
log.Printf("Unknown message type: %s", msgType.Type)
conn.WriteJSON(gin.H{"status": "error", "error": "Unknown message type"})
}
}
+2 -1
View File
@@ -5,6 +5,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/database/clients"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/database/tasks"
)
@@ -26,7 +27,7 @@ func TaskResult(c *gin.Context) {
return
}
if err := tasks.SaveTaskResult(req.TaskId, clientId, req.Result, req.ExitCode, req.FinishedAt); err != nil {
if err := tasks.SaveTaskResult(req.TaskId, clientId, req.Result, req.ExitCode, models.FromTime(req.FinishedAt)); err != nil {
c.JSON(500, gin.H{"status": "error", "message": "Failed to update task result: " + err.Error()})
return
}
+1 -1
View File
@@ -22,7 +22,7 @@ func EstablishConnection(c *gin.Context) {
}
upgrader := websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
return true // TODO: 检查源站
return true // 被控
},
}
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
+34
View File
@@ -10,6 +10,18 @@ import (
"github.com/gin-gonic/gin"
)
func getClientIPType(ip net.IP) int {
// 0:ipv4 1:ipv6 -1:错误的输入
if ip == nil {
return -1
}
if ip.To4() == nil {
return 1
} else {
return 0
}
}
func UploadBasicInfo(c *gin.Context) {
var cbi = map[string]interface{}{}
if err := c.ShouldBindJSON(&cbi); err != nil {
@@ -26,6 +38,28 @@ func UploadBasicInfo(c *gin.Context) {
cbi["uuid"] = uuid
if (func() bool {
if v4, ok := cbi["ipv4"].(string); !ok || v4 == "" {
if v6, ok := cbi["ipv6"].(string); !ok || v6 == "" {
return true
}
}
return false
})() {
ipStr := c.ClientIP()
ip := net.ParseIP(ipStr)
ipType := getClientIPType(ip)
switch ipType {
case 0:
cbi["ipv4"] = ip
case 1:
cbi["ipv6"] = ip
default:
break
}
}
if cfg, err := config.Get(); err == nil && cfg.GeoIpEnabled {
if ipv4, ok := cbi["ipv4"].(string); ok && ipv4 != "" {
ip4 := net.ParseIP(ipv4)
+227
View File
@@ -0,0 +1,227 @@
package jsonRpc
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"reflect"
"strings"
"sync"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/database/accounts"
"github.com/komari-monitor/komari/database/clients"
"github.com/komari-monitor/komari/database/config"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/utils/rpc"
"github.com/komari-monitor/komari/ws"
)
// Json Rpc2 over websocket, /api/rpc2
func OnRpcRequest(c *gin.Context) {
cfg, _ := config.Get()
// GET -> WebSocket
if c.Request.Method == http.MethodGet {
_conn, err := ws.UpgradeRequest(c, func(r *http.Request) bool {
if cfg.AllowCors {
return true
}
return ws.CheckOrigin(r)
})
conn := ws.NewSafeConn(_conn)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "Failed to upgrade to WebSocket"})
return
}
permissionGroup := detectPermissionGroup(c, cfg)
meta := buildContextMeta(c, permissionGroup)
defer conn.Close()
for {
var req rpc.JsonRpcRequest
err := conn.ReadJSON(&req)
if err != nil {
// JSON 解析
var se *json.SyntaxError
var ute *json.UnmarshalTypeError
if errors.As(err, &se) || errors.As(err, &ute) {
conn.WriteJSON(rpc.ErrorResponse(nil, rpc.InvalidRequest, "bad request: "+err.Error(), nil))
continue
}
// 其它视为连接/IO 错误
break
}
if jerr := req.Validate(); jerr != nil {
conn.WriteJSON(jerr.ResponseWithID(req.ID))
continue
}
dispatchByPermissionWithMeta(conn, permissionGroup, meta, &req)
}
return
}
// 否则按 HTTP POST JSON-RPC 处理 (支持单个或批量)
if c.Request.Method != http.MethodPost {
c.JSON(http.StatusMethodNotAllowed, gin.H{"error": "method not allowed"})
return
}
body, err := io.ReadAll(c.Request.Body)
if err != nil {
c.JSON(http.StatusBadRequest, rpc.ErrorResponse(nil, rpc.ParseError, "read body error", err.Error()))
return
}
requests, jerr := rpc.ParseRequests(body)
if jerr != nil {
c.JSON(http.StatusBadRequest, jerr.Response())
return
}
permissionGroup := detectPermissionGroup(c, cfg)
meta := buildContextMeta(c, permissionGroup)
// 批量
responses := make([]*rpc.JsonRpcResponse, 0, len(requests))
for _, rreq := range requests {
// 权限与命名空间
fc := strings.Split(rreq.Method, ":")
if len(fc) == 1 {
fc[0] = "common"
}
allowed := false
switch fc[0] {
case "guest", "", "rpc", "common":
allowed = true
case "client":
if permissionGroup == "client" || permissionGroup == "admin" {
allowed = true
}
case "admin":
if permissionGroup == "admin" {
allowed = true
}
default:
responses = append(responses, rpc.ErrorResponse(rreq.ID, 401, "Unauthorized", nil))
continue
}
if !allowed {
responses = append(responses, rpc.ErrorResponse(rreq.ID, 401, "Unauthorized", nil))
continue
}
responses = append(responses, rpc.CallWithContext(rpc.NewContextWithMeta(context.TODO(), meta), rreq.ID, rreq.Method, rreq.Params))
}
// 单个请求直接对象,批量请求数组 (符合 JSON-RPC 2.0)
if len(responses) == 1 {
c.JSON(http.StatusOK, responses[0])
} else {
c.JSON(http.StatusOK, responses)
}
}
// detectPermissionGroup 提取权限分组,与原逻辑保持一致
func detectPermissionGroup(c *gin.Context, cfg models.Config) string {
permissionGroup := "guest"
token := c.Query("Authorization")
if _, err := clients.GetClientUUIDByToken(token); err == nil {
permissionGroup = "client"
}
if session_token, _ := c.Cookie("session_token"); session_token != "" {
if _, err := accounts.GetUserBySession(session_token); err == nil {
permissionGroup = "admin"
}
}
apiKey := c.GetHeader("Authorization")
if apiKey == "Bearer "+cfg.ApiKey {
permissionGroup = "admin"
}
return permissionGroup
}
// buildContextMeta 从 gin.Context 构建 *rpc.ContextMeta
func buildContextMeta(c *gin.Context, permissionGroup string) *rpc.ContextMeta {
meta := &rpc.ContextMeta{Permission: permissionGroup}
// 提取客户端 token (query Authorization / Header Authorization Bearer token)
token := c.Query("Authorization")
if token == "" {
// 兼容 header Bearer <token>
hAuth := c.GetHeader("Authorization")
if strings.HasPrefix(hAuth, "Bearer ") {
token = strings.TrimPrefix(hAuth, "Bearer ")
}
}
if token != "" {
if uuid, err := clients.GetClientUUIDByToken(token); err == nil {
meta.ClientToken = token
meta.ClientUUID = uuid
}
}
// 提取用户 (session cookie)
if session_token, _ := c.Cookie("session_token"); session_token != "" {
if user, err := accounts.GetUserBySession(session_token); err == nil {
meta.User = &user
meta.UserUUID = user.UUID
}
}
meta.RemoteIP = c.ClientIP()
meta.UserAgent = c.GetHeader("User-Agent")
return meta
}
// dispatchByPermissionWithMeta 与原函数类似,但会携带 meta 上下文给 handler
func dispatchByPermissionWithMeta(conn *ws.SafeConn, permissionGroup string, meta *rpc.ContextMeta, req *rpc.JsonRpcRequest) {
fc := strings.Split(req.Method, ":")
if len(fc) == 1 {
fc[0] = "common"
}
ctx := rpc.NewContextWithMeta(context.TODO(), meta)
switch fc[0] {
case "guest", "", "rpc", "common":
go conn.WriteJSON(rpc.CallWithContext(ctx, req.ID, req.Method, req.Params))
case "client":
if permissionGroup == "client" || permissionGroup == "admin" {
go conn.WriteJSON(rpc.CallWithContext(ctx, req.ID, req.Method, req.Params))
} else {
conn.WriteJSON(rpc.ErrorResponse(req.ID, 401, "Unauthorized", nil))
}
case "admin":
if permissionGroup == "admin" {
go conn.WriteJSON(rpc.CallWithContext(ctx, req.ID, req.Method, req.Params))
} else {
conn.WriteJSON(rpc.ErrorResponse(req.ID, 401, "Unauthorized", nil))
}
default:
conn.WriteJSON(rpc.ErrorResponse(req.ID, 401, "Unauthorized", nil))
}
}
// registry holds method handlers keyed by "namespace:MethodName"
var (
registryMu sync.RWMutex
registry = make(map[string]reflect.Value)
)
// Register 将以分组common注册
func Register(name string, cb rpc.Handler) error {
return RegisterWithGroupAndMeta(name, "common", cb, &rpc.MethodMeta{
Name: name,
Summary: "This method does not provide a summary",
Description: "This method does not provide a description",
})
}
// RegisterWithGroup 将回调按分组注册。
// 传入函数: Register("ns", someFunc) -> 注册 ns:SomeFunc
// group 为空时使用默认分组 "common"。
func RegisterWithGroupAndMeta(name, group string, cb rpc.Handler, meta *rpc.MethodMeta) error {
return rpc.RegisterWithMeta(group+":"+name, cb, meta)
}
// GetRegisteredKeys 返回已注册的方法 key 列表 (调试用)。
func GetRegisteredKeys() []string {
registryMu.RLock()
defer registryMu.RUnlock()
keys := make([]string, 0, len(registry))
for k := range registry {
keys = append(keys, k)
}
return keys
}
+545
View File
@@ -0,0 +1,545 @@
package jsonRpc
import (
"context"
"fmt"
"math"
"sort"
"time"
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/common"
"github.com/komari-monitor/komari/database"
"github.com/komari-monitor/komari/database/clients"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/database/tasks"
"github.com/komari-monitor/komari/utils"
"github.com/komari-monitor/komari/utils/rpc"
"github.com/komari-monitor/komari/ws"
cache "github.com/patrickmn/go-cache"
)
// pingstats:<uuid>
var pingStatsCache = cache.New(1*time.Minute, 2*time.Minute)
type pingStat struct {
Name string `json:"name"`
Latest int `json:"latest"`
Avg int `json:"avg"`
Tail float64 `json:"tail"` // (P99-P50)/P50
Loss float64 `json:"loss"` // 丢包率 %
Min int `json:"min"`
Max int `json:"max"`
}
// getPingStatsForNode 计算并缓存节点最近 1 小时 ping 统计
func getPingStatsForNode(uuid string, pingTasks []models.PingTask) map[string]pingStat {
if uuid == "" {
return map[string]pingStat{}
}
key := fmt.Sprintf("pingstats:%s", uuid)
if v, ok := pingStatsCache.Get(key); ok {
if m, ok2 := v.(map[string]pingStat); ok2 {
return m
}
}
// 筛选属于该节点的任务
assigned := make([]models.PingTask, 0, 4)
for _, t := range pingTasks {
for _, c := range t.Clients {
if c == uuid {
assigned = append(assigned, t)
break
}
}
}
if len(assigned) == 0 {
empty := map[string]pingStat{}
pingStatsCache.Set(key, empty, cache.DefaultExpiration)
return empty
}
end := time.Now()
start := end.Add(-1 * time.Hour)
recs, err := tasks.GetPingRecords(uuid, -1, start, end)
if err != nil || len(recs) == 0 {
empty := map[string]pingStat{}
pingStatsCache.Set(key, empty, cache.DefaultExpiration)
return empty
}
grouped := make(map[uint][]models.PingRecord)
for _, r := range recs {
for _, t := range assigned {
if r.TaskId == t.Id {
grouped[r.TaskId] = append(grouped[r.TaskId], r)
break
}
}
}
result := make(map[string]pingStat, len(grouped))
for _, t := range assigned {
records := grouped[t.Id]
if len(records) == 0 {
continue
}
latest := -1
var latestTs time.Time
values := make([]int, 0, len(records))
sum := 0
valid := 0
total := 0
lossCount := 0
minLat := 0
maxLat := 0
for _, r := range records {
total++
if r.Value < 0 { // 丢包
lossCount++
continue
}
values = append(values, r.Value)
sum += r.Value
valid++
if minLat == 0 || r.Value < minLat {
minLat = r.Value
}
if r.Value > maxLat {
maxLat = r.Value
}
ts := r.Time.ToTime()
if latestTs.IsZero() || ts.After(latestTs) {
latestTs = ts
latest = r.Value
}
}
avg := 0
if valid > 0 {
avg = sum / valid
}
p50, p99 := 0, 0
if len(values) > 0 {
sort.Ints(values)
percentile := func(vals []int, pct float64) int {
if len(vals) == 0 {
return 0
}
if pct <= 0 {
return vals[0]
}
if pct >= 1 {
return vals[len(vals)-1]
}
pos := (float64(len(vals) - 1)) * pct
lo := int(math.Floor(pos))
hi := int(math.Ceil(pos))
if lo == hi {
return vals[lo]
}
frac := pos - float64(lo)
v := float64(vals[lo]) + (float64(vals[hi])-float64(vals[lo]))*frac
return int(math.Round(v))
}
p50 = percentile(values, 0.50)
p99 = percentile(values, 0.99)
}
tail := 0.0
if p50 > 0 && p99 >= p50 {
tail = float64(p99-p50) / float64(p50)
}
lossRate := 0.0
if total > 0 {
lossRate = float64(lossCount) / float64(total) * 100
}
result[fmt.Sprintf("%d", t.Id)] = pingStat{
Name: t.Name,
Latest: latest,
Avg: avg,
Tail: tail,
Loss: lossRate,
Min: minLat,
Max: maxLat,
}
}
pingStatsCache.Set(key, result, cache.DefaultExpiration)
return result
}
func init() {
RegisterWithGroupAndMeta("getNodes", "common",
func(ctx context.Context, req *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
return getNodes(ctx, req)
},
&rpc.MethodMeta{
Name: "getNodes",
Summary: "Get all nodes",
Params: []rpc.ParamMeta{
{
Name: "uuid",
Description: "Specify the UUID of the node",
Required: false,
Type: "string",
},
},
Returns: "Client | { [uuid]: Client }",
},
)
RegisterWithGroupAndMeta("getNodesLatestStatus", "common",
func(ctx context.Context, req *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
return getNodesLatestStatus(ctx, req)
},
&rpc.MethodMeta{
Name: "getNodesLatestStatus",
Summary: "Get latest status reports (single node or map).",
Params: []rpc.ParamMeta{
{
Name: "uuid",
Description: "Specify the UUID of the node (optional)",
Required: false,
Type: "string",
},
{
Name: "uuids",
Description: "Specify multiple UUIDs (array) to get subset (ignored if uuid provided)",
Required: false,
Type: "string[]",
},
},
Returns: "Record | { [uuid]: Record }",
},
)
Register("getMe", func(ctx context.Context, req *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
return getMe(ctx, req)
})
Register("getPublicInfo", getPublicInfo)
Register("getVersion", getVersion)
Register("getNodeRecentStatus", getNodeRecentStatus)
}
func getNodes(ctx context.Context, req *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
var params struct {
UUID string `json:"uuid"`
}
req.BindParams(&params)
cinfo, err := clients.GetAllClientBasicInfo()
if err != nil {
return nil, rpc.MakeError(rpc.InternalError, "Failed to get client info", cinfo)
}
meta := rpc.MetaFromContext(ctx)
if meta.Permission != "admin" {
// 过滤 Hidden 节点并隐藏敏感字段
filtered := make([]models.Client, 0, len(cinfo))
for _, node := range cinfo {
if node.Hidden { // 非 admin 不显示隐藏节点
continue
}
node.IPv4 = ""
node.IPv6 = ""
node.Remark = ""
node.Version = ""
node.Token = ""
filtered = append(filtered, node)
}
cinfo = filtered
}
if params.UUID != "" {
for _, node := range cinfo {
if node.UUID == params.UUID {
return node, nil
}
}
return nil, rpc.MakeError(rpc.InvalidParams, "Node not found", params.UUID)
}
nodesMap := make(map[string]models.Client, len(cinfo))
for _, node := range cinfo {
nodesMap[node.UUID] = node
}
return nodesMap, nil
}
func getPublicInfo(_ context.Context, _ *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
info, err := database.GetPublicInfo()
if err != nil {
return nil, rpc.MakeError(rpc.InternalError, "Failed to get public info", err.Error())
}
return info, nil
}
func getNodesLatestStatus(ctx context.Context, req *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
var params struct {
UUID string `json:"uuid"`
UUIDs []string `json:"uuids"`
}
req.BindParams(&params)
meta := rpc.MetaFromContext(ctx)
latest := ws.GetLatestReport() // map[string]*common.Report (copy)
connected := ws.GetConnectedClients()
onlineSet := make(map[string]bool, len(connected))
for uuid := range connected {
onlineSet[uuid] = true
}
// Hidden 过滤
if meta.Permission != "admin" {
cinfo, err := clients.GetAllClientBasicInfo()
if err != nil {
return nil, rpc.MakeError(rpc.InternalError, "Failed to get client info", err.Error())
}
hidden := make(map[string]bool, len(cinfo))
for _, c := range cinfo {
if c.Hidden {
hidden[c.UUID] = true
}
}
for uuid := range latest {
if hidden[uuid] {
delete(latest, uuid)
}
}
}
// 如果指定 uuid 但找不到,直接返回 not found
if params.UUID != "" {
if _, ok := latest[params.UUID]; !ok {
return nil, rpc.MakeError(rpc.InvalidParams, "Node not found", params.UUID)
}
}
type recordLike struct {
Client string `json:"client"`
Time models.LocalTime `json:"time"`
Cpu float32 `json:"cpu"`
Gpu float32 `json:"gpu"`
Ram int64 `json:"ram"`
RamTotal int64 `json:"ram_total"`
Swap int64 `json:"swap"`
SwapTotal int64 `json:"swap_total"`
Load float32 `json:"load"`
Load5 float32 `json:"load5"`
Load15 float32 `json:"load15"`
Temp float32 `json:"temp"`
Disk int64 `json:"disk"`
DiskTotal int64 `json:"disk_total"`
NetIn int64 `json:"net_in"`
NetOut int64 `json:"net_out"`
NetTotalUp int64 `json:"net_total_up"`
NetTotalDown int64 `json:"net_total_down"`
Process int `json:"process"`
Connections int `json:"connections"`
ConnectionsUdp int `json:"connections_udp"`
Online bool `json:"online"`
Uptime int64 `json:"uptime"`
Ping map[string]pingStat `json:"ping"`
}
respMap := make(map[string]recordLike, len(latest))
// 预取所有 ping 任务
pingTasks, _ := tasks.GetAllPingTasks()
appendOne := func(uuid string, rep *common.Report) {
if rep == nil {
return
}
stats := getPingStatsForNode(uuid, pingTasks)
rl := recordLike{
Client: uuid,
Time: models.FromTime(rep.UpdatedAt),
Cpu: float32(rep.CPU.Usage),
Gpu: 0,
Ram: rep.Ram.Used,
RamTotal: rep.Ram.Total,
Swap: rep.Swap.Used,
SwapTotal: rep.Swap.Total,
Load: float32(rep.Load.Load1),
Load5: float32(rep.Load.Load5),
Load15: float32(rep.Load.Load15),
Temp: 0,
Disk: rep.Disk.Used,
DiskTotal: rep.Disk.Total,
NetIn: rep.Network.Down,
NetOut: rep.Network.Up,
NetTotalUp: rep.Network.TotalUp,
NetTotalDown: rep.Network.TotalDown,
Process: rep.Process,
Connections: rep.Connections.TCP + rep.Connections.UDP,
ConnectionsUdp: rep.Connections.UDP,
Online: onlineSet[uuid],
Uptime: rep.Uptime,
Ping: stats,
}
respMap[uuid] = rl
}
// 选择逻辑
if params.UUID != "" { // 单个
appendOne(params.UUID, latest[params.UUID])
return respMap[params.UUID], nil
}
selected := map[string]bool{}
if len(params.UUIDs) > 0 {
for _, id := range params.UUIDs {
selected[id] = true
}
for uuid, rep := range latest {
if selected[uuid] {
appendOne(uuid, rep)
}
}
return respMap, nil
}
for uuid, rep := range latest {
appendOne(uuid, rep)
}
return respMap, nil
}
func getMe(ctx context.Context, _ *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
var resp struct {
TwoFAEnabled bool `json:"2fa_enabled"`
LoggedIn bool `json:"logged_in"`
SSOId string `json:"sso_id"`
SSOType string `json:"sso_type"`
Username string `json:"username"`
UUID string `json:"uuid"`
}
meta := rpc.MetaFromContext(ctx)
switch meta.Permission {
case "admin":
resp.TwoFAEnabled = meta.User.TwoFactor != ""
resp.LoggedIn = true
resp.SSOId = meta.User.SSOID
resp.SSOType = meta.User.SSOType
resp.Username = meta.User.Username
resp.UUID = meta.User.UUID
return resp, nil
case "guest":
resp.LoggedIn = false
return resp, nil
case "client":
resp.LoggedIn = true
resp.SSOId = "client"
resp.SSOType = "client"
resp.Username = "client"
resp.UUID = meta.ClientToken
client, err := clients.GetClientUUIDByToken(meta.ClientToken)
if err != nil {
resp.UUID = client
}
return resp, nil
default:
return nil, rpc.MakeError(rpc.InvalidParams, "Invalid user role", meta.Permission)
}
}
func getVersion(_ context.Context, _ *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
return struct {
Version string `json:"version"`
Hash string `json:"hash"`
}{
Version: utils.CurrentVersion,
Hash: utils.VersionHash,
}, nil
}
func getNodeRecentStatus(ctx context.Context, req *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
var params struct {
UUID string `json:"uuid"`
}
req.BindParams(&params)
if params.UUID == "" {
return nil, rpc.MakeError(rpc.InvalidParams, "UUID is required", params)
}
meta := rpc.MetaFromContext(ctx)
// 登录状态检查
isLogin := false
if meta.Permission == "admin" {
isLogin = true
}
// 仅在未登录时需要 Hidden 信息做过滤
hiddenMap := map[string]bool{}
if !isLogin {
var hiddenClients []models.Client
db := dbcore.GetDBInstance()
_ = db.Select("uuid").Where("hidden = ?", true).Find(&hiddenClients).Error
for _, cli := range hiddenClients {
hiddenMap[cli.UUID] = true
}
if hiddenMap[params.UUID] {
return nil, rpc.MakeError(rpc.InvalidParams, "UUID is required", params) //防止未登录用户获取隐藏客户端数据
}
}
raw, _ := api.Records.Get(params.UUID)
reports, _ := raw.([]common.Report)
// 扁平化为 { count, records: [] }
type flatRecord struct {
Client string `json:"client"`
Time models.LocalTime `json:"time"`
Cpu float32 `json:"cpu"`
Gpu float32 `json:"gpu"`
Ram int64 `json:"ram"`
RamTotal int64 `json:"ram_total"`
Swap int64 `json:"swap"`
SwapTotal int64 `json:"swap_total"`
Load float32 `json:"load"`
Temp float32 `json:"temp"`
Disk int64 `json:"disk"`
DiskTotal int64 `json:"disk_total"`
NetIn int64 `json:"net_in"`
NetOut int64 `json:"net_out"`
NetTotalUp int64 `json:"net_total_up"`
NetTotalDown int64 `json:"net_total_down"`
Process int `json:"process"`
Connections int `json:"connections"`
ConnectionsUdp int `json:"connections_udp"`
}
resp := struct {
Count int `json:"count"`
Records []flatRecord `json:"records"`
}{
Count: 0,
Records: []flatRecord{},
}
if len(reports) == 0 {
return resp, nil
}
resp.Records = make([]flatRecord, 0, len(reports))
for _, r := range reports {
fr := flatRecord{
Client: params.UUID,
Time: models.FromTime(r.UpdatedAt),
Cpu: float32(r.CPU.Usage),
Gpu: 0,
Ram: r.Ram.Used,
RamTotal: r.Ram.Total,
Swap: r.Swap.Used,
SwapTotal: r.Swap.Total,
Load: float32(r.Load.Load1),
Temp: 0,
Disk: r.Disk.Used,
DiskTotal: r.Disk.Total,
NetIn: r.Network.Down,
NetOut: r.Network.Up,
NetTotalUp: r.Network.TotalUp,
NetTotalDown: r.Network.TotalDown,
Process: r.Process,
Connections: r.Connections.TCP + r.Connections.UDP,
ConnectionsUdp: r.Connections.UDP,
}
resp.Records = append(resp.Records, fr)
}
resp.Count = len(resp.Records)
return resp, nil
}
+806
View File
@@ -0,0 +1,806 @@
package jsonRpc
import (
"context"
"fmt"
"math"
"sort"
"time"
"github.com/komari-monitor/komari/database/clients"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
recordsdb "github.com/komari-monitor/komari/database/records"
"github.com/komari-monitor/komari/database/tasks"
"github.com/komari-monitor/komari/utils/rpc"
)
func init() {
Register("getRecords", getRecords)
}
func getRecords(ctx context.Context, req *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
meta := rpc.MetaFromContext(ctx)
var params struct {
Type string `json:"type"` // "load" | "ping"; default "load"
UUID string `json:"uuid"` // client uuid; empty = all clients
Hours int `json:"hours"` // time window in hours; default 1 if start/end not provided
Start string `json:"start"` // RFC3339 start time (optional)
End string `json:"end"` // RFC3339 end time (optional)
LoadType string `json:"load_type"` // for type=load: cpu|gpu|ram|swap|load|temp|disk|network|process|connections|all
TaskID int `json:"task_id"` // for type=ping: optional task id; -1 or omitted means all
MaxCount int `json:"maxCount"` // max number of points; -1 unlimited; default 4000
}
req.BindParams(&params)
// defaults
if params.Type == "" {
params.Type = "load"
}
// parse time window
var startTime, endTime time.Time
if params.Start != "" || params.End != "" {
// allow partial: missing end means now
var err error
if params.End == "" {
endTime = time.Now()
} else {
endTime, err = time.Parse(time.RFC3339, params.End)
if err != nil {
return nil, rpc.MakeError(rpc.InvalidParams, "Invalid end time", params.End)
}
}
if params.Start == "" {
// default to 1 hour before end
startTime = endTime.Add(-1 * time.Hour)
} else {
start, err := time.Parse(time.RFC3339, params.Start)
if err != nil {
return nil, rpc.MakeError(rpc.InvalidParams, "Invalid start time", params.Start)
}
startTime = start
}
} else {
hours := params.Hours
if hours <= 0 {
hours = 1 // default 1 hour
}
endTime = time.Now()
startTime = endTime.Add(-time.Duration(hours) * time.Hour)
}
// Hidden filtering for non-admin
isAdmin := meta.Permission == "admin"
hidden := map[string]bool{}
if !isAdmin {
cinfo, err := clients.GetAllClientBasicInfo()
if err != nil {
return nil, rpc.MakeError(rpc.InternalError, "Failed to get client info", err.Error())
}
for _, c := range cinfo {
if c.Hidden {
hidden[c.UUID] = true
}
}
if params.UUID != "" && hidden[params.UUID] {
return nil, rpc.MakeError(rpc.InvalidParams, "UUID not found", params.UUID)
}
}
switch params.Type {
case "load":
// fetch load records
recs, err := getLoadRecordsCombined(params.UUID, startTime, endTime)
if err != nil {
return nil, rpc.MakeError(rpc.InternalError, "Failed to fetch records", err.Error())
}
// hidden filter on non-admin
if !isAdmin {
filtered := recs[:0]
for _, r := range recs {
if hidden[r.Client] {
continue
}
filtered = append(filtered, r)
}
recs = filtered
}
// resolve maxCount default for load
maxCount := params.MaxCount
if maxCount == 0 {
maxCount = 4000
}
// optional load_type filtering -> group by client
if params.LoadType != "" && params.LoadType != "all" {
items := filterRecordsByLoadType(recs, params.LoadType)
grouped := make(map[string][]flatRecord)
for _, it := range items {
grouped[it.Client] = append(grouped[it.Client], it)
}
// sort and count
total := 0
groupsMeta := make([]struct {
name string
length int
}, 0, len(grouped))
for name := range grouped {
arr := grouped[name]
sort.Slice(arr, func(i, j int) bool { return arr[i].Time.ToTime().Before(arr[j].Time.ToTime()) })
grouped[name] = arr
l := len(arr)
total += l
groupsMeta = append(groupsMeta, struct {
name string
length int
}{name: name, length: l})
}
// downsample across all clients proportionally
if maxCount != -1 && total > maxCount {
targets := allocateTargets(groupsMeta, maxCount)
total = 0
for name, k := range targets {
grouped[name] = downsampleFlatRecords(grouped[name], k)
total += len(grouped[name])
}
}
return struct {
Count int `json:"count"`
Records map[string][]flatRecord `json:"records"`
LoadType string `json:"load_type"`
From models.LocalTime `json:"from"`
To models.LocalTime `json:"to"`
}{Count: total, Records: grouped, LoadType: params.LoadType, From: models.FromTime(startTime), To: models.FromTime(endTime)}, nil
}
// default: return full records, grouped by client
grouped := make(map[string][]models.Record)
for _, r := range recs {
grouped[r.Client] = append(grouped[r.Client], r)
}
total := 0
groupsMeta := make([]struct {
name string
length int
}, 0, len(grouped))
for name := range grouped {
arr := grouped[name]
sort.Slice(arr, func(i, j int) bool { return arr[i].Time.ToTime().Before(arr[j].Time.ToTime()) })
grouped[name] = arr
l := len(arr)
total += l
groupsMeta = append(groupsMeta, struct {
name string
length int
}{name: name, length: l})
}
if maxCount != -1 && total > maxCount {
targets := allocateTargets(groupsMeta, maxCount)
total = 0
for name, k := range targets {
grouped[name] = downsampleModelRecords(grouped[name], k)
total += len(grouped[name])
}
}
return struct {
Count int `json:"count"`
Records map[string][]models.Record `json:"records"`
From models.LocalTime `json:"from"`
To models.LocalTime `json:"to"`
}{Count: total, Records: grouped, From: models.FromTime(startTime), To: models.FromTime(endTime)}, nil
case "ping":
taskId := params.TaskID
if taskId == 0 {
taskId = -1
}
recs, err := tasks.GetPingRecords(params.UUID, taskId, startTime, endTime)
if err != nil {
return nil, rpc.MakeError(rpc.InternalError, "Failed to fetch ping records", err.Error())
}
// hidden filter
if !isAdmin {
filtered := recs[:0]
for _, r := range recs {
if r.Client != "" && hidden[r.Client] {
continue
}
filtered = append(filtered, r)
}
recs = filtered
}
type RecordsResp struct {
TaskId uint `json:"task_id,omitempty"`
Time models.LocalTime `json:"time"`
Value int `json:"value"`
Client string `json:"client,omitempty"`
}
type ClientBasicInfo struct {
Client string `json:"client"`
Loss float64 `json:"loss"`
Min int `json:"min"`
Max int `json:"max"`
}
type Resp struct {
Count int `json:"count"`
BasicInfo []ClientBasicInfo `json:"basic_info,omitempty"`
Records []RecordsResp `json:"records"`
Tasks []map[string]any `json:"tasks"`
From models.LocalTime `json:"from"`
To models.LocalTime `json:"to"`
}
response := &Resp{Count: 0, Records: []RecordsResp{}, From: models.FromTime(startTime), To: models.FromTime(endTime)}
// stats per client
clientStats := make(map[string]struct {
total int
loss int
min int
max int
})
for _, r := range recs {
rr := RecordsResp{
TaskId: r.TaskId,
Time: r.Time,
Value: r.Value,
Client: r.Client,
}
st := clientStats[r.Client]
st.total++
if r.Value < 0 {
st.loss++
} else {
if st.min == 0 || r.Value < st.min {
st.min = r.Value
}
if r.Value > st.max {
st.max = r.Value
}
}
clientStats[r.Client] = st
response.Records = append(response.Records, rr)
}
if len(clientStats) > 0 {
response.BasicInfo = make([]ClientBasicInfo, 0, len(clientStats))
for client, st := range clientStats {
if client != "" && !isAdmin && hidden[client] {
continue
}
loss := float64(0)
if st.total > 0 {
loss = float64(st.loss) / float64(st.total) * 100
}
response.BasicInfo = append(response.BasicInfo, ClientBasicInfo{
Client: client,
Loss: loss,
Min: st.min,
Max: st.max,
})
}
}
// tasks summary (always included for ping type; do not expose target field)
pingTasks, err := tasks.GetAllPingTasks()
if err != nil {
return nil, rpc.MakeError(rpc.InternalError, "Failed to fetch ping tasks", err.Error())
}
toList := make([]map[string]any, 0, len(pingTasks))
for _, t := range pingTasks {
if taskId != -1 && t.Id != uint(taskId) {
continue
}
if params.UUID != "" { // ensure task assigned to specific client when filtering by uuid
assigned := false
for _, c := range t.Clients {
if c == params.UUID {
assigned = true
break
}
}
if !assigned {
continue
}
}
total := 0
lossCount := 0
minLat := 0
maxLat := 0
sum := 0
valid := 0
latestVal := -1
var latestTs time.Time
// 收集该任务的所有有效(非丢包)延迟值以计算百分位
latencies := make([]int, 0, 64)
for _, r := range recs {
if r.TaskId != t.Id {
continue
}
if params.UUID != "" && r.Client != params.UUID {
continue
}
total++
if r.Value < 0 { // 丢包
lossCount++
continue
}
valid++
sum += r.Value
latencies = append(latencies, r.Value)
if minLat == 0 || r.Value < minLat {
minLat = r.Value
}
if r.Value > maxLat {
maxLat = r.Value
}
// track latest non-negative value
ts := r.Time.ToTime()
if latestTs.IsZero() || ts.After(latestTs) {
latestTs = ts
latestVal = r.Value
}
}
// 计算 P50 / P99
p50 := 0
p99 := 0
if len(latencies) > 0 {
sort.Ints(latencies)
getPercentileInt := func(values []int, percentile float64) int {
if len(values) == 0 {
return 0
}
if percentile <= 0 {
return values[0]
}
if percentile >= 1 {
return values[len(values)-1]
}
pos := (float64(len(values) - 1)) * percentile
lo := int(math.Floor(pos))
hi := int(math.Ceil(pos))
if lo == hi {
return values[lo]
}
frac := pos - float64(lo)
v := float64(values[lo]) + (float64(values[hi])-float64(values[lo]))*frac
return int(math.Round(v))
}
p50 = getPercentileInt(latencies, 0.50)
p99 = getPercentileInt(latencies, 0.99)
}
ratio := 0.0
if p50 > 0 && p99 >= p50 {
jitterMs := float64(p99 - p50)
adjustedBase := math.Max(math.Min(float64(p50), 50.0), 10.0)
ratio = jitterMs / adjustedBase
}
lossRate := 0.0
if total > 0 {
lossRate = float64(lossCount) / float64(total) * 100
}
avg := 0
if valid > 0 {
avg = sum / valid
}
info := map[string]any{
"id": t.Id,
"name": t.Name,
"type": t.Type,
"interval": t.Interval,
"loss": lossRate,
"min": minLat,
"max": maxLat,
"avg": avg,
"latest": latestVal,
"total": total,
"p50": p50,
"p99": p99,
"p99_p50_ratio": ratio,
}
if params.UUID == "" && taskId != -1 { // retain existing behavior of exposing clients only when filtering by task
info["clients"] = t.Clients
}
toList = append(toList, info)
}
response.Tasks = toList
// apply maxCount for ping
maxCount := params.MaxCount
if maxCount == 0 {
maxCount = 4000
}
if maxCount != -1 && len(response.Records) > maxCount {
// group records by TaskId for proportional downsampling
taskGroups := make(map[uint][]RecordsResp)
for _, r := range response.Records {
taskGroups[r.TaskId] = append(taskGroups[r.TaskId], r)
}
// sort each group by time
for taskId := range taskGroups {
sort.Slice(taskGroups[taskId], func(i, j int) bool {
return taskGroups[taskId][i].Time.ToTime().Before(taskGroups[taskId][j].Time.ToTime())
})
}
// calculate proportional allocation for each task
type taskMeta struct {
taskId uint
length int
}
groupsMeta := make([]taskMeta, 0, len(taskGroups))
for taskId, records := range taskGroups {
groupsMeta = append(groupsMeta, taskMeta{
taskId: taskId,
length: len(records),
})
}
// use existing allocateTargets function (create compatible struct)
strTargets := allocateTargets(
func() []struct {
name string
length int
} {
result := make([]struct {
name string
length int
}, len(groupsMeta))
for i, meta := range groupsMeta {
result[i] = struct {
name string
length int
}{name: fmt.Sprintf("%d", meta.taskId), length: meta.length}
}
return result
}(),
maxCount,
)
// downsample each task group
downsampledRecords := make([]RecordsResp, 0, maxCount)
samplePingRecords := func(in []RecordsResp, k int) []RecordsResp {
n := len(in)
if k <= 0 || n == 0 {
return []RecordsResp{}
}
if k >= n {
return in
}
out := make([]RecordsResp, 0, k)
if k == 1 {
out = append(out, in[n-1])
return out
}
for i := 0; i < k; i++ {
idx := int(math.Round(float64(i) * float64(n-1) / float64(k-1)))
if idx < 0 {
idx = 0
} else if idx >= n {
idx = n - 1
}
out = append(out, in[idx])
}
return out
}
for taskId, records := range taskGroups {
targetKey := fmt.Sprintf("%d", taskId)
targetCount := strTargets[targetKey]
sampled := samplePingRecords(records, targetCount)
downsampledRecords = append(downsampledRecords, sampled...)
}
response.Records = downsampledRecords
}
response.Count = len(response.Records)
// sort by time asc
sort.Slice(response.Records, func(i, j int) bool {
return response.Records[i].Time.ToTime().Before(response.Records[j].Time.ToTime())
})
return response, nil
default:
return nil, rpc.MakeError(rpc.InvalidParams, "Invalid type, expected 'load' or 'ping'", params.Type)
}
}
// ---------- helpers for load records ----------
// getLoadRecordsCombined fetches records for a client or all clients within a time range,
// combining recent short-term table and long-term table with 15-min grouping for recent part.
func getLoadRecordsCombined(uuid string, start, end time.Time) ([]models.Record, error) {
// prefer the existing function when uuid provided
if uuid != "" {
return recordsdb.GetRecordsByClientAndTime(uuid, start, end)
}
db := dbcore.GetDBInstance()
fourHoursAgo := time.Now().Add(-4*time.Hour - time.Minute)
var recent []models.Record
recentStart := start
if end.After(fourHoursAgo) {
if recentStart.Before(fourHoursAgo) {
recentStart = fourHoursAgo
}
_ = db.Table("records").Where("time >= ? AND time <= ?", recentStart, end).Order("time ASC").Find(&recent).Error
}
var longTerm []models.Record
_ = db.Table("records_long_term").Where("time >= ? AND time <= ?", start, end).Order("time ASC").Find(&longTerm).Error
// if no long term, return all recent
if len(longTerm) == 0 {
return recent, nil
}
// group recent by client+15min, keep latest in bucket
type key struct {
c string
slot string
}
grouped := make(map[key]models.Record)
for _, rec := range recent {
k := key{c: rec.Client, slot: rec.Time.ToTime().Truncate(15 * time.Minute).Format(time.RFC3339)}
if old, ok := grouped[k]; !ok || rec.Time.ToTime().After(old.Time.ToTime()) {
grouped[k] = rec
}
}
flat := make([]models.Record, 0, len(grouped))
for _, rec := range grouped {
flat = append(flat, rec)
}
sort.Slice(flat, func(i, j int) bool { return flat[i].Time.ToTime().Before(flat[j].Time.ToTime()) })
flat = append(flat, longTerm...)
return flat, nil
}
// ---------- downsampling helpers ----------
// allocateTargets splits maxTotal across groups proportionally to their lengths.
func allocateTargets(groups []struct {
name string
length int
}, maxTotal int) map[string]int {
result := make(map[string]int, len(groups))
if maxTotal < 0 {
for _, g := range groups {
result[g.name] = g.length
}
return result
}
total := 0
for _, g := range groups {
total += g.length
}
if total <= maxTotal {
for _, g := range groups {
result[g.name] = g.length
}
return result
}
// initial allocation based on proportion
type rem struct {
idx int
frac float64
}
remainders := make([]rem, 0, len(groups))
sumTargets := 0
for i, g := range groups {
if g.length <= 0 {
result[g.name] = 0
continue
}
raw := float64(g.length) * float64(maxTotal) / float64(total)
t := int(math.Floor(raw))
if t > g.length {
t = g.length
}
result[groups[i].name] = t
sumTargets += t
remainders = append(remainders, rem{i, raw - float64(t)})
}
// distribute remaining by largest fractional parts
if sumTargets < maxTotal {
need := maxTotal - sumTargets
sort.Slice(remainders, func(i, j int) bool { return remainders[i].frac > remainders[j].frac })
for _, r := range remainders {
if need == 0 {
break
}
g := groups[r.idx]
cur := result[g.name]
if cur < g.length {
result[g.name] = cur + 1
need--
}
}
// if still left, second pass round-robin
if need > 0 {
for need > 0 {
for _, g := range groups {
if need == 0 {
break
}
if result[g.name] < g.length {
result[g.name]++
need--
}
}
if need > 0 {
break
}
}
}
} else if sumTargets > maxTotal {
over := sumTargets - maxTotal
sort.Slice(remainders, func(i, j int) bool { return remainders[i].frac < remainders[j].frac })
for _, r := range remainders {
if over == 0 {
break
}
g := groups[r.idx]
if result[g.name] > 0 {
result[g.name]--
over--
}
}
if over > 0 {
for over > 0 {
for _, g := range groups {
if over == 0 {
break
}
if result[g.name] > 0 {
result[g.name]--
over--
}
}
if over > 0 {
break
}
}
}
}
return result
}
func downsampleModelRecords(in []models.Record, k int) []models.Record {
n := len(in)
if k <= 0 || n == 0 {
return []models.Record{}
}
if k >= n {
return in
}
out := make([]models.Record, 0, k)
if k == 1 {
out = append(out, in[n-1])
return out
}
for i := 0; i < k; i++ {
idx := int(math.Round(float64(i) * float64(n-1) / float64(k-1)))
if idx < 0 {
idx = 0
} else if idx >= n {
idx = n - 1
}
out = append(out, in[idx])
}
return out
}
func downsampleFlatRecords(in []flatRecord, k int) []flatRecord {
n := len(in)
if k <= 0 || n == 0 {
return []flatRecord{}
}
if k >= n {
return in
}
out := make([]flatRecord, 0, k)
if k == 1 {
out = append(out, in[n-1])
return out
}
for i := 0; i < k; i++ {
idx := int(math.Round(float64(i) * float64(n-1) / float64(k-1)))
if idx < 0 {
idx = 0
} else if idx >= n {
idx = n - 1
}
out = append(out, in[idx])
}
return out
}
// flatRecord is a projection used when load_type is specified.
type flatRecord struct {
Client string `json:"client"`
Time models.LocalTime `json:"time"`
Cpu *float32 `json:"cpu,omitempty"`
Gpu *float32 `json:"gpu,omitempty"`
Ram *int64 `json:"ram,omitempty"`
RamTotal *int64 `json:"ram_total,omitempty"`
Swap *int64 `json:"swap,omitempty"`
SwapTotal *int64 `json:"swap_total,omitempty"`
Load *float32 `json:"load,omitempty"`
Temp *float32 `json:"temp,omitempty"`
Disk *int64 `json:"disk,omitempty"`
DiskTotal *int64 `json:"disk_total,omitempty"`
NetIn *int64 `json:"net_in,omitempty"`
NetOut *int64 `json:"net_out,omitempty"`
NetTotalUp *int64 `json:"net_total_up,omitempty"`
NetTotalDown *int64 `json:"net_total_down,omitempty"`
Process *int `json:"process,omitempty"`
Connections *int `json:"connections,omitempty"`
ConnectionsUdp *int `json:"connections_udp,omitempty"`
Uptime *int64 `json:"uptime,omitempty"`
}
func filterRecordsByLoadType(recs []models.Record, loadType string) []flatRecord {
out := make([]flatRecord, 0, len(recs))
for _, r := range recs {
fr := flatRecord{Client: r.Client, Time: r.Time}
// always include uptime when present
//if r.Uptime != 0 {
// v := r.Uptime
// fr.Uptime = &v
//}
switch loadType {
case "cpu":
v := r.Cpu
fr.Cpu = &v
case "gpu":
v := r.Gpu
fr.Gpu = &v
case "ram":
v := r.Ram
fr.Ram = &v
vt := r.RamTotal
fr.RamTotal = &vt
case "swap":
v := r.Swap
fr.Swap = &v
vt := r.SwapTotal
fr.SwapTotal = &vt
case "load":
v := r.Load
fr.Load = &v
case "temp":
v := r.Temp
fr.Temp = &v
case "disk":
v := r.Disk
fr.Disk = &v
vt := r.DiskTotal
fr.DiskTotal = &vt
case "network":
vi := r.NetIn
vo := r.NetOut
vtu := r.NetTotalUp
vtd := r.NetTotalDown
fr.NetIn = &vi
fr.NetOut = &vo
fr.NetTotalUp = &vtu
fr.NetTotalDown = &vtd
case "process":
v := r.Process
fr.Process = &v
case "connections":
v := r.Connections
fr.Connections = &v
vu := r.ConnectionsUdp
fr.ConnectionsUdp = &vu
default:
// unknown type: fallback to all fields as a full record would be returned elsewhere
v := r.Cpu
fr.Cpu = &v
}
out = append(out, fr)
}
return out
}
+29
View File
@@ -2,14 +2,43 @@ package api
import (
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/database/accounts"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
)
func GetClientRecentRecords(c *gin.Context) {
uuid := c.Param("uuid")
if uuid == "" {
RespondError(c, 400, "UUID is required")
return
}
// 登录状态检查
isLogin := false
session, _ := c.Cookie("session_token")
_, err := accounts.GetUserBySession(session)
if err == nil {
isLogin = true
}
// 仅在未登录时需要 Hidden 信息做过滤
hiddenMap := map[string]bool{}
if !isLogin {
var hiddenClients []models.Client
db := dbcore.GetDBInstance()
_ = db.Select("uuid").Where("hidden = ?", true).Find(&hiddenClients).Error
for _, cli := range hiddenClients {
hiddenMap[cli.UUID] = true
}
if hiddenMap[uuid] {
RespondError(c, 400, "UUID is required") //防止未登录用户获取隐藏客户端数据
return
}
}
records, _ := Records.Get(uuid)
RespondSuccess(c, records)
}
+9 -7
View File
@@ -48,13 +48,15 @@ func Login(c *gin.Context) {
}
// 2FA
user, _ := accounts.GetUserByUUID(uuid)
if user.TwoFactor != "" && data.TwoFa == "" {
RespondError(c, http.StatusUnauthorized, "2FA code is required")
return
}
if ok, err := accounts.Verify2Fa(uuid, data.TwoFa); err != nil && ok {
RespondError(c, http.StatusUnauthorized, "Invalid 2FA code")
return
if user.TwoFactor != "" { // 开启了2FA
if data.TwoFa == "" {
RespondError(c, http.StatusUnauthorized, "2FA code is required")
return
}
if ok, err := accounts.Verify2Fa(uuid, data.TwoFa); err != nil || !ok {
RespondError(c, http.StatusUnauthorized, "Invalid 2FA code")
return
}
}
// Create session
session, err := accounts.CreateSession(uuid, 2592000, c.Request.UserAgent(), c.ClientIP(), "password")
+17 -4
View File
@@ -2,6 +2,7 @@ package api
import (
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/database/accounts"
"github.com/komari-monitor/komari/database/clients"
)
@@ -11,16 +12,28 @@ func GetNodesInformation(c *gin.Context) {
RespondError(c, 500, "Failed to retrieve client information: "+err.Error())
return
}
isLogin := false
session, _ := c.Cookie("session_token")
_, err = accounts.GetUserBySession(session)
if err == nil {
isLogin = true
}
count := len(clientList)
// 公开信息不展示IP地址,私有备注不展示
for i := 0; i < count; i++ {
// 过滤掉 Hidden 的客户端,并清理需要隐藏的字段
j := 0
for i := 0; i < len(clientList); i++ {
if clientList[i].Hidden && !isLogin { // 不返回 Hidden 客户端
continue
}
clientList[i].IPv4 = ""
clientList[i].IPv6 = ""
clientList[i].Remark = ""
clientList[i].Remark = "" // 私有备注不展示
clientList[i].Version = ""
clientList[i].Token = ""
clientList[j] = clientList[i]
j++
}
clientList = clientList[:j]
RespondSuccess(c, clientList)
}
+24 -6
View File
@@ -2,11 +2,13 @@ package api
import (
"fmt"
"slices"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/database/accounts"
"github.com/komari-monitor/komari/database/auditlog"
"github.com/komari-monitor/komari/database/config"
"github.com/komari-monitor/komari/utils"
"github.com/komari-monitor/komari/utils/oauth"
)
@@ -18,7 +20,7 @@ func OAuth(c *gin.Context) {
return
}
authURL, state := oauth.CurrentProvider().GetAuthorizationURL()
authURL, state := oauth.CurrentProvider().GetAuthorizationURL(utils.GetCallbackURL(c))
c.SetCookie("oauth_state", state, 3600, "/", "", false, true)
@@ -27,12 +29,28 @@ func OAuth(c *gin.Context) {
// /api/oauth_callback
func OAuthCallback(c *gin.Context) {
// 验证state防止CSRF攻击
state, _ := c.Cookie("oauth_state")
c.SetCookie("oauth_state", "", -1, "/", "", false, true)
if state == "" || state != c.Query("state") {
c.JSON(400, gin.H{"status": "error", "error": "Invalid state"})
return
// 获取当前OAuth提供商名称
providerName := oauth.CurrentProvider().GetName()
providersSkipStateCheck := []string{"qq"}
if slices.Contains(providersSkipStateCheck, providerName) {
// 对于QQ登录,由于是通过QQ聚合登录平台中转,state可能会不匹配
// 但我们仍然需要验证state的存在性(不能是空的)
if state == "" {
c.JSON(400, gin.H{"status": "error", "error": "Invalid state"})
return
}
} else {
// 对于其他提供商,严格验证state匹配
if state == "" || state != c.Query("state") {
c.JSON(400, gin.H{"status": "error", "error": "Invalid state"})
return
}
}
queries := make(map[string]string)
@@ -41,7 +59,7 @@ func OAuthCallback(c *gin.Context) {
queries[key] = values[0]
}
}
oidcUser, err := oauth.CurrentProvider().OnCallback(c.Request.Context(), state, queries)
oidcUser, err := oauth.CurrentProvider().OnCallback(c, state, queries, utils.GetCallbackURL(c))
if err != nil {
c.JSON(500, gin.H{"status": "error", "error": "Failed to get user info: " + err.Error()})
return
@@ -68,7 +86,7 @@ func OAuthCallback(c *gin.Context) {
return
}
auditlog.Log(c.ClientIP(), user.UUID, "bound external account (OAuth)"+fmt.Sprintf(",sso_id: %s", sso_id), "login")
c.Redirect(302, "/manage")
c.Redirect(302, "/admin")
return
}
+5 -20
View File
@@ -2,29 +2,14 @@ package api
import (
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/database/config"
"github.com/komari-monitor/komari/database"
)
func GetPublicSettings(c *gin.Context) {
cst, err := config.Get()
if err != nil {
RespondError(c, 500, err.Error())
p, e := database.GetPublicInfo()
if e != nil {
RespondError(c, 500, e.Error())
return
}
// Return public settings including CORS
RespondSuccess(c, gin.H{
"sitename": cst.Sitename,
"description": cst.Description,
"custom_head": cst.CustomHead,
"custom_body": cst.CustomBody,
"oauth_enable": cst.OAuthEnabled,
"oauth_provider": cst.OAuthProvider,
"disable_password_login": cst.DisablePasswordLogin,
"allow_cors": cst.AllowCors,
"record_enabled": cst.RecordEnabled,
"record_preserve_time": cst.RecordPreserveTime,
"ping_record_preserve_time": cst.PingRecordPreserveTime,
"private_site": cst.PrivateSite,
})
RespondSuccess(c, p)
}
+374 -54
View File
@@ -1,17 +1,47 @@
package record
import (
"slices"
"strconv"
"time"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/database/accounts"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
records "github.com/komari-monitor/komari/database/records"
"github.com/komari-monitor/komari/database/tasks"
)
func GetRecordsByUUID(c *gin.Context) {
uuid := c.Query("uuid")
loadType := c.Query("load_type")
// 登录状态检查
isLogin := false
session, _ := c.Cookie("session_token")
_, err := accounts.GetUserBySession(session)
if err == nil {
isLogin = true
}
// 仅在未登录时需要 Hidden 信息做过滤
hiddenMap := map[string]bool{}
if !isLogin {
var hiddenClients []models.Client
db := dbcore.GetDBInstance()
_ = db.Select("uuid").Where("hidden = ?", true).Find(&hiddenClients).Error
for _, cli := range hiddenClients {
hiddenMap[cli.UUID] = true
}
if hiddenMap[uuid] {
api.RespondError(c, 400, "UUID is required") //防止未登录用户获取隐藏客户端数据
return
}
}
hours := c.Query("hours")
if uuid == "" {
api.RespondError(c, 400, "UUID is required")
@@ -27,101 +57,391 @@ func GetRecordsByUUID(c *gin.Context) {
return
}
records, err := records.GetRecordsByClientAndTime(uuid, time.Now().Add(-time.Duration(hoursInt)*time.Hour), time.Now())
// 验证 load_type 参数
validLoadTypes := map[string]bool{
"cpu": true, "ram": true, "swap": true,
"load": true, "temp": true, "disk": true, "network": true,
"process": true, "connections": true, "all": true, "": true,
}
if !validLoadTypes[loadType] {
api.RespondError(c, 400, "Invalid load_type parameter")
return
}
clientRecords, err := records.GetRecordsByClientAndTime(uuid, time.Now().Add(-time.Duration(hoursInt)*time.Hour), time.Now())
if err != nil {
api.RespondError(c, 500, "Failed to fetch records: "+err.Error())
return
}
api.RespondSuccess(c, gin.H{
"records": records,
"count": len(records),
})
// 准备基本响应
response := gin.H{
"records": clientRecords,
"count": len(clientRecords),
}
// 如果有load_type过滤,应用过滤
if loadType != "" && loadType != "all" {
filteredRecords := filterRecordsByLoadType(clientRecords, loadType)
response = gin.H{
"records": filteredRecords,
"count": len(filteredRecords),
"load_type": loadType,
}
}
// 自动检测是否有GPU数据并附加到响应中
if loadType == "" || loadType == "all" || loadType == "gpu" {
gpuRecords, err := records.GetGPURecordsByClientAndTime(uuid, time.Now().Add(-time.Duration(hoursInt)*time.Hour), time.Now())
if err == nil && len(gpuRecords) > 0 {
// 按设备索引分组数据,构建简化的设备结构
gpuDevices := make(map[string]interface{})
for _, record := range gpuRecords {
deviceKey := strconv.Itoa(record.DeviceIndex)
// 如果设备还没有初始化,创建设备信息
if gpuDevices[deviceKey] == nil {
gpuDevices[deviceKey] = gin.H{
"device_index": record.DeviceIndex,
"device_name": record.DeviceName,
"records": []models.GPURecord{},
}
}
// 添加记录到设备
device := gpuDevices[deviceKey].(gin.H)
records := device["records"].([]models.GPURecord)
device["records"] = append(records, record)
gpuDevices[deviceKey] = device
}
// 添加优化后的GPU数据结构到响应
response["gpu_devices"] = gpuDevices
response["has_gpu_data"] = true
} else {
response["has_gpu_data"] = false
}
}
api.RespondSuccess(c, response)
}
// GET query: uuid string, hours int
// filterRecordsByLoadType 根据 load_type 过滤记录,只返回相关字段
func filterRecordsByLoadType(records []models.Record, loadType string) []gin.H {
filteredRecords := make([]gin.H, 0, len(records))
for _, r := range records {
record := gin.H{
"client": r.Client,
"time": r.Time,
}
switch loadType {
case "cpu":
record["cpu"] = r.Cpu
case "gpu":
record["gpu"] = r.Gpu
case "ram":
record["ram"] = r.Ram
record["ram_total"] = r.RamTotal
if r.RamTotal > 0 {
record["ram_percent"] = float32(r.Ram) / float32(r.RamTotal) * 100
}
case "swap":
record["swap"] = r.Swap
record["swap_total"] = r.SwapTotal
if r.SwapTotal > 0 {
record["swap_percent"] = float32(r.Swap) / float32(r.SwapTotal) * 100
}
case "load":
record["load"] = r.Load
case "temp":
record["temp"] = r.Temp
case "disk":
record["disk"] = r.Disk
record["disk_total"] = r.DiskTotal
if r.DiskTotal > 0 {
record["disk_percent"] = float32(r.Disk) / float32(r.DiskTotal) * 100
}
case "network":
record["net_in"] = r.NetIn
record["net_out"] = r.NetOut
record["net_total_up"] = r.NetTotalUp
record["net_total_down"] = r.NetTotalDown
case "process":
record["process"] = r.Process
case "connections":
record["connections"] = r.Connections
record["connections_udp"] = r.ConnectionsUdp
record["connections_tcp"] = r.Connections - r.ConnectionsUdp
}
filteredRecords = append(filteredRecords, record)
}
return filteredRecords
}
// GET query: uuid string, task_id int, hours int
// 支持三种查询模式:
// 1. 仅 uuid - 获取该客户端的所有 ping 任务记录
// 2. 仅 task_id - 获取该任务的所有客户端记录
// 3. uuid + task_id - 获取特定客户端在特定任务的记录
func GetPingRecords(c *gin.Context) {
uuid := c.Query("uuid")
hours := c.Query("hours")
type RecordsResp struct {
TaskId uint `json:"task_id"`
Time string `json:"time"`
Value int `json:"value"`
}
type Resp struct {
Count int `json:"count"`
Records []RecordsResp `json:"records"`
Tasks []gin.H `json:"tasks,omitempty"` // 任务列表
}
taskIdStr := c.Query("task_id")
if uuid == "" {
api.RespondError(c, 400, "UUID is required")
// 必须提供 uuid 或 task_id 其中至少一个
if uuid == "" && taskIdStr == "" {
api.RespondError(c, 400, "UUID or task_id is required")
return
}
// 登录状态检查
isLogin := false
session, _ := c.Cookie("session_token")
_, err := accounts.GetUserBySession(session)
if err == nil {
isLogin = true
}
type RecordsResp struct {
TaskId uint `json:"task_id,omitempty"`
Time string `json:"time"`
Value int `json:"value"`
Client string `json:"client,omitempty"`
}
type ClientBasicInfo struct {
Client string `json:"client"`
Loss float64 `json:"loss"`
Min int `json:"min"`
Max int `json:"max"`
}
type Resp struct {
Count int `json:"count"`
BasicInfo []ClientBasicInfo `json:"basic_info,omitempty"`
Records []RecordsResp `json:"records"`
Tasks []gin.H `json:"tasks,omitempty"`
}
var records []models.PingRecord
hiddenMap := map[string]bool{}
response := &Resp{
Count: 0,
Records: []RecordsResp{},
}
// 仅在未登录时需要 Hidden 信息做过滤
if !isLogin {
var hiddenClients []models.Client
db := dbcore.GetDBInstance()
_ = db.Select("uuid").Where("hidden = ?", true).Find(&hiddenClients).Error
for _, cli := range hiddenClients {
hiddenMap[cli.UUID] = true
}
if uuid != "" {
if hiddenMap[uuid] {
api.RespondSuccess(c, response) // 对于尝试获取隐藏uuid一键哈气
return
}
}
}
hours := c.Query("hours")
if hours == "" {
hours = "4"
}
hoursInt, err := strconv.Atoi(hours)
if err != nil {
api.RespondError(c, 400, "Invalid hours parameter")
return
hoursInt = 4
}
records, err := tasks.GetPingRecordsByClientAndTime(uuid, time.Now().Add(-time.Duration(hoursInt)*time.Hour), time.Now())
endTime := time.Now()
startTime := endTime.Add(-time.Duration(hoursInt) * time.Hour)
// 解析 task_id,支持同时传入 uuid 和 task_id
taskId := -1
if taskIdStr != "" {
taskId, err = strconv.Atoi(taskIdStr)
if err != nil {
api.RespondError(c, 400, "Invalid task_id parameter")
return
}
}
// 查询记录,现在支持 uuid + task_id 组合查询
records, err = tasks.GetPingRecords(uuid, taskId, startTime, endTime)
if err != nil {
api.RespondError(c, 500, "Failed to fetch records: "+err.Error())
api.RespondError(c, 500, "Failed to fetch ping records: "+err.Error())
return
}
response := &Resp{
Count: len(records),
Records: []RecordsResp{},
}
// 用于统计每个客户端的信息(按 task_id 查询时使用)
clientStats := make(map[string]struct {
total int
loss int
min int
max int
})
for _, r := range records {
response.Records = append(response.Records, RecordsResp{
TaskId: r.TaskId,
Time: r.Time.ToTime().Format(time.RFC3339),
Value: r.Value,
})
if r.Client != "" && !isLogin {
if hiddenMap[r.Client] {
continue // 跳过隐藏的节点
}
}
toTime := r.Time.ToTime()
rec := RecordsResp{
Time: toTime.Format(time.RFC3339),
Value: r.Value,
}
rec.Client = r.Client
stats := clientStats[r.Client]
stats.total++
if r.Value < 0 {
stats.loss++
} else {
if stats.min == 0 || r.Value < stats.min {
stats.min = r.Value
}
if r.Value > stats.max {
stats.max = r.Value
}
}
clientStats[r.Client] = stats
rec.TaskId = r.TaskId
response.Records = append(response.Records, rec)
}
if len(records) > 0 {
// 获取当前属于该 uuid 的 pingTasks
// 返回 BasicInfo - 按客户端分组的统计信息
// 在以下情况下特别有用:
// 1. 仅 task_id 查询 - 查看该任务下所有客户端的表现
// 2. uuid + task_id 查询 - 查看特定客户端在特定任务的表现
if len(clientStats) > 0 {
response.BasicInfo = make([]ClientBasicInfo, 0, len(clientStats))
for client, stats := range clientStats {
if client != "" && !isLogin {
if hiddenMap[client] {
continue // 跳过隐藏的节点
}
}
loss := float64(0)
if stats.total > 0 {
loss = float64(stats.loss) / float64(stats.total) * 100
}
response.BasicInfo = append(response.BasicInfo, ClientBasicInfo{
Client: client,
Loss: loss,
Min: stats.min,
Max: stats.max,
})
}
// 如果同时指定了 uuid 和 task_id,BasicInfo 应该只有一条记录
// 这种情况下可以在响应中标记查询模式
if uuid != "" && taskId != -1 && len(response.BasicInfo) == 1 {
// 这是精确查询模式
}
}
// 优化后的任务信息返回逻辑
// 1. uuid != "" - 返回该客户端参与的所有任务信息
// 2. uuid != "" && taskId != -1 - 返回该客户端在指定任务的信息
// 3. taskId != -1 && uuid == "" - 返回该任务的所有客户端统计(通过 BasicInfo)
if uuid != "" || taskId != -1 {
// 获取所有 pingTasks
pingTasks, err := tasks.GetAllPingTasks()
if err != nil {
api.RespondError(c, 500, "Failed to fetch ping tasks: "+err.Error())
return
}
taskIdSet := make(map[uint]struct{})
for _, r := range records {
taskIdSet[r.TaskId] = struct{}{}
}
tasksList := make([]gin.H, 0, len(pingTasks))
for _, t := range pingTasks {
// 只保留分配给该 uuid 的任务
found := false
for _, client := range t.Clients {
if client == uuid {
found = true
break
// 如果指定了 taskId,只处理该任务
if taskId != -1 {
if t.Id != uint(taskId) {
continue
}
}
if !found {
continue
// 如果指定了 uuid,检查任务是否分配给该客户端
if uuid != "" {
found := slices.Contains(t.Clients, uuid)
if !found {
continue
}
}
// 只返回有记录的任务
if _, ok := taskIdSet[t.Id]; ok {
tasksList = append(tasksList, gin.H{
"id": t.Id,
"name": t.Name,
"interval": t.Interval,
})
// 计算该任务的丢包率和延迟统计
totalCount := 0
lossCount := 0
minLatency := 0
maxLatency := 0
avgLatency := 0
sumLatency := 0
validCount := 0
for _, r := range records {
// 根据查询模式过滤记录
if r.TaskId != t.Id {
continue
}
// 如果同时指定了 uuid 和 task_id,只统计该客户端的记录
if uuid != "" && r.Client != uuid {
continue
}
totalCount++
if r.Value < 0 {
lossCount++
} else {
validCount++
sumLatency += r.Value
if minLatency == 0 || r.Value < minLatency {
minLatency = r.Value
}
if r.Value > maxLatency {
maxLatency = r.Value
}
}
}
var lossRate float64 = 0
if totalCount > 0 {
lossRate = float64(lossCount) / float64(totalCount) * 100
}
if validCount > 0 {
avgLatency = sumLatency / validCount
}
taskInfo := gin.H{
"id": t.Id,
"name": t.Name,
"type": t.Type,
"interval": t.Interval,
"loss": lossRate,
"min": minLatency,
"max": maxLatency,
"avg": avgLatency,
"total": totalCount,
}
// 如果是仅 task_id 查询,添加客户端列表信息
if uuid == "" && taskId != -1 {
taskInfo["clients"] = t.Clients
}
tasksList = append(tasksList, taskInfo)
}
response.Tasks = tasksList
}
response.Count = len(response.Records) // 计算最后结果保持计数一致
api.RespondSuccess(c, response)
}
+38
View File
@@ -0,0 +1,38 @@
package task
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/database/tasks"
)
type PublicPingTask struct {
Id uint `json:"id"`
Name string `json:"name"`
Clients []string `json:"clients"`
Type string `json:"type"`
Interval int `json:"interval"`
}
func GetPublicPingTasks(c *gin.Context) {
tasks, err := tasks.GetAllPingTasks()
if err != nil {
api.RespondError(c, http.StatusInternalServerError, err.Error())
return
}
publicTasks := make([]PublicPingTask, len(tasks))
for i, task := range tasks {
publicTasks[i] = PublicPingTask{
Id: task.Id,
Name: task.Name,
Clients: task.Clients,
Type: task.Type,
Interval: task.Interval,
}
}
api.RespondSuccess(c, publicTasks)
}
+1 -3
View File
@@ -30,9 +30,7 @@ func RequestTerminal(c *gin.Context) {
return
}
upgrader := websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
return true
},
CheckOrigin: ws.CheckOrigin,
}
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
+122
View File
@@ -0,0 +1,122 @@
package api
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"github.com/komari-monitor/komari/common"
"github.com/komari-monitor/komari/database/accounts"
"github.com/komari-monitor/komari/database/config"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/ws"
)
func GetClients(c *gin.Context) {
// 升级到ws
if !websocket.IsWebSocketUpgrade(c.Request) {
c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "Require WebSocket upgrade"})
return
}
cfg, _ := config.Get()
upgrader := websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
if cfg.AllowCors {
return true
}
return ws.CheckOrigin(r)
},
}
// Upgrade the HTTP connection to a WebSocket connection
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "Failed to upgrade to WebSocket"})
return
}
defer conn.Close()
// 初始化用户信息
var (
isLogin = false
hiddenMap = map[string]bool{}
session, _ = c.Cookie("session_token")
)
// 登录状态检查
_, err = accounts.GetUserBySession(session)
if err == nil {
isLogin = true
}
// 仅在未登录时需要 Hidden 信息做过滤
if !isLogin {
var hiddenClients []models.Client
db := dbcore.GetDBInstance()
_ = db.Select("uuid").Where("hidden = ?", true).Find(&hiddenClients).Error
for _, cli := range hiddenClients {
hiddenMap[cli.UUID] = true
}
}
// 请求
for {
var resp struct {
Online []string `json:"online"` // 已建立连接的客户端uuid列表
Data map[string]common.Report `json:"data"` // 最后上报的数据
}
resp.Online = []string{}
resp.Data = map[string]common.Report{}
_, data, err := conn.ReadMessage()
if err != nil {
//log.Println("Error reading message:", err)
return
}
message := string(data)
uuID := ""
if message != "get" { // 非请求全部内容
if strings.HasPrefix(message, "get ") {
uuID = strings.TrimSpace(strings.TrimPrefix(message, "get "))
} else {
conn.WriteJSON(gin.H{"status": "error", "error": "Invalid message"})
continue
}
}
// 在线客户端uuid列表(WebSocket 与非 WebSocket)
for _, key := range ws.GetAllOnlineUUIDs() {
if !isLogin && hiddenMap[key] {
continue
}
if uuID != "" && key != uuID {
continue
}
resp.Online = append(resp.Online, key)
}
//过往节点数据信息
for key, report := range ws.GetLatestReport() {
if !isLogin && hiddenMap[key] {
continue
}
if uuID != "" && key != uuID {
continue
}
report.UUID = "" // 不暴露 uuid
if report.CPU.Usage == 0 {
report.CPU.Usage = 0.01
}
resp.Data[key] = *report
}
err = conn.WriteJSON(gin.H{"status": "success", "data": resp})
if err != nil {
return
}
}
}
+404
View File
@@ -0,0 +1,404 @@
package cmd
import (
"context"
"errors"
"io"
"log"
"math"
"net"
"strings"
"sync"
"time"
apiClient "github.com/komari-monitor/komari/api/client"
"github.com/komari-monitor/komari/common"
"github.com/komari-monitor/komari/database/auditlog"
"github.com/komari-monitor/komari/database/config"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/utils/geoip"
"github.com/komari-monitor/komari/utils/notifier"
"github.com/komari-monitor/komari/ws"
"github.com/komari-monitor/komari/compat/nezha/proto"
"google.golang.org/grpc"
"google.golang.org/grpc/keepalive"
"google.golang.org/grpc/metadata"
"gorm.io/gorm/clause"
)
// [Deprecated] Use StartNezhaCompat instead.
func StartNezhaCompatServer(addr string) error {
boot := uint64(time.Now().Unix())
lis, err := net.Listen("tcp", addr)
if err != nil {
return err
}
srv := &nezhaCompatServer{bootTime: boot}
// Interceptors for metadata auth presence (lightweight; validation is inside handlers too)
unary := func(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
return handler(ctx, req)
}
stream := func(srvIface interface{}, ss grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) error {
return handler(srvIface, ss)
}
// Enable gRPC keepalive to tolerate long-idle streams
gs := grpc.NewServer(
grpc.UnaryInterceptor(unary),
grpc.StreamInterceptor(stream),
// Keepalive: allow client pings and keep connections stable without app traffic
grpc.KeepaliveParams(keepalive.ServerParameters{
Time: 2 * time.Minute,
Timeout: 20 * time.Second,
}),
grpc.KeepaliveEnforcementPolicy(keepalive.EnforcementPolicy{
MinTime: 20 * time.Second,
PermitWithoutStream: true,
}),
)
proto.RegisterNezhaServiceServer(gs, srv)
log.Printf("Nezha compat gRPC listening on %s", addr)
return gs.Serve(lis)
}
// ---- Manual start/stop support ----
var (
nezhaSrv *grpc.Server
nezhaLis net.Listener
nezhaOnceM sync.Mutex
)
// StartNezhaCompat starts the Nezha compatible gRPC server asynchronously.
// Returns error if already started.
func StartNezhaCompat(addr string) error {
nezhaOnceM.Lock()
defer nezhaOnceM.Unlock()
if nezhaSrv != nil {
return errors.New("nezha compat server already started")
}
lis, err := net.Listen("tcp", addr)
if err != nil {
return err
}
boot := uint64(time.Now().Unix())
sImpl := &nezhaCompatServer{bootTime: boot}
unary := func(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
return handler(ctx, req)
}
stream := func(srvIface interface{}, ss grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) error {
return handler(srvIface, ss)
}
gs := grpc.NewServer(
grpc.UnaryInterceptor(unary),
grpc.StreamInterceptor(stream),
grpc.KeepaliveParams(keepalive.ServerParameters{Time: 2 * time.Minute, Timeout: 20 * time.Second}),
grpc.KeepaliveEnforcementPolicy(keepalive.EnforcementPolicy{MinTime: 20 * time.Second, PermitWithoutStream: true}),
)
proto.RegisterNezhaServiceServer(gs, sImpl)
nezhaSrv = gs
nezhaLis = lis
go func() {
if err := gs.Serve(lis); err != nil {
log.Printf("Nezha compat gRPC server stopped: %v", err)
}
}()
log.Printf("Nezha compat gRPC started on %s", addr)
return nil
}
// StopNezhaCompat stops the server if running.
func StopNezhaCompat() error {
nezhaOnceM.Lock()
defer nezhaOnceM.Unlock()
if nezhaSrv == nil {
return errors.New("nezha compat server not running")
}
// 强制立即断开所有连接与流,不等待在途 RPC 完成。
nezhaSrv.Stop()
// Listener close (Serve already returns after GracefulStop, but close to be explicit)
if nezhaLis != nil {
_ = nezhaLis.Close()
}
nezhaSrv = nil
nezhaLis = nil
log.Printf("Nezha compat gRPC stopped")
return nil
}
type nezhaCompatServer struct {
proto.UnimplementedNezhaServiceServer
bootTime uint64
}
// authentication helpers
func getAuth(ctx context.Context) (uuid string, secret string, err error) {
md, ok := metadata.FromIncomingContext(ctx)
if !ok {
return "", "", errors.New("missing metadata")
}
getFirst := func(key string) string {
vals := md.Get(key)
if len(vals) > 0 {
return vals[0]
}
return ""
}
uuid = getFirst("client_uuid")
secret = getFirst("client_secret")
if uuid == "" || secret == "" {
return "", "", errors.New("unauthorized: missing client_uuid/client_secret")
}
return uuid, secret, nil
}
// ReportSystemInfo: upsert static host info
func (s *nezhaCompatServer) ReportSystemInfo(ctx context.Context, host *proto.Host) (*proto.Receipt, error) {
uuid, secret, err := getAuth(ctx)
if err != nil {
return nil, err
}
if err := upsertClientFromHost(uuid, secret, host); err != nil {
return nil, err
}
return &proto.Receipt{Proced: true}, nil
}
// ReportSystemInfo2: same as above but returns dashboard boot time
func (s *nezhaCompatServer) ReportSystemInfo2(ctx context.Context, host *proto.Host) (*proto.Uint64Receipt, error) {
uuid, secret, err := getAuth(ctx)
if err != nil {
return nil, err
}
if err := upsertClientFromHost(uuid, secret, host); err != nil {
return nil, err
}
return &proto.Uint64Receipt{Data: s.bootTime}, nil
}
// ReportSystemState: ingest time-series records
func (s *nezhaCompatServer) ReportSystemState(stream proto.NezhaService_ReportSystemStateServer) error {
ctx := stream.Context()
uuid, _, err := getAuth(ctx)
if err != nil {
return err
}
// presence start
connID := time.Now().UnixNano()
ws.SetPresence(uuid, connID, true)
go notifier.OnlineNotification(uuid, connID)
defer func() {
ws.SetPresence(uuid, connID, false)
notifier.OfflineNotification(uuid, connID)
}()
for {
st, err := stream.Recv()
if err == io.EOF {
return nil
}
if err != nil {
return err
}
// refresh presence TTL on every frame
ws.KeepAlivePresence(uuid, connID, 30*time.Second)
if err := ingestState(uuid, st); err != nil {
// still ack to avoid client stuck; log error
log.Printf("Nezha ingest state error: %v", err)
}
if err := stream.Send(&proto.Receipt{Proced: true}); err != nil {
return err
}
}
}
// RequestTask: do not dispatch tasks, just drain results to avoid Unimplemented
func (s *nezhaCompatServer) RequestTask(stream proto.NezhaService_RequestTaskServer) error {
ctx := stream.Context()
uuid, _, err := getAuth(ctx)
if err != nil {
return err
}
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
connID := time.Now().UnixNano()
ws.SetPresence(uuid, connID, true)
defer ws.SetPresence(uuid, connID, false)
// receive results in background
recvErr := make(chan error, 1)
go func() {
for {
_, rerr := stream.Recv()
if rerr == io.EOF {
recvErr <- nil
return
}
if rerr != nil {
recvErr <- rerr
return
}
// refresh presence TTL when result received
ws.KeepAlivePresence(uuid, connID, 30*time.Second)
}
}()
// send heartbeat tasks periodically
for {
select {
case <-ctx.Done():
return ctx.Err()
case err := <-recvErr:
return err
case <-ticker.C:
if err := stream.Send(&proto.Task{}); err != nil {
return err
}
}
}
}
// Unimplemented methods intentionally left as default (IOStream, ReportGeoIP)
// upsertClientFromHost maps Host into models.Client and upserts by UUID
func upsertClientFromHost(uuid, secret string, h *proto.Host) error {
db := dbcore.GetDBInstance()
now := models.FromTime(time.Now())
// token guard: if existing record has different token, reject
var exist models.Client
if err := db.Where("uuid = ?", uuid).First(&exist).Error; err == nil {
if exist.Token != "" && exist.Token != secret {
return errors.New("unauthorized: token mismatch")
}
}
cpuName := ""
if len(h.Cpu) > 0 {
cpuName = h.Cpu[0]
}
gpuName := strings.Join(h.Gpu, "; ")
osName := h.Platform
if h.PlatformVersion != "" {
osName = h.Platform + " " + h.PlatformVersion
}
// clamp uint64 to int64 safely
clamp := func(v uint64) int64 {
if v > uint64(math.MaxInt64) {
return math.MaxInt64
}
return int64(v)
}
c := models.Client{
UUID: uuid,
Token: secret,
Name: "nezha_" + uuid[0:8],
CpuName: cpuName,
Arch: h.Arch,
CpuCores: len(h.Cpu),
OS: osName,
KernelVersion: h.PlatformVersion,
Virtualization: h.Virtualization,
GpuName: gpuName,
MemTotal: clamp(h.MemTotal),
SwapTotal: clamp(h.SwapTotal),
DiskTotal: clamp(h.DiskTotal),
Version: h.Version,
UpdatedAt: now,
}
// Upsert by UUID; don't override existing Token if already set
updates := map[string]interface{}{
"cpu_name": c.CpuName,
"arch": c.Arch,
"cpu_cores": c.CpuCores,
"os": c.OS,
"kernel_version": c.KernelVersion,
"virtualization": c.Virtualization,
"gpu_name": c.GpuName,
"mem_total": c.MemTotal,
"swap_total": c.SwapTotal,
"disk_total": c.DiskTotal,
"version": c.Version,
"updated_at": time.Now(),
}
return db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "uuid"}},
DoUpdates: clause.Assignments(updates),
}).Create(&c).Error
}
// ingestState maps Nezha State into common.Report then saves a Record
func ingestState(uuid string, st *proto.State) error {
// we may need totals from client
db := dbcore.GetDBInstance()
var client models.Client
if err := db.Where("uuid = ?", uuid).First(&client).Error; err != nil {
// If missing, create with minimal defaults to avoid failing ingestion
client = models.Client{UUID: uuid, Token: "", Name: "nezha_" + uuid[0:8]}
auditlog.EventLog("info", "auto created client "+client.Name)
_ = db.Create(&client).Error
}
rep := common.Report{
CPU: common.CPUReport{Usage: st.Cpu},
Ram: common.RamReport{Total: client.MemTotal, Used: int64(st.MemUsed)},
Swap: common.RamReport{Total: client.SwapTotal, Used: int64(st.SwapUsed)},
Load: common.LoadReport{Load1: st.Load1, Load5: st.Load5, Load15: st.Load15},
Disk: common.DiskReport{Total: client.DiskTotal, Used: int64(st.DiskUsed)},
Network: common.NetworkReport{
Up: int64(st.NetOutSpeed),
Down: int64(st.NetInSpeed),
TotalUp: int64(st.NetOutTransfer),
TotalDown: int64(st.NetInTransfer),
},
Uptime: int64(st.Uptime),
Process: int(st.ProcessCount),
UpdatedAt: time.Now(),
}
// 更新实时缓存供前端使用
ws.SetLatestReport(uuid, &rep)
// 写入内存缓存,入库交由定时聚合任务处理
return apiClient.SaveClientReport(uuid, rep)
}
// ReportGeoIP: 保存 Agent 上报的 IP,并回填国家码/面板启动时间
func (s *nezhaCompatServer) ReportGeoIP(ctx context.Context, in *proto.GeoIP) (*proto.GeoIP, error) {
uuid, _, err := getAuth(ctx)
if err != nil {
return nil, err
}
updates := map[string]interface{}{
"updated_at": time.Now(),
}
var iso string
if in != nil && in.Ip != nil {
if v4 := strings.TrimSpace(in.Ip.Ipv4); v4 != "" {
updates["ipv4"] = v4
if cfg, err := config.Get(); err == nil && cfg.GeoIpEnabled {
if ip := net.ParseIP(v4); ip != nil {
if gi, _ := geoip.GetGeoInfo(ip); gi != nil {
iso = gi.ISOCode
}
}
}
}
if v6 := strings.TrimSpace(in.Ip.Ipv6); v6 != "" {
updates["ipv6"] = v6
if iso == "" { // 优先使用 v4 的国家码
if cfg, err := config.Get(); err == nil && cfg.GeoIpEnabled {
if ip := net.ParseIP(v6); ip != nil {
if gi, _ := geoip.GetGeoInfo(ip); gi != nil {
iso = gi.ISOCode
}
}
}
}
}
}
if iso != "" {
// UI 使用旗帜 emoji
updates["region"] = geoip.GetRegionUnicodeEmoji(iso)
}
if len(updates) > 0 {
_ = dbcore.GetDBInstance().Model(&models.Client{}).Where("uuid = ?", uuid).Updates(updates).Error
}
// 回写 GeoIP(包含国家码与面板启动时间)
resp := &proto.GeoIP{Use6: in.GetUse6(), Ip: in.GetIp(), CountryCode: iso, DashboardBootTime: s.bootTime}
return resp, nil
}
+36 -11
View File
@@ -20,7 +20,9 @@ import (
"github.com/komari-monitor/komari/api/admin/test"
"github.com/komari-monitor/komari/api/admin/update"
"github.com/komari-monitor/komari/api/client"
"github.com/komari-monitor/komari/api/jsonRpc"
"github.com/komari-monitor/komari/api/record"
"github.com/komari-monitor/komari/api/task"
"github.com/komari-monitor/komari/cmd/flags"
"github.com/komari-monitor/komari/database"
"github.com/komari-monitor/komari/database/accounts"
@@ -38,7 +40,6 @@ import (
"github.com/komari-monitor/komari/utils/messageSender"
"github.com/komari-monitor/komari/utils/notifier"
"github.com/komari-monitor/komari/utils/oauth"
"github.com/komari-monitor/komari/ws"
"github.com/spf13/cobra"
)
@@ -83,16 +84,17 @@ func RunServer() {
go DoScheduledWork()
go messageSender.Initialize()
// oidcInit
oidcProvider, err := database.GetOidcConfigByName(conf.OAuthProvider)
if err != nil {
log.Printf("Failed to get OIDC provider config: %v", err)
}
err = oauth.LoadProvider(oidcProvider.Name, oidcProvider.Addition)
if err != nil {
log.Printf("Failed to load OIDC provider: %v", err)
} else {
log.Printf("Using %s as OIDC provider", oidcProvider.Name)
go oauth.Initialize()
if conf.NezhaCompatEnabled {
go func() {
if err := StartNezhaCompat(conf.NezhaCompatListen); err != nil {
log.Printf("Nezha compat server error: %v", err)
auditlog.EventLog("error", fmt.Sprintf("Nezha compat server error: %v", err))
}
}()
}
config.Subscribe(func(event config.ConfigEvent) {
if event.New.OAuthProvider != event.Old.OAuthProvider {
oidcProvider, err := database.GetOidcConfigByName(event.New.OAuthProvider)
@@ -109,6 +111,19 @@ func RunServer() {
if event.New.NotificationMethod != event.Old.NotificationMethod {
messageSender.Initialize()
}
if event.New.NezhaCompatEnabled != event.Old.NezhaCompatEnabled {
if event.New.NezhaCompatEnabled {
if err := StartNezhaCompat(event.New.NezhaCompatListen); err != nil {
log.Printf("start Nezha compat server error: %v", err)
auditlog.EventLog("error", fmt.Sprintf("start Nezha compat server error: %v", err))
}
} else {
if err := StopNezhaCompat(); err != nil {
log.Printf("stop Nezha compat server error: %v", err)
auditlog.EventLog("error", fmt.Sprintf("stop Nezha compat server error: %v", err))
}
}
}
})
// 初始化 cloudflared
@@ -164,7 +179,7 @@ func RunServer() {
// #region 公开路由
r.POST("/api/login", api.Login)
r.GET("/api/me", api.GetMe)
r.GET("/api/clients", ws.GetClients)
r.GET("/api/clients", api.GetClients)
r.GET("/api/nodes", api.GetNodesInformation)
r.GET("/api/public", api.GetPublicSettings)
r.GET("/api/oauth", api.OAuth)
@@ -175,7 +190,12 @@ func RunServer() {
r.GET("/api/records/load", record.GetRecordsByUUID)
r.GET("/api/records/ping", record.GetPingRecords)
r.GET("/api/task/ping", task.GetPublicPingTasks)
r.GET("/api/rpc2", jsonRpc.OnRpcRequest)
r.POST("/api/rpc2", jsonRpc.OnRpcRequest)
// #region Agent
r.POST("/api/clients/register", client.RegisterClient)
tokenAuthrized := r.Group("/api/clients", api.TokenAuthMiddleware())
{
tokenAuthrized.GET("/report", client.WebSocketReport) // websocket
@@ -230,6 +250,8 @@ func RunServer() {
themeGroup.GET("/list", admin.ListThemes)
themeGroup.POST("/delete", admin.DeleteTheme)
themeGroup.GET("/set", admin.SetTheme)
themeGroup.POST("/update", admin.UpdateTheme)
themeGroup.POST("/settings", admin.UpdateThemeSettings)
}
// clients
clientGroup := adminAuthrized.Group("/client")
@@ -322,6 +344,7 @@ func RunServer() {
Addr: flags.Listen,
Handler: r,
}
log.Printf("Starting server on %s ...", flags.Listen)
go func() {
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
OnFatal(err)
@@ -387,6 +410,8 @@ func DoScheduledWork() {
records.DeleteAll()
tasks.DeleteAllPingRecords()
}
// 每分钟检查一次流量提醒
go notifier.CheckTraffic()
}
}
+18
View File
@@ -69,6 +69,7 @@ type Report struct {
Disk DiskReport `json:"disk"`
Network NetworkReport `json:"network"`
Connections ConnectionsReport `json:"connections"`
GPU *GPUDetailReport `json:"gpu,omitempty"` // 新增GPU详细信息
Uptime int64 `json:"uptime"`
Process int `json:"process"`
Message string `json:"message"`
@@ -83,6 +84,23 @@ type CPUReport struct {
Usage float64 `json:"usage,omitempty"`
}
// GPUDetailReport 详细GPU信息报告
type GPUDetailReport struct {
Count int `json:"count"` // GPU数量
AverageUsage float64 `json:"average_usage"` // 平均使用率
DetailedInfo []GPUDeviceInfo `json:"detailed_info"` // 每个GPU的详细信息
}
// GPUDeviceInfo 单个GPU设备信息
type GPUDeviceInfo struct {
Name string `json:"name"` // GPU型号名称
MemoryTotal int64 `json:"memory_total"` // 显存总量(字节)
MemoryUsed int64 `json:"memory_used"` // 显存已用(字节)
Utilization float64 `json:"utilization"` // GPU使用率(%)
Temperature int `json:"temperature"` // GPU温度(°C)
}
// 保持向后兼容的旧GPUReport结构
type GPUReport struct {
Name string `json:"name,omitempty"`
Usage float64 `json:"usage,omitempty"`
+83
View File
@@ -0,0 +1,83 @@
syntax = "proto3";
package proto;
option go_package = "github.com/komari-monitor/komari/compat/nezha/proto";
service NezhaService {
rpc ReportSystemState(stream State) returns (stream Receipt) {}
rpc ReportSystemInfo(Host) returns (Receipt) {}
rpc RequestTask(stream TaskResult) returns (stream Task) {}
rpc IOStream(stream IOStreamData) returns (stream IOStreamData) {}
rpc ReportGeoIP(GeoIP) returns (GeoIP) {}
rpc ReportSystemInfo2(Host) returns (Uint64Receipt) {}
}
message Host {
string platform = 1;
string platform_version = 2;
repeated string cpu = 3;
uint64 mem_total = 4;
uint64 disk_total = 5;
uint64 swap_total = 6;
string arch = 7;
string virtualization = 8;
uint64 boot_time = 9;
string version = 10;
repeated string gpu = 11;
}
message State {
double cpu = 1;
uint64 mem_used = 2;
uint64 swap_used = 3;
uint64 disk_used = 4;
uint64 net_in_transfer = 5;
uint64 net_out_transfer = 6;
uint64 net_in_speed = 7;
uint64 net_out_speed = 8;
uint64 uptime = 9;
double load1 = 10;
double load5 = 11;
double load15 = 12;
uint64 tcp_conn_count = 13;
uint64 udp_conn_count = 14;
uint64 process_count = 15;
repeated State_SensorTemperature temperatures = 16;
repeated double gpu = 17;
}
message State_SensorTemperature {
string name = 1;
double temperature = 2;
}
message Task {
uint64 id = 1;
uint64 type = 2;
string data = 3;
}
message TaskResult {
uint64 id = 1;
uint64 type = 2;
float delay = 3;
string data = 4;
bool successful = 5;
}
message Receipt { bool proced = 1; }
message Uint64Receipt { uint64 data = 1; }
message IOStreamData { bytes data = 1; }
message GeoIP {
bool use6 = 1;
IP ip = 2;
string country_code = 3;
uint64 dashboard_boot_time = 4;
}
message IP {
string ipv4 = 1;
string ipv6 = 2;
}
File diff suppressed because it is too large. Load diff
+374
View File
@@ -0,0 +1,374 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
package proto
import (
context "context"
grpc "google.golang.org/grpc"
codes "google.golang.org/grpc/codes"
status "google.golang.org/grpc/status"
)
// This is a compile-time assertion to ensure that this generated file
// is compatible with the grpc package it is being compiled against.
const _ = grpc.SupportPackageIsVersion7
// NezhaServiceClient is the client API for NezhaService service.
//
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
type NezhaServiceClient interface {
ReportSystemState(ctx context.Context, opts ...grpc.CallOption) (NezhaService_ReportSystemStateClient, error)
ReportSystemInfo(ctx context.Context, in *Host, opts ...grpc.CallOption) (*Receipt, error)
RequestTask(ctx context.Context, opts ...grpc.CallOption) (NezhaService_RequestTaskClient, error)
IOStream(ctx context.Context, opts ...grpc.CallOption) (NezhaService_IOStreamClient, error)
ReportGeoIP(ctx context.Context, in *GeoIP, opts ...grpc.CallOption) (*GeoIP, error)
ReportSystemInfo2(ctx context.Context, in *Host, opts ...grpc.CallOption) (*Uint64Receipt, error)
}
type nezhaServiceClient struct {
cc grpc.ClientConnInterface
}
func NewNezhaServiceClient(cc grpc.ClientConnInterface) NezhaServiceClient {
return &nezhaServiceClient{cc}
}
func (c *nezhaServiceClient) ReportSystemState(ctx context.Context, opts ...grpc.CallOption) (NezhaService_ReportSystemStateClient, error) {
stream, err := c.cc.NewStream(ctx, &_NezhaService_serviceDesc.Streams[0], "/proto.NezhaService/ReportSystemState", opts...)
if err != nil {
return nil, err
}
x := &nezhaServiceReportSystemStateClient{stream}
return x, nil
}
type NezhaService_ReportSystemStateClient interface {
Send(*State) error
Recv() (*Receipt, error)
grpc.ClientStream
}
type nezhaServiceReportSystemStateClient struct {
grpc.ClientStream
}
func (x *nezhaServiceReportSystemStateClient) Send(m *State) error {
return x.ClientStream.SendMsg(m)
}
func (x *nezhaServiceReportSystemStateClient) Recv() (*Receipt, error) {
m := new(Receipt)
if err := x.ClientStream.RecvMsg(m); err != nil {
return nil, err
}
return m, nil
}
func (c *nezhaServiceClient) ReportSystemInfo(ctx context.Context, in *Host, opts ...grpc.CallOption) (*Receipt, error) {
out := new(Receipt)
err := c.cc.Invoke(ctx, "/proto.NezhaService/ReportSystemInfo", in, out, opts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *nezhaServiceClient) RequestTask(ctx context.Context, opts ...grpc.CallOption) (NezhaService_RequestTaskClient, error) {
stream, err := c.cc.NewStream(ctx, &_NezhaService_serviceDesc.Streams[1], "/proto.NezhaService/RequestTask", opts...)
if err != nil {
return nil, err
}
x := &nezhaServiceRequestTaskClient{stream}
return x, nil
}
type NezhaService_RequestTaskClient interface {
Send(*TaskResult) error
Recv() (*Task, error)
grpc.ClientStream
}
type nezhaServiceRequestTaskClient struct {
grpc.ClientStream
}
func (x *nezhaServiceRequestTaskClient) Send(m *TaskResult) error {
return x.ClientStream.SendMsg(m)
}
func (x *nezhaServiceRequestTaskClient) Recv() (*Task, error) {
m := new(Task)
if err := x.ClientStream.RecvMsg(m); err != nil {
return nil, err
}
return m, nil
}
func (c *nezhaServiceClient) IOStream(ctx context.Context, opts ...grpc.CallOption) (NezhaService_IOStreamClient, error) {
stream, err := c.cc.NewStream(ctx, &_NezhaService_serviceDesc.Streams[2], "/proto.NezhaService/IOStream", opts...)
if err != nil {
return nil, err
}
x := &nezhaServiceIOStreamClient{stream}
return x, nil
}
type NezhaService_IOStreamClient interface {
Send(*IOStreamData) error
Recv() (*IOStreamData, error)
grpc.ClientStream
}
type nezhaServiceIOStreamClient struct {
grpc.ClientStream
}
func (x *nezhaServiceIOStreamClient) Send(m *IOStreamData) error {
return x.ClientStream.SendMsg(m)
}
func (x *nezhaServiceIOStreamClient) Recv() (*IOStreamData, error) {
m := new(IOStreamData)
if err := x.ClientStream.RecvMsg(m); err != nil {
return nil, err
}
return m, nil
}
func (c *nezhaServiceClient) ReportGeoIP(ctx context.Context, in *GeoIP, opts ...grpc.CallOption) (*GeoIP, error) {
out := new(GeoIP)
err := c.cc.Invoke(ctx, "/proto.NezhaService/ReportGeoIP", in, out, opts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *nezhaServiceClient) ReportSystemInfo2(ctx context.Context, in *Host, opts ...grpc.CallOption) (*Uint64Receipt, error) {
out := new(Uint64Receipt)
err := c.cc.Invoke(ctx, "/proto.NezhaService/ReportSystemInfo2", in, out, opts...)
if err != nil {
return nil, err
}
return out, nil
}
// NezhaServiceServer is the server API for NezhaService service.
// All implementations must embed UnimplementedNezhaServiceServer
// for forward compatibility
type NezhaServiceServer interface {
ReportSystemState(NezhaService_ReportSystemStateServer) error
ReportSystemInfo(context.Context, *Host) (*Receipt, error)
RequestTask(NezhaService_RequestTaskServer) error
IOStream(NezhaService_IOStreamServer) error
ReportGeoIP(context.Context, *GeoIP) (*GeoIP, error)
ReportSystemInfo2(context.Context, *Host) (*Uint64Receipt, error)
mustEmbedUnimplementedNezhaServiceServer()
}
// UnimplementedNezhaServiceServer must be embedded to have forward compatible implementations.
type UnimplementedNezhaServiceServer struct {
}
func (UnimplementedNezhaServiceServer) ReportSystemState(NezhaService_ReportSystemStateServer) error {
return status.Errorf(codes.Unimplemented, "method ReportSystemState not implemented")
}
func (UnimplementedNezhaServiceServer) ReportSystemInfo(context.Context, *Host) (*Receipt, error) {
return nil, status.Errorf(codes.Unimplemented, "method ReportSystemInfo not implemented")
}
func (UnimplementedNezhaServiceServer) RequestTask(NezhaService_RequestTaskServer) error {
return status.Errorf(codes.Unimplemented, "method RequestTask not implemented")
}
func (UnimplementedNezhaServiceServer) IOStream(NezhaService_IOStreamServer) error {
return status.Errorf(codes.Unimplemented, "method IOStream not implemented")
}
func (UnimplementedNezhaServiceServer) ReportGeoIP(context.Context, *GeoIP) (*GeoIP, error) {
return nil, status.Errorf(codes.Unimplemented, "method ReportGeoIP not implemented")
}
func (UnimplementedNezhaServiceServer) ReportSystemInfo2(context.Context, *Host) (*Uint64Receipt, error) {
return nil, status.Errorf(codes.Unimplemented, "method ReportSystemInfo2 not implemented")
}
func (UnimplementedNezhaServiceServer) mustEmbedUnimplementedNezhaServiceServer() {}
// UnsafeNezhaServiceServer may be embedded to opt out of forward compatibility for this service.
// Use of this interface is not recommended, as added methods to NezhaServiceServer will
// result in compilation errors.
type UnsafeNezhaServiceServer interface {
mustEmbedUnimplementedNezhaServiceServer()
}
func RegisterNezhaServiceServer(s *grpc.Server, srv NezhaServiceServer) {
s.RegisterService(&_NezhaService_serviceDesc, srv)
}
func _NezhaService_ReportSystemState_Handler(srv interface{}, stream grpc.ServerStream) error {
return srv.(NezhaServiceServer).ReportSystemState(&nezhaServiceReportSystemStateServer{stream})
}
type NezhaService_ReportSystemStateServer interface {
Send(*Receipt) error
Recv() (*State, error)
grpc.ServerStream
}
type nezhaServiceReportSystemStateServer struct {
grpc.ServerStream
}
func (x *nezhaServiceReportSystemStateServer) Send(m *Receipt) error {
return x.ServerStream.SendMsg(m)
}
func (x *nezhaServiceReportSystemStateServer) Recv() (*State, error) {
m := new(State)
if err := x.ServerStream.RecvMsg(m); err != nil {
return nil, err
}
return m, nil
}
func _NezhaService_ReportSystemInfo_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(Host)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(NezhaServiceServer).ReportSystemInfo(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: "/proto.NezhaService/ReportSystemInfo",
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(NezhaServiceServer).ReportSystemInfo(ctx, req.(*Host))
}
return interceptor(ctx, in, info, handler)
}
func _NezhaService_RequestTask_Handler(srv interface{}, stream grpc.ServerStream) error {
return srv.(NezhaServiceServer).RequestTask(&nezhaServiceRequestTaskServer{stream})
}
type NezhaService_RequestTaskServer interface {
Send(*Task) error
Recv() (*TaskResult, error)
grpc.ServerStream
}
type nezhaServiceRequestTaskServer struct {
grpc.ServerStream
}
func (x *nezhaServiceRequestTaskServer) Send(m *Task) error {
return x.ServerStream.SendMsg(m)
}
func (x *nezhaServiceRequestTaskServer) Recv() (*TaskResult, error) {
m := new(TaskResult)
if err := x.ServerStream.RecvMsg(m); err != nil {
return nil, err
}
return m, nil
}
func _NezhaService_IOStream_Handler(srv interface{}, stream grpc.ServerStream) error {
return srv.(NezhaServiceServer).IOStream(&nezhaServiceIOStreamServer{stream})
}
type NezhaService_IOStreamServer interface {
Send(*IOStreamData) error
Recv() (*IOStreamData, error)
grpc.ServerStream
}
type nezhaServiceIOStreamServer struct {
grpc.ServerStream
}
func (x *nezhaServiceIOStreamServer) Send(m *IOStreamData) error {
return x.ServerStream.SendMsg(m)
}
func (x *nezhaServiceIOStreamServer) Recv() (*IOStreamData, error) {
m := new(IOStreamData)
if err := x.ServerStream.RecvMsg(m); err != nil {
return nil, err
}
return m, nil
}
func _NezhaService_ReportGeoIP_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GeoIP)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(NezhaServiceServer).ReportGeoIP(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: "/proto.NezhaService/ReportGeoIP",
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(NezhaServiceServer).ReportGeoIP(ctx, req.(*GeoIP))
}
return interceptor(ctx, in, info, handler)
}
func _NezhaService_ReportSystemInfo2_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(Host)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(NezhaServiceServer).ReportSystemInfo2(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: "/proto.NezhaService/ReportSystemInfo2",
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(NezhaServiceServer).ReportSystemInfo2(ctx, req.(*Host))
}
return interceptor(ctx, in, info, handler)
}
var _NezhaService_serviceDesc = grpc.ServiceDesc{
ServiceName: "proto.NezhaService",
HandlerType: (*NezhaServiceServer)(nil),
Methods: []grpc.MethodDesc{
{
MethodName: "ReportSystemInfo",
Handler: _NezhaService_ReportSystemInfo_Handler,
},
{
MethodName: "ReportGeoIP",
Handler: _NezhaService_ReportGeoIP_Handler,
},
{
MethodName: "ReportSystemInfo2",
Handler: _NezhaService_ReportSystemInfo2_Handler,
},
},
Streams: []grpc.StreamDesc{
{
StreamName: "ReportSystemState",
Handler: _NezhaService_ReportSystemState_Handler,
ServerStreams: true,
ClientStreams: true,
},
{
StreamName: "RequestTask",
Handler: _NezhaService_RequestTask_Handler,
ServerStreams: true,
ClientStreams: true,
},
{
StreamName: "IOStream",
Handler: _NezhaService_IOStream_Handler,
ServerStreams: true,
ClientStreams: true,
},
},
Metadata: "compat/nezha/nezha.proto",
}
+21
View File
@@ -2,11 +2,17 @@ package accounts
import (
"errors"
"fmt"
"net"
"time"
"github.com/komari-monitor/komari/database/config"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
messageevent "github.com/komari-monitor/komari/database/models/messageEvent"
"github.com/komari-monitor/komari/utils"
"github.com/komari-monitor/komari/utils/geoip"
"github.com/komari-monitor/komari/utils/messageSender"
)
// GetAllSessions 获取所有会话
@@ -33,6 +39,21 @@ func CreateSession(uuid string, expires int, userAgent, ip, login_method string)
LoginMethod: login_method,
LatestOnline: models.FromTime(time.Now()),
}
cfg, _ := config.Get()
if cfg.LoginNotification {
ipAddr := net.ParseIP(ip)
ipinfo, _ := geoip.GetGeoInfo(ipAddr)
loc := "unknown"
if ipinfo != nil {
loc = ipinfo.Name
}
messageSender.SendEvent(models.EventMessage{
Event: messageevent.Login,
Time: time.Now(),
Message: fmt.Sprintf("%s: %s (%s)\n%s", login_method, ip, loc, userAgent),
Emoji: "🔑",
})
}
err := db.Create(&sessionRecord).Error
if err != nil {
+32 -1
View File
@@ -1,6 +1,7 @@
package clients
import (
"math"
"time"
"github.com/komari-monitor/komari/common"
@@ -47,7 +48,7 @@ func UpdateOrInsertBasicInfo(cbi common.ClientInfo) error {
if cbi.Arch != "" {
updates["arch"] = cbi.Arch
}
if cbi.CpuCores != 0 {
if cbi.CpuCores > 0 || cbi.CpuCores < math.MaxInt-1 {
updates["cpu_cores"] = cbi.CpuCores
}
if cbi.OS != "" {
@@ -120,6 +121,36 @@ func SaveClientInfo(update map[string]interface{}) error {
update["updated_at"] = time.Now()
checkInt64 := func(name string, val float64) error {
if val < 0 {
return fmt.Errorf("%s must be non-negative, got %d", name, int64(val))
}
if val > math.MaxInt64-1 {
return fmt.Errorf("%s exceeds int64 max limit: %d", name, int64(val))
}
return nil
}
verify := func(update map[string]interface{}) error {
if update["cpu_cores"].(float64) < 0 || update["cpu_cores"].(float64) > math.MaxInt-1 {
return fmt.Errorf("Cpu.Cores be not a valid int64 number: %d", update["cpu_cores"])
}
if err := checkInt64("Ram.Total", update["mem_total"].(float64)); err != nil {
return err
}
if err := checkInt64("Swap.Total", update["swap_total"].(float64)); err != nil {
return err
}
if err := checkInt64("Disk.Total", update["disk_total"].(float64)); err != nil {
return err
}
return nil
}
if err := verify(update); err != nil {
return err
}
err := db.Model(&models.Client{}).Where("uuid = ?", clientUUID).Updates(update).Error
if err != nil {
return err
+118 -2
View File
@@ -3,6 +3,7 @@ package clients
import (
"encoding/json"
"fmt"
"math"
"time"
"github.com/komari-monitor/komari/common"
@@ -51,15 +52,129 @@ func ParseReport(data map[string]interface{}) (report common.Report, err error)
return report, nil
}
// 检查数据防止异常数据导致数据库损坏
func ReportVerify(report common.Report) error {
// 防止输入不合理范围
if report.CPU.Usage < 0 || report.CPU.Usage > 100 {
return fmt.Errorf("CPU.Usage must be between 0 and 100")
}
if report.Load.Load1 < 0 || report.Load.Load1 > 1000 {
return fmt.Errorf("Load.Load1 must be non-negative, got %.2f", report.Load.Load1)
}
checkFloat64 := func(name string, val float64) error {
if val > math.MaxFloat64-1 || val < -math.MaxFloat64+1 {
return fmt.Errorf("%s value exceeds float64 range: %g", name, val)
}
return nil
}
// [float64] 防止数据溢出
if err := checkFloat64("CPU.Usage", report.CPU.Usage); err != nil {
return err
}
if err := checkFloat64("Load.Load1", report.Load.Load1); err != nil {
return err
}
checkInt64 := func(name string, val int64) error {
if val < 0 {
return fmt.Errorf("%s must be non-negative, got %d", name, val)
}
if val > math.MaxInt64-1 {
return fmt.Errorf("%s exceeds int64 max limit: %d", name, val)
}
return nil
}
// [int64] 防止数据溢出
// Ram 验证
if err := checkInt64("Ram.Used", report.Ram.Used); err != nil {
return err
}
if err := checkInt64("Ram.Total", report.Ram.Total); err != nil {
return err
}
// Swap 验证
if err := checkInt64("Swap.Used", report.Swap.Used); err != nil {
return err
}
if err := checkInt64("Swap.Total", report.Swap.Total); err != nil {
return err
}
// Disk 验证
if err := checkInt64("Disk.Used", report.Disk.Used); err != nil {
return err
}
if err := checkInt64("Disk.Total", report.Disk.Total); err != nil {
return err
}
// Network 验证
if err := checkInt64("Network.Up", report.Network.Up); err != nil {
return err
}
if err := checkInt64("Network.Down", report.Network.Down); err != nil {
return err
}
if err := checkInt64("Network.TotalUp", report.Network.TotalUp); err != nil {
return err
}
if err := checkInt64("Network.TotalDown", report.Network.TotalDown); err != nil {
return err
}
// 拒绝所有负数Int
if report.Process < 0 {
return fmt.Errorf("Process must be non-negative: %d", report.Process)
}
if report.Connections.TCP < 0 {
return fmt.Errorf("Connections.TCP must be non-negative: %d", report.Connections.TCP)
}
if report.Connections.UDP < 0 {
return fmt.Errorf("Connections.UDP must be non-negative: %d", report.Connections.UDP)
}
return nil
}
// SaveClientReport 保存客户端报告到 Record 表
func SaveClientReport(clientUUID string, report common.Report) (err error) {
db := dbcore.GetDBInstance()
if err := ReportVerify(report); err != nil {
return fmt.Errorf("failed to save Record: %v", err)
}
// 保存GPU详细记录到独立表
currentTime := time.Now()
if report.GPU != nil && len(report.GPU.DetailedInfo) > 0 {
for idx, gpu := range report.GPU.DetailedInfo {
gpuRecord := models.GPURecord{
Client: clientUUID,
Time: models.FromTime(currentTime),
DeviceIndex: idx,
DeviceName: gpu.Name,
MemTotal: gpu.MemoryTotal,
MemUsed: gpu.MemoryUsed,
Utilization: float32(gpu.Utilization),
Temperature: gpu.Temperature,
}
if err := db.Create(&gpuRecord).Error; err != nil {
return fmt.Errorf("failed to save GPU record: %v", err)
}
}
}
// 计算平均GPU使用率,用于向后兼容
averageGPUUsage := float32(0)
if report.GPU != nil && len(report.GPU.DetailedInfo) > 0 {
averageGPUUsage = float32(report.GPU.AverageUsage)
}
Record := models.Record{
Client: clientUUID,
Time: models.FromTime(time.Now()),
Time: models.FromTime(currentTime),
Cpu: float32(report.CPU.Usage),
Gpu: 0, // Report 未提供 GPU Usage,设为 0(与原 nil 行为类似)
Gpu: averageGPUUsage, // 使用平均GPU使用率
Ram: report.Ram.Used,
RamTotal: report.Ram.Total,
Swap: report.Swap.Used,
@@ -75,6 +190,7 @@ func SaveClientReport(clientUUID string, report common.Report) (err error) {
Process: report.Process,
Connections: report.Connections.TCP,
ConnectionsUdp: report.Connections.UDP,
//Uptime: report.Uptime,
}
// 使用事务确保 Record 和 ClientsInfo 一致性
+22 -10
View File
@@ -25,15 +25,18 @@ func Get() (models.Config, error) {
if err := db.First(&config).Error; err != nil {
if err == gorm.ErrRecordNotFound {
config = models.Config{
ID: 1,
Sitename: "Komari",
Description: "Komari Monitor, a simple server monitoring tool.",
AllowCors: false,
OAuthEnabled: false,
GeoIpEnabled: true,
GeoIpProvider: "ip-api",
UpdatedAt: models.FromTime(time.Now()),
CreatedAt: models.FromTime(time.Now()),
ID: 1,
Sitename: "Komari",
Description: "Komari Monitor, a simple server monitoring tool.",
AllowCors: false,
OAuthEnabled: false,
GeoIpEnabled: true,
GeoIpProvider: "ipinfo",
NezhaCompatEnabled: false,
NezhaCompatListen: "",
NotificationTemplate: "{{emoji}}{{emoji}}{{emoji}}\nEvent: {{event}}\nClients: {{client}}\nMessage: {{message}}\nTime: {{time}}",
UpdatedAt: models.FromTime(time.Now()),
CreatedAt: models.FromTime(time.Now()),
}
if err := db.Create(&config).Error; err != nil {
log.Fatal("Failed to create default config:", err)
@@ -82,7 +85,16 @@ func Update(cst map[string]interface{}) error {
if newDisablePasswordLogin && !newOAuthEnabled {
return errors.New("at least one login method must be enabled (password/oauth)")
}
// 没绑定账号也不能禁用
if newDisablePasswordLogin {
usr := &models.User{}
if err := db.Model(&models.User{}).First(usr).Error; err != nil {
return errors.Join(err, errors.New("failed to retrieve user"))
}
if usr.SSOID == "" {
return errors.New("cannot disable password login when no SSO-bound account exists")
}
}
err := db.Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&models.Config{}).Where("id = ?", oldConfig.ID).Updates(cst).Error; err != nil {
return errors.Join(err, errors.New("failed to update configuration"))
+8 -1
View File
@@ -245,7 +245,6 @@ func GetDBInstance() *gorm.DB {
log.Fatalf("Failed to connect to MySQL database: %v", err)
}
log.Printf("Using MySQL database: %s@%s:%s/%s", flags.DatabaseUser, flags.DatabaseHost, flags.DatabasePort, flags.DatabaseName)
default:
log.Fatalf("Unsupported database type: %s", flags.DatabaseType)
}
@@ -255,6 +254,7 @@ func GetDBInstance() *gorm.DB {
&models.User{},
&models.Client{},
&models.Record{},
&models.GPURecord{},
&models.Config{},
&models.Log{},
&models.Clipboard{},
@@ -264,6 +264,7 @@ func GetDBInstance() *gorm.DB {
&models.PingTask{},
&models.OidcProvider{},
&models.MessageSenderProvider{},
&models.ThemeConfiguration{},
)
if err != nil {
log.Fatalf("Failed to create tables: %v", err)
@@ -274,6 +275,12 @@ func GetDBInstance() *gorm.DB {
if err != nil {
log.Printf("Failed to create records_long_term table, it may already exist: %v", err)
}
err = instance.Table("gpu_records_long_term").AutoMigrate(
&models.GPURecord{},
)
if err != nil {
log.Printf("Failed to create gpu_records_long_term table, it may already exist: %v", err)
}
err = instance.AutoMigrate(
&models.Session{},
)
+18 -10
View File
@@ -1,15 +1,20 @@
package models
type Config struct {
ID uint `json:"id,omitempty" gorm:"primaryKey;autoIncrement"` // 1
Sitename string `json:"sitename" gorm:"type:varchar(100);not null"`
Description string `json:"description" gorm:"type:text"`
AllowCors bool `json:"allow_cors" gorm:"column:allow_cors;default:false"`
Theme string `json:"theme" gorm:"type:varchar(100);default:'default'"` // 主题名称,默认 'default'
PrivateSite bool `json:"private_site" gorm:"default:false"` // 是否为私有站点,默认 false
ID uint `json:"id,omitempty" gorm:"primaryKey;autoIncrement"` // 1
Sitename string `json:"sitename" gorm:"type:varchar(100);not null"`
Description string `json:"description" gorm:"type:text"`
AllowCors bool `json:"allow_cors" gorm:"column:allow_cors;default:false"`
Theme string `json:"theme" gorm:"type:varchar(100);default:'default'"` // 主题名称,默认 'default'
PrivateSite bool `json:"private_site" gorm:"default:false"` // 是否为私有站点,默认 false
ApiKey string `json:"api_key" gorm:"type:varchar(255);default:''"`
AutoDiscoveryKey string `json:"auto_discovery_key" gorm:"type:varchar(255);default:''"` // 自动发现密钥
// GeoIP 配置
GeoIpEnabled bool `json:"geo_ip_enabled" gorm:"default:true"`
GeoIpProvider string `json:"geo_ip_provider" gorm:"type:varchar(20);default:'ip-api'"` // empty, mmdb, ip-api, geojs
// Nezha 兼容(Agent gRPC)
NezhaCompatEnabled bool `json:"nezha_compat_enabled" gorm:"default:false"`
NezhaCompatListen string `json:"nezha_compat_listen" gorm:"type:varchar(100);default:''"` // 例如 0.0.0.0:5555
// OAuth 配置
OAuthEnabled bool `json:"o_auth_enabled" gorm:"default:false"`
OAuthProvider string `json:"o_auth_provider" gorm:"type:varchar(50);default:'github'"`
@@ -18,10 +23,13 @@ type Config struct {
CustomHead string `json:"custom_head" gorm:"type:longtext"`
CustomBody string `json:"custom_body" gorm:"type:longtext"`
// 通知
NotificationEnabled bool `json:"notification_enabled" gorm:"default:false"` // 通知总开关
NotificationMethod string `json:"notification_method" gorm:"type:varchar(64);default:'none'"`
ExpireNotificationEnabled bool `json:"expire_notification_enabled" gorm:"default:false"` // 是否启用过期通知
ExpireNotificationLeadDays int `json:"expire_notification_lead_days" gorm:"default:7"` // 过期前多少天通知,默认7天
NotificationEnabled bool `json:"notification_enabled" gorm:"default:false"` // 通知总开关
NotificationMethod string `json:"notification_method" gorm:"type:varchar(64);default:'none'"`
NotificationTemplate string `json:"notification_template" gorm:"type:longtext;default:'{{event}}: {{client}}\n{{time}}'"`
ExpireNotificationEnabled bool `json:"expire_notification_enabled" gorm:"default:false"` // 是否启用过期通知
ExpireNotificationLeadDays int `json:"expire_notification_lead_days" gorm:"default:7"` // 过期前多少天通知,默认7天
LoginNotification bool `json:"login_notification" gorm:"default:false"` // 登录通知
TrafficLimitPercentage float64 `json:"traffic_limit_percentage" gorm:"default:80.00"` // 流量限制百分比,默认80.00%
// Record
RecordEnabled bool `json:"record_enabled" gorm:"default:true"` // 是否启用记录功能
RecordPreserveTime int `json:"record_preserve_time" gorm:"default:720"` // 记录保留时间,单位小时,默认30天
@@ -0,0 +1,11 @@
package messageevent
const (
Offline = "Offline"
Online = "Online"
Expire = "Expire"
Renew = "Renew"
Login = "Login"
Alert = "Alert"
Traffic = "Traffic"
)
+10
View File
@@ -1,6 +1,16 @@
package models
import "time"
type MessageSenderProvider struct {
Name string `json:"name" gorm:"primaryKey;unique;not null"`
Addition string `json:"addition" gorm:"type:longtext" default:"{}"`
}
type EventMessage struct {
Event string `json:"event"`
Clients []Client `json:"clients"`
Time time.Time `json:"time"`
Message string `json:"message"`
Emoji string `json:"emoji"`
}
+45 -29
View File
@@ -8,35 +8,38 @@ import (
// Client represents a registered client device
type Client struct {
UUID string `json:"uuid,omitempty" gorm:"type:varchar(36);primaryKey"`
Token string `json:"token,omitempty" gorm:"type:varchar(255);unique;not null"`
Name string `json:"name" gorm:"type:varchar(100)"`
CpuName string `json:"cpu_name" gorm:"type:varchar(100)"`
Virtualization string `json:"virtualization" gorm:"type:varchar(50)"`
Arch string `json:"arch" gorm:"type:varchar(50)"`
CpuCores int `json:"cpu_cores" gorm:"type:int"`
OS string `json:"os" gorm:"type:varchar(100)"`
KernelVersion string `json:"kernel_version" gorm:"type:varchar(100)"`
GpuName string `json:"gpu_name" gorm:"type:varchar(100)"`
IPv4 string `json:"ipv4,omitempty" gorm:"type:varchar(100)"`
IPv6 string `json:"ipv6,omitempty" gorm:"type:varchar(100)"`
Region string `json:"region" gorm:"type:varchar(100)"`
Remark string `json:"remark,omitempty" gorm:"type:longtext"`
PublicRemark string `json:"public_remark,omitempty" gorm:"type:longtext"`
MemTotal int64 `json:"mem_total" gorm:"type:bigint"`
SwapTotal int64 `json:"swap_total" gorm:"type:bigint"`
DiskTotal int64 `json:"disk_total" gorm:"type:bigint"`
Version string `json:"version,omitempty" gorm:"type:varchar(100)"`
Weight int `json:"weight" gorm:"type:int"`
Price float64 `json:"price"`
BillingCycle int `json:"billing_cycle"`
AutoRenewal bool `json:"auto_renewal" gorm:"default:false"` // 是否自动续费
Currency string `json:"currency" gorm:"type:varchar(20);default:'$'"`
ExpiredAt LocalTime `json:"expired_at" gorm:"type:timestamp"`
Group string `json:"group" gorm:"type:varchar(100)"`
Tags string `json:"tags" gorm:"type:text"` // split by ';'
CreatedAt LocalTime `json:"created_at"`
UpdatedAt LocalTime `json:"updated_at"`
UUID string `json:"uuid,omitempty" gorm:"type:varchar(36);primaryKey"`
Token string `json:"token,omitempty" gorm:"type:varchar(255);unique;not null"`
Name string `json:"name" gorm:"type:varchar(100)"`
CpuName string `json:"cpu_name" gorm:"type:varchar(100)"`
Virtualization string `json:"virtualization" gorm:"type:varchar(50)"`
Arch string `json:"arch" gorm:"type:varchar(50)"`
CpuCores int `json:"cpu_cores" gorm:"type:int"`
OS string `json:"os" gorm:"type:varchar(100)"`
KernelVersion string `json:"kernel_version" gorm:"type:varchar(100)"`
GpuName string `json:"gpu_name" gorm:"type:varchar(100)"`
IPv4 string `json:"ipv4,omitempty" gorm:"type:varchar(100)"`
IPv6 string `json:"ipv6,omitempty" gorm:"type:varchar(100)"`
Region string `json:"region" gorm:"type:varchar(100)"`
Remark string `json:"remark,omitempty" gorm:"type:longtext"`
PublicRemark string `json:"public_remark,omitempty" gorm:"type:longtext"`
MemTotal int64 `json:"mem_total" gorm:"type:bigint"`
SwapTotal int64 `json:"swap_total" gorm:"type:bigint"`
DiskTotal int64 `json:"disk_total" gorm:"type:bigint"`
Version string `json:"version,omitempty" gorm:"type:varchar(100)"`
Weight int `json:"weight" gorm:"type:int"`
Price float64 `json:"price"`
BillingCycle int `json:"billing_cycle"`
AutoRenewal bool `json:"auto_renewal" gorm:"default:false"` // 是否自动续费
Currency string `json:"currency" gorm:"type:varchar(20);default:'$'"`
ExpiredAt LocalTime `json:"expired_at" gorm:"type:timestamp"`
Group string `json:"group" gorm:"type:varchar(100)"`
Tags string `json:"tags" gorm:"type:text"` // split by ';'
Hidden bool `json:"hidden" gorm:"default:false"`
TrafficLimit int64 `json:"traffic_limit" gorm:"type:bigint"`
TrafficLimitType string `json:"traffic_limit_type" gorm:"type:varchar(10);default:'max'"` // 流量阈值类型:sum max min up down
CreatedAt LocalTime `json:"created_at"`
UpdatedAt LocalTime `json:"updated_at"`
}
// User represents an authenticated user
@@ -87,6 +90,19 @@ type Record struct {
Process int `json:"process"`
Connections int `json:"connections"`
ConnectionsUdp int `json:"connections_udp"`
//Uptime int64 `json:"uptime" gorm:"type:bigint"`
}
// GPURecord logs individual GPU metrics over time
type GPURecord struct {
Client string `json:"client" gorm:"type:varchar(36);index"` // 客户端UUID
Time LocalTime `json:"time" gorm:"index"` // 记录时间
DeviceIndex int `json:"device_index" gorm:"index"` // GPU设备索引 (0,1,2...)
DeviceName string `json:"device_name" gorm:"type:varchar(100)"` // GPU型号
MemTotal int64 `json:"mem_total" gorm:"type:bigint"` // 显存总量(字节)
MemUsed int64 `json:"mem_used" gorm:"type:bigint"` // 显存使用(字节)
Utilization float32 `json:"utilization" gorm:"type:decimal(5,2)"` // GPU使用率(%)
Temperature int `json:"temperature"` // GPU温度(°C)
}
// StringArray represents a slice of strings stored as JSON in the database
+30 -7
View File
@@ -2,11 +2,34 @@ package models
// Theme represents a komari theme information
type Theme struct {
Name string `json:"name"` // 主题名称
Short string `json:"short"` // 短名称,用作文件夹名
Description string `json:"description"` // 主题描述
Version string `json:"version"` // 版本号
Author string `json:"author"` // 作者
URL string `json:"url"` // 主题URL
Preview string `json:"preview"` // 预览图片相对路径
Name string `json:"name"` // 主题名称
Short string `json:"short"` // 短名称,用作文件夹名
Description string `json:"description"` // 主题描述
Version string `json:"version"` // 版本号
Author string `json:"author"` // 作者
URL string `json:"url"` // 主题URL
Preview string `json:"preview"` // 预览图片相对路径
Configuration Configuration `json:"configuration"` // 声明配置项
}
type Configuration struct {
Type string `json:"type"` // managed
Icon string `json:"icon"` // 图标
Name string `json:"name"`
Data any `json:"data"` // 配置数据
}
type ManagedThemeConfigurationItem struct {
Key string `json:"key"`
Name string `json:"name"`
Required bool `json:"required"`
Type string `json:"type"` // string number select switch title
Options string `json:"options"`
Default any `json:"default"`
Help string `json:"help"`
}
type ThemeConfiguration struct {
Short string `json:"short" gorm:"primaryKey;unique;not null"`
Data string `json:"data" gorm:"type:longtext" default:"{}"`
}
+202
View File
@@ -18,14 +18,61 @@ func RecordOne(rec models.Record) error {
return db.Create(&rec).Error
}
func RecordGPU(rec models.GPURecord) error {
db := dbcore.GetDBInstance()
return db.Create(&rec).Error
}
func DeleteAll() error {
db := dbcore.GetDBInstance()
if err := db.Exec("DELETE FROM records_long_term").Error; err != nil {
return err
}
if err := db.Exec("DELETE FROM gpu_records_long_term").Error; err != nil {
return err
}
if err := db.Exec("DELETE FROM gpu_records").Error; err != nil {
return err
}
return db.Exec("DELETE FROM records").Error
}
// GetGPURecordsByClientAndTime 获取GPU记录数据
func GetGPURecordsByClientAndTime(uuid string, start, end time.Time) ([]models.GPURecord, error) {
db := dbcore.GetDBInstance()
var records []models.GPURecord
fourHoursAgo := time.Now().Add(-4*time.Hour - time.Minute)
var recentRecords []models.GPURecord
recentStart := start
if end.After(fourHoursAgo) {
if recentStart.Before(fourHoursAgo) {
recentStart = fourHoursAgo
}
err := db.Where("client = ? AND time >= ? AND time <= ?", uuid, recentStart, end).
Order("time ASC, device_index ASC").Find(&recentRecords).Error
if err != nil {
log.Printf("Error fetching recent GPU records for client %s between %s and %s: %v", uuid, recentStart, end, err)
return nil, err
}
}
var longTermRecords []models.GPURecord
err := db.Table("gpu_records_long_term").Where("client = ? AND time >= ? AND time <= ?", uuid, start, end).
Order("time ASC, device_index ASC").Find(&longTermRecords).Error
if err != nil {
log.Printf("Error fetching long-term GPU records for client %s between %s and %s: %v", uuid, start, end, err)
return recentRecords, nil
}
// 合并结果 - 不再需要类型转换
records = append(records, recentRecords...)
records = append(records, longTermRecords...)
return records, nil
}
func GetLatestRecord(uuid string) (Record []models.Record, err error) {
db := dbcore.GetDBInstance()
err = db.Where("client = ?", uuid).Order("time DESC").Limit(1).Find(&Record).Error
@@ -35,6 +82,8 @@ func GetLatestRecord(uuid string) (Record []models.Record, err error) {
func DeleteRecordBefore(before time.Time) error {
db := dbcore.GetDBInstance()
db.Table("records_long_term").Where("time < ?", before).Delete(&models.Record{})
db.Table("gpu_records_long_term").Where("time < ?", before).Delete(&models.GPURecord{})
db.Where("time < ?", before).Delete(&models.GPURecord{})
return db.Where("time < ?", before).Delete(&models.Record{}).Error
}
@@ -117,6 +166,12 @@ func CompactRecord() error {
return err
}
err = migrateGPURecords(db)
if err != nil {
log.Printf("Error migrating GPU records: %v", err)
return err
}
if flags.DatabaseType == "sqlite" {
if err := db.Exec("VACUUM").Error; err != nil {
log.Printf("Error vacuuming database: %v", err)
@@ -159,6 +214,7 @@ func migrateOldRecords(db *gorm.DB) error {
Process []int
Connections []int
ConnectionsUdp []int
Uptime []int64
}
groupedRecords := make(map[string]*groupData)
@@ -185,6 +241,7 @@ func migrateOldRecords(db *gorm.DB) error {
data.Process = append(data.Process, record.Process)
data.Connections = append(data.Connections, record.Connections)
data.ConnectionsUdp = append(data.ConnectionsUdp, record.ConnectionsUdp)
//data.Uptime = append(data.Uptime, record.Uptime)
}
getPercentile := func(values []float64, percentile float64) float64 {
@@ -279,6 +336,7 @@ func migrateOldRecords(db *gorm.DB) error {
Process: getInt32Percentile(data.Process, high_percentile),
Connections: getInt32Percentile(data.Connections, high_percentile),
ConnectionsUdp: getInt32Percentile(data.ConnectionsUdp, high_percentile),
//Uptime: getIntPercentile(data.Uptime, high_percentile),
}
// 如果记录已存在则更新,否则创建新记录
@@ -301,3 +359,147 @@ func migrateOldRecords(db *gorm.DB) error {
return nil
})
}
// migrateGPURecords 压缩GPU记录数据
func migrateGPURecords(db *gorm.DB) error {
fourHoursAgo := time.Now().Add(-4 * time.Hour)
// 查询超过4小时的GPU记录
var gpuRecords []models.GPURecord
if err := db.Where("time < ?", fourHoursAgo).Find(&gpuRecords).Error; err != nil {
return err
}
if len(gpuRecords) == 0 {
return nil
}
// 按Client + DeviceIndex + 15分钟时间窗口分组
type gpuGroupKey struct {
Client string
DeviceIndex int
TimeSlot time.Time
DeviceName string
}
type gpuGroupData struct {
MemTotal []int64
MemUsed []int64
Utilization []float32
Temperature []int
}
groupedGPUs := make(map[gpuGroupKey]*gpuGroupData)
for _, record := range gpuRecords {
key := gpuGroupKey{
Client: record.Client,
DeviceIndex: record.DeviceIndex,
TimeSlot: record.Time.ToTime().Truncate(15 * time.Minute),
DeviceName: record.DeviceName,
}
if _, ok := groupedGPUs[key]; !ok {
groupedGPUs[key] = &gpuGroupData{}
}
data := groupedGPUs[key]
data.MemTotal = append(data.MemTotal, record.MemTotal)
data.MemUsed = append(data.MemUsed, record.MemUsed)
data.Utilization = append(data.Utilization, record.Utilization)
data.Temperature = append(data.Temperature, record.Temperature)
}
// 百分位数计算函数 (复用传统Record压缩逻辑)
getPercentile := func(values []float64, percentile float64) float64 {
if len(values) == 0 {
return 0
}
sortedValues := make([]float64, len(values))
copy(sortedValues, values)
sort.Float64s(sortedValues)
index := float64(len(sortedValues)-1) * percentile
lowerIndex := int(index)
if lowerIndex >= len(sortedValues)-1 {
return sortedValues[len(sortedValues)-1]
}
frac := index - float64(lowerIndex)
return sortedValues[lowerIndex] + frac*(sortedValues[lowerIndex+1]-sortedValues[lowerIndex])
}
getIntPercentile := func(values []int64, percentile float64) int64 {
if len(values) == 0 {
return 0
}
floats := make([]float64, len(values))
for i, v := range values {
floats[i] = float64(v)
}
return int64(getPercentile(floats, percentile))
}
// 温度数据转换辅助函数
convertIntToInt64 := func(values []int) []int64 {
result := make([]int64, len(values))
for i, v := range values {
result[i] = int64(v)
}
return result
}
getFloat32Percentile := func(values []float32, percentile float64) float32 {
if len(values) == 0 {
return 0
}
floats := make([]float64, len(values))
for i, v := range values {
floats[i] = float64(v)
}
return float32(getPercentile(floats, percentile))
}
// 保持与传统Record压缩的一致性
high_percentile := 0.7
return db.Transaction(func(tx *gorm.DB) error {
for key, data := range groupedGPUs {
// 检查是否已存在记录
var existingCount int64
if err := tx.Table("gpu_records_long_term").Where("client = ? AND device_index = ? AND time = ?",
key.Client, key.DeviceIndex, key.TimeSlot).Count(&existingCount).Error; err != nil {
return err
}
compressedGPU := models.GPURecord{
Client: key.Client,
DeviceIndex: key.DeviceIndex,
Time: models.FromTime(key.TimeSlot),
DeviceName: key.DeviceName,
MemTotal: getIntPercentile(data.MemTotal, high_percentile),
MemUsed: getIntPercentile(data.MemUsed, high_percentile),
Utilization: getFloat32Percentile(data.Utilization, high_percentile),
Temperature: int(getIntPercentile(convertIntToInt64(data.Temperature), high_percentile)),
}
if existingCount > 0 {
// 更新已存在记录
if err := tx.Table("gpu_records_long_term").Where("client = ? AND device_index = ? AND time = ?",
key.Client, key.DeviceIndex, key.TimeSlot).Updates(&compressedGPU).Error; err != nil {
return err
}
} else {
// 创建新记录
if err := tx.Table("gpu_records_long_term").Create(&compressedGPU).Error; err != nil {
return err
}
}
}
// 删除已压缩的原始GPU数据
if err := tx.Where("time < ?", fourHoursAgo.Add(-1*time.Hour)).Delete(&models.GPURecord{}).Error; err != nil {
return err
}
return nil
})
}
+10 -11
View File
@@ -61,15 +61,6 @@ func SavePingRecord(record models.PingRecord) error {
return db.Create(&record).Error
}
func GetPingRecords(client string) ([]models.PingRecord, error) {
db := dbcore.GetDBInstance()
var records []models.PingRecord
if err := db.Where("client = ?", client).Order("time DESC").Find(&records).Error; err != nil {
return nil, err
}
return records, nil
}
func DeletePingRecordsBefore(time time.Time) error {
db := dbcore.GetDBInstance()
err := db.Where("time < ?", time).Delete(&models.PingRecord{}).Error
@@ -101,10 +92,18 @@ func ReloadPingSchedule() error {
}
return utils.ReloadPingSchedule(pingTasks)
}
func GetPingRecordsByClientAndTime(uuid string, start, end time.Time) ([]models.PingRecord, error) {
func GetPingRecords(uuid string, taskId int, start, end time.Time) ([]models.PingRecord, error) {
db := dbcore.GetDBInstance()
var records []models.PingRecord
if err := db.Where("client = ? AND time >= ? AND time <= ?", uuid, start, end).Order("time DESC").Find(&records).Error; err != nil {
dbQuery := db.Model(&models.PingRecord{})
if uuid != "" {
dbQuery = dbQuery.Where("client = ?", uuid)
}
if taskId >= 0 {
dbQuery = dbQuery.Where("task_id = ?", uint(taskId))
}
if err := dbQuery.Where("time >= ? AND time <= ?", start, end).Order("time DESC").Find(&records).Error; err != nil {
return nil, err
}
return records, nil
+1 -1
View File
@@ -83,7 +83,7 @@ func DeleteTaskByTaskId(taskId string) error {
return dbcore.GetDBInstance().Where("task_id = ?", taskId).Delete(&models.Task{}).Error
}
func SaveTaskResult(taskId, clientId, result string, exitCode int, timestamp time.Time) error {
func SaveTaskResult(taskId, clientId, result string, exitCode int, timestamp models.LocalTime) error {
return dbcore.GetDBInstance().
Model(&models.TaskResult{}).
Where("task_id = ? AND client = ?", taskId, clientId).
+101
View File
@@ -0,0 +1,101 @@
package database
import (
"encoding/json"
"log"
"os"
"path/filepath"
"strings"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/database/config"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
)
func GetPublicInfo() (any, error) {
cst, err := config.Get()
if err != nil {
return nil, err
}
db := dbcore.GetDBInstance()
tc := models.ThemeConfiguration{}
err = db.Model(&models.ThemeConfiguration{}).Where("short = ?", cst.Theme).First(&tc).Error
if err != nil {
tc.Data = "{}"
}
tc_data := gin.H{}
err = json.Unmarshal([]byte(tc.Data), &tc_data)
if err != nil {
log.Printf("%v", err)
}
// Try to load theme declaration file and merge defaults for managed configuration
// Theme declarations live in ./data/theme/<short>/komari-theme.json
if cst.Theme != "" && cst.Theme != "default" {
themeConfigPath := filepath.Join("./data/theme", cst.Theme, "komari-theme.json")
if _, err := os.Stat(themeConfigPath); err == nil {
b, err := os.ReadFile(themeConfigPath)
if err == nil {
var themeDecl struct {
Configuration struct {
Type string `json:"type"`
Data []models.ManagedThemeConfigurationItem `json:"data"`
} `json:"configuration"`
}
if err := json.Unmarshal(b, &themeDecl); err == nil {
if themeDecl.Configuration.Type == "managed" {
for _, item := range themeDecl.Configuration.Data {
if item.Key == "" {
continue
}
// missing
if _, exists := tc_data[item.Key]; !exists {
var def any = item.Default
// select
if item.Type == "select" {
if def == nil || def == "" {
if item.Options != "" {
opts := strings.Split(item.Options, ",")
if len(opts) > 0 {
def = strings.TrimSpace(opts[0])
}
}
}
}
// number->0, string->"", switch->false
if def == nil {
switch item.Type {
case "number":
def = 0
case "switch":
def = false
default:
def = ""
}
}
tc_data[item.Key] = def
}
}
}
}
}
}
}
return gin.H{
"sitename": cst.Sitename,
"description": cst.Description,
"custom_head": cst.CustomHead,
"custom_body": cst.CustomBody,
"oauth_enable": cst.OAuthEnabled,
"oauth_provider": cst.OAuthProvider,
"disable_password_login": cst.DisablePasswordLogin,
"allow_cors": cst.AllowCors,
"record_enabled": cst.RecordEnabled,
"record_preserve_time": cst.RecordPreserveTime,
"ping_record_preserve_time": cst.PingRecordPreserveTime,
"private_site": cst.PrivateSite,
"theme": cst.Theme,
"theme_settings": tc_data,
}, nil
}
+18 -2
View File
@@ -107,8 +107,24 @@ sudo ./install-komari.sh
IssueやPull Requestを歓迎します!
## 謝辞
- こんなに暇でいられる自分に感謝
- PRを提出し、テーマを作成してくれたすべての開発者
### DreamCloud
[DreamCloud - コストパフォーマンスに優れたアジア太平洋向け高防御直通](https://as211392.com/)
### 🚀 SharonNetworks スポンサー
[![Sharon Networks](https://raw.githubusercontent.com/komari-monitor/public/refs/heads/main/images/sharon-networks.webp)](https://sharon.io)
SharonNetworks は、あなたのビジネスの離陸を力強くサポートします!
アジア太平洋のデータセンターから中国最適化ネットワーク接続を提供。低レイテンシー & 高帯域幅、Tbps 級のローカル洗浄 DDoS 防御で、ビジネスとお客様の体験を守ります。コミュニティ [Telegram グループ](https://t.me/SharonNetwork) に参加すると、チャリティまたはグループ内抽選で無料利用のチャンスがあります。
### オープンソースコミュニティ
PR を送ってくれた方、テーマを作成してくれた全ての開発者
—— そして:こんなに暇でいられる自分に感謝
## Star履歴
+18 -3
View File
@@ -108,9 +108,24 @@ sudo ./install-komari.sh
歡迎提交 Issue 或 Pull Request!
## 鳴謝
- [DreamCloud - 极高性价比解锁直连亚太高防](https://as211392.com/)
- 感謝我自己能這麼閒
- 提交PR、製作主題的各位開發者
### DreamCloud
[DreamCloud - 極高性價比解鎖直連亞太高防](https://as211392.com/)
### 🚀 由 SharonNetworks 贊助
[![Sharon Networks](https://raw.githubusercontent.com/komari-monitor/public/refs/heads/main/images/sharon-networks.webp)](https://sharon.io)
SharonNetworks 為您的業務起飛保駕護航!
亞太資料中心提供頂級的中國優化網路接入 · 低延遲 & 高頻寬 & 提供 Tbps 級本地清洗高防服務,為您的業務保駕護航,為您的客戶提供極致體驗。加入社群 [Telegram 群組](https://t.me/SharonNetwork) 可參與公益募捐或群內抽獎免費使用。
### 開源社群
提交 PR、製作主題的各位開發者
—— 以及:感謝我自己能這麼閒
## Star History
+18 -3
View File
@@ -108,9 +108,24 @@ sudo ./install-komari.sh
欢迎提交 Issue 或 Pull Request!
## 鸣谢
- [DreamCloud - 极高性价比解锁直连亚太高防](https://as211392.com/)
- 感谢我自己能这么闲
- 提交PR、制作主题的各位开发者
### DreamCloud
[DreamCloud - 极高性价比解锁直连亚太高防](https://as211392.com/)
### 🚀 由 SharonNetworks 赞助
[![Sharon Networks](https://raw.githubusercontent.com/komari-monitor/public/refs/heads/main/images/sharon-networks.webp)](https://sharon.io)
SharonNetworks 为您的业务起飞保驾护航!
亚太数据中心提供顶级的中国优化网络接入 · 低延时 & 高带宽 & 提供 Tbps 级本地清洗高防服务,为您的业务保驾护航,为您的客户提供极致体验。加入社区 [Telegram 群组](https://t.me/SharonNetwork) 可参与公益募捐或群内抽奖免费使用。
### 开源社区
提交 PR、制作主题的各位开发者
—— 以及:感谢我自己能这么闲
## Star History
+10 -6
View File
@@ -3,6 +3,7 @@ module github.com/komari-monitor/komari
go 1.23.2
require (
github.com/coreos/go-oidc/v3 v3.15.0
github.com/gin-gonic/gin v1.10.0
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
@@ -11,6 +12,8 @@ require (
github.com/pquerna/otp v1.5.0
github.com/spf13/cobra v1.9.1
github.com/stretchr/testify v1.10.0
google.golang.org/grpc v1.75.0
google.golang.org/protobuf v1.36.6
gorm.io/driver/mysql v1.5.7
gorm.io/driver/sqlite v1.5.7
gorm.io/gorm v1.26.1
@@ -19,11 +22,11 @@ require (
// Misuse of ServerConfig.PublicKeyCallback may cause authorization bypass in golang.org/x/crypto #1
// golang.org/x/crypto Vulnerable to Denial of Service (DoS) via Slow or Incomplete Key Exchange #3
require golang.org/x/crypto v0.37.0 // indirect
require golang.org/x/crypto v0.39.0 // indirect
// HTTP Proxy bypass using IPv6 Zone IDs in golang.org/x/net #2
// golang.org/x/net vulnerable to Cross-site Scripting #4
require golang.org/x/net v0.39.0 // indirect
require golang.org/x/net v0.41.0 // indirect
require (
filippo.io/edwards25519 v1.1.0 // indirect
@@ -34,12 +37,12 @@ require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
github.com/gin-contrib/sse v1.0.0 // indirect
github.com/go-jose/go-jose/v4 v4.1.1 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.26.0 // indirect
github.com/go-sql-driver/mysql v1.9.2 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/google/go-cmp v0.6.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
@@ -58,9 +61,10 @@ require (
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
golang.org/x/arch v0.15.0 // indirect
golang.org/x/sys v0.32.0 // indirect
golang.org/x/text v0.25.0 // indirect
google.golang.org/protobuf v1.36.6 // indirect
golang.org/x/oauth2 v0.30.0 // indirect
golang.org/x/sys v0.33.0 // indirect
golang.org/x/text v0.26.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250707201910-8d1bb00bc6a7 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+40 -10
View File
@@ -10,6 +10,8 @@ github.com/bytedance/sonic/loader v0.2.4/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFos
github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4=
github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/coreos/go-oidc/v3 v3.15.0 h1:R6Oz8Z4bqWR7VFQ+sPSvZPQv4x8M+sJkDO5ojgwlyAg=
github.com/coreos/go-oidc/v3 v3.15.0/go.mod h1:HaZ3szPaZ0e4r6ebqvsLWlk2Tn+aejfmrfah6hnSYEU=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -21,6 +23,12 @@ github.com/gin-contrib/sse v1.0.0 h1:y3bT1mUWUxDpW4JLQg/HnTqV4rozuW4tC9eFKTxYI9E
github.com/gin-contrib/sse v1.0.0/go.mod h1:zNuFdwarAygJBht0NTKiSi3jRf6RbqeILZ9Sp6Slhe0=
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/go-jose/go-jose/v4 v4.1.1 h1:JYhSgy4mXXzAdF3nUx3ygx347LRXJRrpgyU3adRmkAI=
github.com/go-jose/go-jose/v4 v4.1.1/go.mod h1:BdsZGqgdO3b6tTc6LSE56wcDbMMLuPsw5d4ZD5f94kA=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
@@ -34,8 +42,10 @@ github.com/go-sql-driver/mysql v1.9.2 h1:4cNKDYQ1I84SXslGddlsrMhc8k4LeDVj6Ad6WRj
github.com/go-sql-driver/mysql v1.9.2/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
@@ -107,17 +117,37 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI=
go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg=
go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc=
go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps=
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
golang.org/x/arch v0.15.0 h1:QtOrQd0bTUnhNVNndMpLHNWrDmYzZ2KDqSrEymqInZw=
golang.org/x/arch v0.15.0/go.mod h1:JmwW7aLIoRUKgaTzhkiEFxvcEiQGyOg9BMonBJUS7EE=
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250707201910-8d1bb00bc6a7 h1:pFyd6EwwL2TqFf8emdthzeX+gZE1ElRq3iM8pui4KBY=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250707201910-8d1bb00bc6a7/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
google.golang.org/grpc v1.75.0 h1:+TW+dqTd2Biwe6KKfhE5JpiYIBWq865PhKGSXiivqt4=
google.golang.org/grpc v1.75.0/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ=
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+1 -1
View File
@@ -26,7 +26,7 @@ log_step() {
# Global variables
INSTALL_DIR="/opt/komari"
DATA_DIR="/opt/komari/data"
DATA_DIR="/opt/komari"
SERVICE_NAME="komari"
BINARY_PATH="$INSTALL_DIR/komari"
DEFAULT_PORT="25774"
+31
View File
@@ -0,0 +1,31 @@
package utils
import "github.com/gin-gonic/gin"
// https://github.com/labstack/echo/blob/98ca08e7dd64075b858e758d6693bf9799340756/context.go#L275-L294
func GetScheme(c *gin.Context) string {
// Can't use `r.Request.URL.Scheme`
// See: https://groups.google.com/forum/#!topic/golang-nuts/pMUkBlQBDF0
if c.Request.TLS != nil {
return "https"
}
if scheme := c.Request.Header.Get("X-Forwarded-Proto"); scheme != "" {
return scheme
}
if scheme := c.Request.Header.Get("X-Forwarded-Protocol"); scheme != "" {
return scheme
}
if ssl := c.Request.Header.Get("X-Forwarded-Ssl"); ssl == "on" {
return "https"
}
if scheme := c.Request.Header.Get("X-Url-Scheme"); scheme != "" {
return scheme
}
return "http"
}
func GetCallbackURL(c *gin.Context) string {
scheme := GetScheme(c)
host := c.Request.Host
return scheme + "://" + host + "/api/oauth_callback"
}
+5 -1
View File
@@ -20,10 +20,14 @@ func Parse(v any) []Item {
var items []Item
for i := 0; i < val.NumField(); i++ {
field := val.Type().Field(i)
typ := field.Tag.Get("type")
if typ != "option" {
typ = field.Type.Name()
}
item := Item{
Name: field.Tag.Get("json"),
Required: field.Tag.Get("required") == "true",
Type: field.Type.Name(),
Type: typ,
Options: field.Tag.Get("options"),
Default: field.Tag.Get("default"),
Help: field.Tag.Get("help"),
+2
View File
@@ -1,9 +1,11 @@
package messageSender
import (
_ "github.com/komari-monitor/komari/utils/messageSender/bark"
_ "github.com/komari-monitor/komari/utils/messageSender/email"
_ "github.com/komari-monitor/komari/utils/messageSender/empty"
_ "github.com/komari-monitor/komari/utils/messageSender/telegram"
_ "github.com/komari-monitor/komari/utils/messageSender/webhook"
)
func All() {
+124
View File
@@ -0,0 +1,124 @@
package bark
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
"github.com/komari-monitor/komari/utils/messageSender/factory"
)
type BarkSender struct {
Addition
}
func (b *BarkSender) GetName() string {
return "bark"
}
func (b *BarkSender) GetConfiguration() factory.Configuration {
return &b.Addition
}
func (b *BarkSender) Init() error {
// 验证 ServerURL 格式
if b.Addition.ServerURL != "" {
if _, err := url.Parse(b.Addition.ServerURL); err != nil {
return fmt.Errorf("invalid server URL: %v", err)
}
}
return nil
}
func (b *BarkSender) Destroy() error {
return nil
}
func (b *BarkSender) SendTextMessage(message, title string) error {
if b.Addition.DeviceKey == "" {
return fmt.Errorf("device key is required")
}
if message == "" {
return fmt.Errorf("message is empty")
}
// 准备请求数据
payload := map[string]interface{}{
"body": message,
"device_key": b.Addition.DeviceKey,
}
// 如果有标题,添加标题
if title != "" {
payload["title"] = title
}
// 添加可选参数
if b.Addition.Icon != "" {
payload["icon"] = b.Addition.Icon
}
if b.Addition.Level != "" {
payload["level"] = b.Addition.Level
}
// 序列化 JSON
jsonData, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("failed to marshal payload: %v", err)
}
// 构建请求 URL
serverURL := b.Addition.ServerURL
if serverURL == "" {
serverURL = "https://api.day.app"
}
// 确保 URL 以正确的格式结尾
serverURL = strings.TrimRight(serverURL, "/")
requestURL := fmt.Sprintf("%s/push", serverURL)
// 发送 HTTP POST 请求
resp, err := http.Post(requestURL, "application/json", bytes.NewBuffer(jsonData))
if err != nil {
return fmt.Errorf("failed to send request: %v", err)
}
defer resp.Body.Close()
// 检查响应状态
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("bark API returned non-OK status: %d", resp.StatusCode)
}
// 解析响应
var result struct {
Code int `json:"code"`
Message string `json:"message"`
Data interface{} `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
// 如果解析失败,认为发送成功(某些 Bark 服务器可能返回纯文本)
return nil
}
// 检查 Bark API 响应
if result.Code != 200 {
return fmt.Errorf("bark API error (code: %d): %s", result.Code, result.Message)
}
return nil
}
func init() {
factory.RegisterMessageSender(func() factory.IMessageSender {
return &BarkSender{}
})
}
// 确保实现了 IMessageSender 接口
var _ factory.IMessageSender = (*BarkSender)(nil)
+18
View File
@@ -0,0 +1,18 @@
package bark
import (
"github.com/komari-monitor/komari/utils/messageSender/factory"
)
type Addition struct {
ServerURL string `json:"server_url" required:"true" default:"https://api.day.app" help:"Bark server URL, e.g., https://api.day.app or your self-hosted server address"`
DeviceKey string `json:"device_key" required:"true" help:"Your Bark device key, which can be found in the Bark App"`
Icon string `json:"icon" help:"Push notification icon, supports URL or system icon name"`
Level string `json:"level" type:"option" default:"timeSensitive" options:"active,timeSensitive,passive,critical" help:"Push notification level: active, timeSensitive (default), passive, critical"`
}
func init() {
factory.RegisterMessageSender(func() factory.IMessageSender {
return &BarkSender{}
})
}
+57
View File
@@ -4,7 +4,9 @@ import (
"encoding/json"
"fmt"
"log"
"strings"
"sync"
"time"
"github.com/komari-monitor/komari/database"
"github.com/komari-monitor/komari/database/auditlog"
@@ -89,3 +91,58 @@ func SendTextMessage(message string, title string) error {
auditlog.Log("", "", "Failed to send message after 3 attempts: "+err.Error()+","+title, "error")
return err
}
func SendEvent(event models.EventMessage) error {
if CurrentProvider() == nil {
return fmt.Errorf("message sender provider is not initialized")
}
var err error
cfg, err := config.Get()
if err != nil {
return err
}
if !cfg.NotificationEnabled {
return nil
}
messageTemplate := cfg.NotificationTemplate
if messageTemplate == "" {
messageTemplate = "{{emoji}}{{emoji}}{{emoji}}\nEvent: {{event}}\nClients: {{client}}\nMessage: {{message}}\nTime: {{time}}"
}
messageTemplate = parseTemplate(messageTemplate, event)
for i := 0; i < 3; i++ {
err = CurrentProvider().SendTextMessage(messageTemplate, event.Event)
if err == nil {
auditlog.Log("", "", "Event message sent: "+event.Event, "info")
return nil
}
}
auditlog.Log("", "", "Failed to send event message after 3 attempts: "+err.Error()+","+event.Event, "error")
return err
}
func parseTemplate(messageTemplate string, event models.EventMessage) string {
// Aggregate client names. If Name is empty, fall back to UUID.
clientNames := make([]string, 0, len(event.Clients))
for _, c := range event.Clients {
name := c.Name
if strings.TrimSpace(name) == "" {
// fallback to UUID when name is not set
name = c.UUID
}
clientNames = append(clientNames, name)
}
joinedClients := strings.Join(clientNames, ", ")
replaceMap := map[string]string{
"{{event}}": event.Event,
"{{client}}": joinedClients,
"{{time}}": event.Time.Format(time.RFC3339),
"{{message}}": event.Message,
"{{emoji}}": event.Emoji,
}
result := messageTemplate
for placeholder, value := range replaceMap {
result = strings.ReplaceAll(result, placeholder, value)
}
return result
}
+21
View File
@@ -0,0 +1,21 @@
package webhook
import (
"github.com/komari-monitor/komari/utils/messageSender/factory"
)
type Addition struct {
URL string `json:"url" required:"true"`
Method string `json:"method" default:"GET" type:"option" options:"POST,GET"`
ContentType string `json:"content_type" default:"application/json"`
Headers string `json:"headers" help:"HTTP headers in JSON format"`
Body string `json:"body" default:"{\"message\":\"{{message}}\",\"title\":\"{{title}}\"}"` // 默认使用message和title字段
Username string `json:"username"`
Password string `json:"password"`
}
func init() {
factory.RegisterMessageSender(func() factory.IMessageSender {
return &WebhookSender{}
})
}
+134
View File
@@ -0,0 +1,134 @@
package webhook
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"github.com/komari-monitor/komari/utils/messageSender/factory"
)
type WebhookSender struct {
Addition
}
func (w *WebhookSender) GetName() string {
return "webhook"
}
func (w *WebhookSender) GetConfiguration() factory.Configuration {
return &w.Addition
}
func (w *WebhookSender) Init() error {
return nil
}
func (w *WebhookSender) Destroy() error {
return nil
}
func (w *WebhookSender) SendTextMessage(message, title string) error {
if w.Addition.URL == "" {
return fmt.Errorf("webhook URL is not configured")
}
method := strings.ToUpper(w.Addition.Method)
if method == "" {
method = "GET" // 默认使用 GET
}
client := &http.Client{
Timeout: 30 * time.Second, // 固定30秒超时
}
var req *http.Request
var err error
switch method {
case "POST":
req, err = w.createPOSTRequest(message, title)
case "GET":
req, err = w.createGETRequest(message, title)
default:
return fmt.Errorf("unsupported HTTP method: %s", method)
}
if err != nil {
return fmt.Errorf("failed to create request: %v", err)
}
// 解析并设置自定义头部
if w.Addition.Headers != "" {
var headers map[string]string
if err := json.Unmarshal([]byte(w.Addition.Headers), &headers); err == nil {
for key, value := range headers {
req.Header.Set(key, value)
}
}
}
// 设置基本认证
if w.Addition.Username != "" && w.Addition.Password != "" {
req.SetBasicAuth(w.Addition.Username, w.Addition.Password)
}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("failed to send webhook request: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("webhook request failed with status %d: %s", resp.StatusCode, string(body))
}
return nil
}
func (w *WebhookSender) createPOSTRequest(message, title string) (*http.Request, error) {
// 替换模板中的占位符
body := w.replaceTemplate(w.Addition.Body, message, title)
contentType := w.Addition.ContentType
if contentType == "" {
contentType = "application/json"
}
req, err := http.NewRequest("POST", w.Addition.URL, bytes.NewBufferString(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", contentType)
return req, nil
}
func (w *WebhookSender) createGETRequest(message, title string) (*http.Request, error) {
URL := w.replaceTemplate(w.Addition.URL, message, title)
u, err := url.Parse(URL)
if err != nil {
return nil, fmt.Errorf("invalid URL: %v", err)
}
req, err := http.NewRequest("GET", u.String(), nil)
if err != nil {
return nil, err
}
return req, nil
}
// replaceTemplate 替换模板中的 {{message}} 和 {{title}} 占位符
func (w *WebhookSender) replaceTemplate(template, message, title string) string {
result := template
result = strings.ReplaceAll(result, "{{message}}", message)
result = strings.ReplaceAll(result, "{{title}}", title)
return result
}
+10 -3
View File
@@ -7,6 +7,8 @@ import (
"github.com/komari-monitor/komari/database/clients"
"github.com/komari-monitor/komari/database/config"
"github.com/komari-monitor/komari/database/models"
messageevent "github.com/komari-monitor/komari/database/models/messageEvent"
"github.com/komari-monitor/komari/utils/messageSender"
"github.com/komari-monitor/komari/utils/renewal"
)
@@ -67,11 +69,16 @@ func CheckExpireScheduledWork() {
}
if len(clientLeadToExpire) > 0 {
message := "The following clients are about to expire: \n\n"
message := ""
for _, clientInfo := range clientLeadToExpire {
message += fmt.Sprintf("• %s in %d days\n", clientInfo.Name, clientInfo.DaysLeft)
message += fmt.Sprintf("• %s (%dd)\n", clientInfo.Name, clientInfo.DaysLeft)
}
messageSender.SendTextMessage(message, "Komari Expiration Notification")
messageSender.SendEvent(models.EventMessage{
Event: messageevent.Expire,
Time: time.Now(),
Message: message,
Emoji: "⏳",
})
}
}
+13 -9
View File
@@ -1,7 +1,6 @@
package notifier
import (
"fmt"
"log"
"reflect"
"sync"
@@ -10,6 +9,7 @@ import (
"github.com/komari-monitor/komari/database/clients"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
messageevent "github.com/komari-monitor/komari/database/models/messageEvent"
"github.com/komari-monitor/komari/database/records"
"github.com/komari-monitor/komari/utils/messageSender"
)
@@ -189,20 +189,24 @@ func getMetricValue(record models.Record, metric string) float32 {
// sendLoadNotification 发送负载通知
func sendLoadNotification(clientUUIDs []string, task models.LoadNotification) {
message := "The following clients have exceeded the load threshold:\n\n"
ex_clients := []models.Client{}
for _, clientUUID := range clientUUIDs {
client, err := clients.GetClientByUUID(clientUUID)
if err != nil {
log.Printf("Failed to get client info for %s: %v", clientUUID, err)
continue
cl, err := clients.GetClientByUUID(clientUUID)
if err == nil {
ex_clients = append(ex_clients, cl)
}
message += fmt.Sprintf("%s\n", client.Name)
}
if len(clientUUIDs) == 0 {
if len(ex_clients) == 0 {
return
}
go func() {
messageSender.SendTextMessage(message, fmt.Sprintf("%s - Komari Load Alert", task.Name))
messageSender.SendEvent(models.EventMessage{
Event: messageevent.Alert,
Clients: ex_clients,
Time: time.Now(),
Emoji: "⚠️",
Message: task.Name,
})
}()
}
+41 -11
View File
@@ -10,6 +10,7 @@ import (
"github.com/komari-monitor/komari/database/config"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
messageevent "github.com/komari-monitor/komari/database/models/messageEvent"
"github.com/komari-monitor/komari/utils/messageSender"
"github.com/komari-monitor/komari/utils/renewal"
)
@@ -20,6 +21,8 @@ type notificationState struct {
mu sync.Mutex // 互斥锁,保护该客户端状态
pendingOfflineSince time.Time // 客户端离线的时间。为零值表示客户端在线或已发送离线通知。
isFirstConnection bool // 标记是否为首次上线连接。
isConnExist bool // 标记是否存在连接
connectionID int64 // 连接ID,用于区分不同的连接会话,防止竞态条件
}
// clientStates 使用 sync.Map 实现对客户端状态的并发访问。
@@ -52,7 +55,7 @@ func getOrInitState(clientID string) *notificationState {
}
// OfflineNotification 在启用通知且未在宽限期内发送的情况下,发送客户端离线通知。
func OfflineNotification(clientID string) {
func OfflineNotification(clientID string, endedConnectionID int64) {
client, err := clients.GetClientByUUID(clientID)
if err != nil {
return
@@ -73,7 +76,8 @@ func OfflineNotification(clientID string) {
state.mu.Lock()
// 如果已处于待通知状态,则不做处理。
if !state.pendingOfflineSince.IsZero() {
// 只有当离线事件来自当前的连接会话时,我们才认为它有效。
if !state.pendingOfflineSince.IsZero() || state.connectionID != endedConnectionID {
state.mu.Unlock()
return
}
@@ -82,7 +86,7 @@ func OfflineNotification(clientID string) {
state.mu.Unlock()
// 新建协程,等待宽限期后判断是否需要发送通知。
go func(startTime time.Time) {
go func(startTime time.Time, expectedConnectionID int64) {
time.Sleep(gracePeriod)
state.mu.Lock()
@@ -90,18 +94,27 @@ func OfflineNotification(clientID string) {
// 检查离线状态是否仍为本次协程启动时的状态。
// 若为零值,说明客户端已重连。
// 若时间不同,说明客户端重连后又断开,已启动新计时。
if state.pendingOfflineSince.IsZero() || !state.pendingOfflineSince.Equal(startTime) {
// 当前的 connectionID 是否还是我们触发离线时的那个ID。如果不是,说明客户端重连过,本次离线通知已失效。
if state.pendingOfflineSince.IsZero() || state.connectionID != expectedConnectionID {
log.Printf("%s is reconnected new connID: %d, old connID: %d", clientID, state.connectionID, expectedConnectionID)
return
}
// 即将发送通知,重置待通知状态。
// 需要多一个boolean 是因为pendingOfflineSince在offline睡眠后才修改,可能导致online判断不对
state.pendingOfflineSince = time.Time{}
state.isConnExist = false
// Send notification
message := fmt.Sprintf("🔴%s is offline", client.Name)
go func(msg string) {
if err := messageSender.SendTextMessage(msg, "Komari Offline Notification"); err != nil {
if err := messageSender.SendEvent(models.EventMessage{
Event: messageevent.Offline,
Clients: []models.Client{client},
Time: time.Now(),
//Message: msg,
Emoji: "🔴",
}); err != nil {
log.Println("Failed to send offline notification:", err)
}
}(message)
@@ -111,11 +124,11 @@ func OfflineNotification(clientID string) {
if err := db.Model(&models.OfflineNotification{}).Where("client = ?", clientID).Update("last_notified", now).Error; err != nil {
log.Printf("Failed to update last_notified for client %s: %v", clientID, err)
}
}(now)
}(now, endedConnectionID)
}
// OnlineNotification 在启用通知的情况下,发送客户端上线通知。
func OnlineNotification(clientID string) {
func OnlineNotification(clientID string, connectionID int64) {
client, err := clients.GetClientByUUID(clientID)
if err != nil {
return
@@ -131,12 +144,14 @@ func OnlineNotification(clientID string) {
state.mu.Lock()
defer state.mu.Unlock()
state.connectionID = connectionID
// 规则1:首次连接不通知。
if state.isFirstConnection {
state.isFirstConnection = false
// 同时清除任何待离线状态(如服务器重启时客户端本已离线)
state.pendingOfflineSince = time.Time{}
state.isConnExist = true
return
}
@@ -145,15 +160,30 @@ func OnlineNotification(clientID string) {
// 上线时总是清除待离线状态。
state.pendingOfflineSince = time.Time{}
// 规则4:宽限期内重连,不通知。
// 规则2:宽限期内重连,不通知。
if wasPending {
return
}
// 规则3:客户端离线足够久已通知(或未待离线),现在重新上线,发送上线通知。
// 规则3: 没断开后重连, 不通知
// 为了解决OfflineNotify中不是全程加锁
if state.isConnExist {
log.Printf("%s has connection exist: %d", clientID, connectionID)
return
} else {
state.isConnExist = true
}
// 规则4:客户端离线足够久已通知(或未待离线),现在重新上线,发送上线通知。
message := fmt.Sprintf("🟢%s is online", client.Name)
go func(msg string) {
if err := messageSender.SendTextMessage(msg, "Komari Online Notification"); err != nil {
if err := messageSender.SendEvent(models.EventMessage{
Event: messageevent.Online,
Clients: []models.Client{client},
Time: time.Now(),
//Message: msg,
Emoji: "🟢",
}); err != nil {
log.Println("Failed to send online notification:", err)
}
}(message)
+143
View File
@@ -0,0 +1,143 @@
package notifier
import (
"fmt"
"math"
"strings"
"time"
"github.com/komari-monitor/komari/database/clients"
"github.com/komari-monitor/komari/database/config"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/utils/messageSender"
"github.com/komari-monitor/komari/ws"
cache "github.com/patrickmn/go-cache"
)
// trafficCache 用于记录每个客户端已触发的阈值步进,避免重复提醒
// key: "traffic:"+clientUUID, value: int 步进百分比(例如 80, 85, 90 ... 100)
var trafficCache = cache.New(30*24*time.Hour, time.Hour) // 30天缓存,1小时清理
// CheckTraffic 检查各客户端流量使用情况,并在达到阈值和每+5%时提醒一次;100%时额外提醒一次
// 由外部协程每分钟调用一次
func CheckTraffic() {
// 获取最新上报与客户端配置
reports := ws.GetLatestReport()
if len(reports) == 0 {
return
}
cfg, err := config.Get()
if err != nil {
return
}
if cfg.TrafficLimitPercentage <= 0 {
return
}
// 起始阈值:例如 80%,非5的倍数则从上取整到最近的5的倍数,例如 83->85
startThreshold := cfg.TrafficLimitPercentage
if startThreshold < 0 {
startThreshold = 0
}
baseStep := int(math.Ceil(startThreshold/5.0) * 5.0)
if baseStep > 100 {
baseStep = 100
}
allClients, err := clients.GetAllClientBasicInfo()
if err != nil {
return
}
for _, c := range allClients {
if c.TrafficLimit <= 0 {
continue
}
r, ok := reports[c.UUID]
if !ok || r == nil {
continue
}
// 计算不同类型的使用值
used := computeUsedByType(strings.ToLower(c.TrafficLimitType), r.Network.TotalUp, r.Network.TotalDown)
if used <= 0 {
continue
}
pct := float64(used) / float64(c.TrafficLimit) * 100.0
if pct < startThreshold {
continue
}
// 当前所在阈值步进(5%的倍数)
curStep := int(math.Floor(pct/5.0) * 5.0)
if curStep < baseStep {
curStep = baseStep
}
// if curStep > 100 {
// curStep = 100
// }
key := "traffic:" + c.UUID
last, _ := trafficCache.Get(key)
lastStep, _ := last.(int)
if curStep > lastStep { // 只在进入新步进时提醒一次
trafficCache.SetDefault(key, curStep)
msg := fmt.Sprintf("used %d%% (%s / %s), type=%s", curStep, humanBytes(used), humanBytes(c.TrafficLimit), strings.ToLower(c.TrafficLimitType))
// 发送通知(内部会检查 NotificationEnabled)
_ = messageSender.SendEvent(models.EventMessage{
Event: "Traffic",
Clients: []models.Client{c},
Time: time.Now(),
Emoji: "⚠️",
Message: msg,
})
}
}
}
func computeUsedByType(t string, up, down int64) int64 {
switch t {
case "up":
return up
case "down":
return down
case "sum":
return up + down
case "min":
if up < down {
return up
}
return down
case "max":
fallthrough
default:
if up > down {
return up
}
return down
}
}
func humanBytes(b int64) string {
const unit = 1024
if b < unit {
return fmt.Sprintf("%d B", b)
}
div, exp := int64(unit), 0
for n := b / unit; n >= unit; n /= unit {
div *= unit
exp++
}
// KMGTPE
prefixes := []string{"K", "M", "G", "T", "P", "E"}
if exp >= len(prefixes) {
exp = len(prefixes) - 1
}
return fmt.Sprintf("%.2f %sB", float64(b)/float64(div), prefixes[exp])
}
+3 -1
View File
@@ -1,11 +1,13 @@
package oauth
import (
_ "github.com/komari-monitor/komari/utils/oauth/cloudflare"
_ "github.com/komari-monitor/komari/utils/oauth/factory"
_ "github.com/komari-monitor/komari/utils/oauth/generic"
_ "github.com/komari-monitor/komari/utils/oauth/github"
_ "github.com/komari-monitor/komari/utils/oauth/qq"
)
func All() {
//empty function to ensure all OIDC providers are registered
}
}
+92
View File
@@ -0,0 +1,92 @@
package cloudflare
import (
"fmt"
"strings"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/utils"
"github.com/komari-monitor/komari/utils/oauth/factory"
)
func (c *Cloudflare) GetName() string {
return "CloudflareAccess"
}
func (c *Cloudflare) GetConfiguration() factory.Configuration {
return &c.Addition
}
func (c *Cloudflare) GetAuthorizationURL(redirectURI string) (string, string) {
// 生成 state
state := utils.GenerateRandomString(8)
// 将 state 作为参数附加到回调 URL
sep := "?"
if strings.Contains(redirectURI, "?") {
sep = "&"
}
authURL := fmt.Sprintf("%s%sstate=%s", redirectURI, sep, state)
return authURL, state
}
func (c *Cloudflare) OnCallback(ctx *gin.Context, state string, query map[string]string, callbackURI string) (factory.OidcCallback, error) {
// 从请求头中获取 Cloudflare Access JWT
accessJWT := ctx.GetHeader("Cf-Access-Jwt-Assertion")
if accessJWT == "" {
return factory.OidcCallback{}, fmt.Errorf("no Cloudflare Access JWT found")
}
// 验证 JWT
teamDomain := c.Addition.TeamDomain
// 如果不是完整的URL,则默认添加 https 前缀和 cloudflareaccess.com 后缀
if !strings.HasPrefix(teamDomain, "https://") {
teamDomain = "https://" + teamDomain + ".cloudflareaccess.com"
}
certsURL := fmt.Sprintf("%s/cdn-cgi/access/certs", teamDomain)
config := &oidc.Config{
ClientID: c.Addition.PolicyAUD,
}
keySet := oidc.NewRemoteKeySet(ctx.Request.Context(), certsURL)
verifier := oidc.NewVerifier(teamDomain, keySet, config)
// 验证 token
idToken, err := verifier.Verify(ctx.Request.Context(), accessJWT)
if err != nil {
return factory.OidcCallback{}, fmt.Errorf("failed to verify Cloudflare Access token: %w", err)
}
// 提取用户信息
var claims struct {
// Email string `json:"email"`
Sub string `json:"sub"`
}
if err := idToken.Claims(&claims); err != nil {
return factory.OidcCallback{}, fmt.Errorf("failed to extract claims from token: %w", err)
}
// 使用 sub 作为用户 ID
return factory.OidcCallback{UserId: claims.Sub}, nil
}
func (c *Cloudflare) Init() error {
if c.Addition.TeamDomain == "" {
return fmt.Errorf("team_domain is required")
}
if c.Addition.PolicyAUD == "" {
return fmt.Errorf("policy_aud is required")
}
return nil
}
func (c *Cloudflare) Destroy() error {
// Cloudflare Access 提供商不需要特殊的清理操作
return nil
}
var _ factory.IOidcProvider = (*Cloudflare)(nil)
+20
View File
@@ -0,0 +1,20 @@
package cloudflare
import (
"github.com/komari-monitor/komari/utils/oauth/factory"
)
func init() {
factory.RegisterOidcProvider(func() factory.IOidcProvider {
return &Cloudflare{}
})
}
type Cloudflare struct {
Addition
}
type Addition struct {
TeamDomain string `json:"team_domain" required:"true"`
PolicyAUD string `json:"policy_aud" required:"true"`
}
+3 -3
View File
@@ -1,14 +1,14 @@
package factory
import "context"
import "github.com/gin-gonic/gin"
type IOidcProvider interface {
GetName() string
// 请务必返回 &Configuration{} 的指针
GetConfiguration() Configuration
// 获取授权URL和状态
GetAuthorizationURL() (string, string)
OnCallback(ctx context.Context, state string, query map[string]string) (OidcCallback, error)
GetAuthorizationURL(redirectURI string) (string, string)
OnCallback(ctx *gin.Context, state string, query map[string]string, callbackURI string) (OidcCallback, error)
Init() error
Destroy() error
}
+4 -4
View File
@@ -22,10 +22,10 @@ func TestRegisterAndGetProviderConfigs(t *testing.T) {
t.Error("Expected non-empty OIDC provider names, got empty")
}
err := LoadProvider("github", `{"client_id":"test_id","client_secret":"test_secret"}`)
if err != nil {
t.Errorf("Failed to load provider: %v", err)
}
//err := LoadProvider("github", `{"client_id":"test_id","client_secret":"test_secret"}`)
//if err != nil {
// t.Errorf("Failed to load provider: %v", err)
//}
cfg := CurrentProvider().GetConfiguration()
if cfg == nil {
t.Error("Expected non-nil configuration for 'github' provider, got nil")
+11 -8
View File
@@ -1,13 +1,14 @@
package generic
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/utils"
"github.com/komari-monitor/komari/utils/oauth/factory"
"github.com/patrickmn/go-cache"
@@ -20,21 +21,22 @@ func (g *Generic) GetConfiguration() factory.Configuration {
return &g.Addition
}
func (g *Generic) GetAuthorizationURL() (string, string) {
func (g *Generic) GetAuthorizationURL(redirectURI string) (string, string) {
state := utils.GenerateRandomString(16)
// 构建GitHub OAuth授权URL
authURL := fmt.Sprintf(
"%s?client_id=%s&state=%s&scope=%s&response_type=code",
"%s?client_id=%s&state=%s&scope=%s&redirect_uri=%s&response_type=code",
g.Addition.AuthURL,
url.QueryEscape(g.Addition.ClientId),
url.QueryEscape(state),
url.QueryEscape(g.Addition.Scope),
url.QueryEscape(redirectURI),
)
g.stateCache.Set(state, true, cache.DefaultExpiration)
return authURL, state
}
func (g *Generic) OnCallback(ctx context.Context, state string, query map[string]string) (factory.OidcCallback, error) {
func (g *Generic) OnCallback(ctx *gin.Context, state string, query map[string]string, callbackURI string) (factory.OidcCallback, error) {
code := query["code"]
// 验证state防止CSRF攻击
@@ -58,16 +60,17 @@ func (g *Generic) OnCallback(ctx context.Context, state string, query map[string
"client_id": {g.Addition.ClientId},
"client_secret": {g.Addition.ClientSecret},
"code": {code},
"redirect_uri": {callbackURI},
"grant_type": {"authorization_code"},
}
req, _ := http.NewRequest("POST", g.Addition.TokenURL, nil)
req.URL.RawQuery = data.Encode()
req, _ := http.NewRequest("POST", g.Addition.TokenURL, strings.NewReader(data.Encode()))
req.Header.Set("Accept", "application/json")
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return factory.OidcCallback{}, fmt.Errorf("failed to get access token: %v", err)
return factory.OidcCallback{}, fmt.Errorf("failed to get access token: %s", utils.DataMasking(err.Error(), []string{g.Addition.ClientSecret, g.Addition.ClientId}))
}
defer resp.Body.Close()
@@ -76,7 +79,7 @@ func (g *Generic) OnCallback(ctx context.Context, state string, query map[string
}
if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
return factory.OidcCallback{}, fmt.Errorf("failed to parse access token response: %v", err)
return factory.OidcCallback{}, fmt.Errorf("failed to parse access token response: %s", utils.DataMasking(err.Error(), []string{g.Addition.ClientSecret, g.Addition.ClientId}))
}
// 获取用户信息
+5 -5
View File
@@ -1,13 +1,13 @@
package github
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"time"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/utils"
"github.com/komari-monitor/komari/utils/oauth/factory"
"github.com/patrickmn/go-cache"
@@ -24,7 +24,7 @@ func (g *Github) GetConfiguration() factory.Configuration {
return &g.Addition
}
func (g *Github) GetAuthorizationURL() (string, string) {
func (g *Github) GetAuthorizationURL(_ string) (string, string) {
state := utils.GenerateRandomString(16)
// 构建GitHub OAuth授权URL
@@ -36,7 +36,7 @@ func (g *Github) GetAuthorizationURL() (string, string) {
g.stateCache.Set(state, true, cache.NoExpiration)
return authURL, state
}
func (g *Github) OnCallback(ctx context.Context, state string, query map[string]string) (factory.OidcCallback, error) {
func (g *Github) OnCallback(ctx *gin.Context, state string, query map[string]string, _ string) (factory.OidcCallback, error) {
code := query["code"]
// 验证state防止CSRF攻击
@@ -71,7 +71,7 @@ func (g *Github) OnCallback(ctx context.Context, state string, query map[string]
resp, err := http.DefaultClient.Do(req)
if err != nil {
return factory.OidcCallback{}, fmt.Errorf("failed to get access token: %v", err)
return factory.OidcCallback{}, fmt.Errorf("failed to get access token: %s", utils.DataMasking(err.Error(), []string{g.Addition.ClientSecret, g.Addition.ClientId}))
}
defer resp.Body.Close()
@@ -82,7 +82,7 @@ func (g *Github) OnCallback(ctx context.Context, state string, query map[string]
}
if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
return factory.OidcCallback{}, fmt.Errorf("failed to parse access token response: %v", err)
return factory.OidcCallback{}, fmt.Errorf("failed to parse access token response: %s", utils.DataMasking(err.Error(), []string{g.Addition.ClientSecret, g.Addition.ClientId}))
}
// 获取用户信息
+43 -27
View File
@@ -7,6 +7,7 @@ import (
"sync"
"github.com/komari-monitor/komari/database"
"github.com/komari-monitor/komari/database/config"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/utils/oauth/factory"
)
@@ -26,33 +27,6 @@ func CurrentProvider() factory.IOidcProvider {
func LoadProvider(name string, configJson string) error {
mu.Lock()
defer mu.Unlock()
go func() {
once.Do(func() {
all := factory.GetAllOidcProviders()
for _, provider := range all {
if _, err := database.GetOidcConfigByName(provider.GetName()); err == nil {
continue
}
// 如果数据库中没有该提供者的配置,则保存默认配置
config := provider.GetConfiguration()
configBytes, err := json.Marshal(config)
if err != nil {
log.Printf("Failed to marshal config for provider %s: %v", provider.GetName(), err)
return
}
if err := database.SaveOidcConfig(&models.OidcProvider{
Name: provider.GetName(),
Addition: string(configBytes),
}); err != nil {
log.Printf("Failed to save default config for provider %s: %v", provider.GetName(), err)
return
}
}
})
}()
if currentProvider != nil && currentProvider.GetName() == name {
return nil // 已经加载了相同的提供程序
}
if currentProvider != nil {
if err := currentProvider.Destroy(); err != nil {
log.Printf("Failed to destroy provider %s: %v", currentProvider.GetName(), err)
@@ -72,3 +46,45 @@ func LoadProvider(name string, configJson string) error {
}
return nil
}
func Initialize() error {
once.Do(func() {
all := factory.GetAllOidcProviders()
for _, provider := range all {
if _, err := database.GetOidcConfigByName(provider.GetName()); err == nil {
continue
}
// 如果数据库中没有该提供者的配置,则保存默认配置
config := provider.GetConfiguration()
configBytes, err := json.Marshal(config)
if err != nil {
log.Printf("Failed to marshal config for provider %s: %v", provider.GetName(), err)
return
}
if err := database.SaveOidcConfig(&models.OidcProvider{
Name: provider.GetName(),
Addition: string(configBytes),
}); err != nil {
log.Printf("Failed to save default config for provider %s: %v", provider.GetName(), err)
return
}
}
})
cfg, _ := config.Get()
if cfg.OAuthProvider == "" || cfg.OAuthProvider == "none" {
LoadProvider("empty", "{}")
return nil
}
provider, err := database.GetOidcConfigByName(cfg.OAuthProvider)
if err != nil {
// 如果没有找到配置,使用empty provider
LoadProvider("empty", "{}")
return nil
}
err = LoadProvider(provider.Name, provider.Addition)
if err != nil {
log.Printf("Failed to load OIDC provider %s: %v", provider.Name, err)
return err
}
return nil
}
+24
View File
@@ -0,0 +1,24 @@
package qq
import (
"github.com/komari-monitor/komari/utils/oauth/factory"
"github.com/patrickmn/go-cache"
)
func init() {
factory.RegisterOidcProvider(func() factory.IOidcProvider {
return &QQ{}
})
}
type QQ struct {
Addition
stateCache *cache.Cache // 用于存储state和用户信息的映射
}
type Addition struct {
AggregationURL string `json:"aggregation_url" required:"true" default:"https://login.qjqq.cn"` // 聚合登录地址
AppId string `json:"app_id" required:"true"`
AppKey string `json:"app_key" required:"true"`
LoginType string `json:"login_type" required:"true"` // 登录方式,如qq, google等
}
+171
View File
@@ -0,0 +1,171 @@
package qq
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"time"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/utils"
"github.com/komari-monitor/komari/utils/oauth/factory"
"github.com/patrickmn/go-cache"
)
func (q *QQ) GetName() string {
return "qq"
}
func (q *QQ) GetConfiguration() factory.Configuration {
return &q.Addition
}
func (q *QQ) GetAuthorizationURL(redirectURI string) (string, string) {
state := utils.GenerateRandomString(16)
// 构建请求QQ聚合登录平台的URL
requestURL := fmt.Sprintf(
"%s/connect.php?act=login&appid=%s&appkey=%s&type=%s&redirect_uri=%s",
q.Addition.AggregationURL,
url.QueryEscape(q.Addition.AppId),
url.QueryEscape(q.Addition.AppKey),
url.QueryEscape(q.Addition.LoginType),
url.QueryEscape(redirectURI),
)
// 向聚合登录平台发送请求
resp, err := http.Get(requestURL)
if err != nil {
// 如果请求失败,返回错误信息
return "", state
}
defer resp.Body.Close()
// 读取响应内容
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", state
}
// 解析响应JSON
var result struct {
Code int `json:"code"`
Msg string `json:"msg"`
URL string `json:"url"`
}
if err := json.Unmarshal(body, &result); err != nil {
return "", state
}
// 检查响应状态
if result.Code != 0 {
return "", state
}
q.stateCache.Set(state, true, cache.DefaultExpiration)
return result.URL, state
}
// OnCallback 处理QQ OAuth回调
// 例如:http://localhost:25774/api/oauth_callback?type=qq&code=XXXXXXXXXXXXXXXX
// 然后我们使用code参数向聚合登录平台请求用户信息
func (q *QQ) OnCallback(ctx *gin.Context, state string, query map[string]string, callbackURI string) (factory.OidcCallback, error) {
// 根据文档,回调地址会附带type和code参数
code := query["code"]
loginType := query["type"]
// 如果回调中没有type参数,则使用配置中的LoginType
if loginType == "" {
loginType = q.Addition.LoginType
}
// 验证state防止CSRF攻击
if q.stateCache == nil {
return factory.OidcCallback{}, fmt.Errorf("state cache not initialized")
}
if _, ok := q.stateCache.Get(state); !ok {
return factory.OidcCallback{}, fmt.Errorf("invalid state")
}
if state == "" {
return factory.OidcCallback{}, fmt.Errorf("invalid state")
}
// 检查是否提供了Authorization Code
if code == "" {
return factory.OidcCallback{}, fmt.Errorf("no authorization code provided")
}
// 通过Authorization Code获取用户信息
// 根据文档,请求URL应为: {AggregationURL}/connect.php?act=callback&appid={appid}&appkey={appkey}&type={登录方式}&code={code}
callbackURL := fmt.Sprintf(
"%s/connect.php?act=callback&appid=%s&appkey=%s&type=%s&code=%s",
q.Addition.AggregationURL,
url.QueryEscape(q.Addition.AppId),
url.QueryEscape(q.Addition.AppKey),
url.QueryEscape(loginType),
url.QueryEscape(code),
)
resp, err := http.Get(callbackURL)
if err != nil {
return factory.OidcCallback{}, fmt.Errorf("failed to get user info: %v", err)
}
defer resp.Body.Close()
// 检查HTTP响应状态
if resp.StatusCode != http.StatusOK {
return factory.OidcCallback{}, fmt.Errorf("HTTP request failed with status code: %d", resp.StatusCode)
}
// 读取响应
body, err := io.ReadAll(resp.Body)
if err != nil {
return factory.OidcCallback{}, fmt.Errorf("failed to read response: %v", err)
}
// 解析响应
var result struct {
Code int `json:"code"`
Msg string `json:"msg"`
Type string `json:"type"`
SocialUid string `json:"social_uid"`
AccessToken string `json:"access_token"`
FaceImg string `json:"faceimg"`
Nickname string `json:"nickname"`
Gender string `json:"gender"`
Location string `json:"location"`
IP string `json:"ip"`
}
if err := json.Unmarshal(body, &result); err != nil {
return factory.OidcCallback{}, fmt.Errorf("failed to parse callback response: %v, response body: %s", err, string(body))
}
// 检查返回状态码
if result.Code != 0 {
return factory.OidcCallback{}, fmt.Errorf("QQ login callback failed with code %d: %s", result.Code, result.Msg)
}
// 检查是否返回了用户唯一标识
if result.SocialUid == "" {
return factory.OidcCallback{}, fmt.Errorf("empty social_uid returned, full response: %s", string(body))
}
// 返回用户唯一标识
return factory.OidcCallback{UserId: result.SocialUid}, nil
}
func (q *QQ) Init() error {
q.stateCache = cache.New(time.Minute*5, time.Minute*10)
return nil
}
func (q *QQ) Destroy() error {
q.stateCache.Flush()
return nil
}
var _ factory.IOidcProvider = (*QQ)(nil)
+57 -38
View File
@@ -1,6 +1,7 @@
package utils
import (
"context"
"sync"
"time"
@@ -10,16 +11,13 @@ import (
// PingTaskManager 管理定时器和任务
type PingTaskManager struct {
mu sync.Mutex
tickers map[int]*time.Ticker
tasks map[int][]models.PingTask
stopChan chan struct{}
mu sync.Mutex
cancelFunc context.CancelFunc
tasks map[int][]models.PingTask
}
var manager = &PingTaskManager{
tickers: make(map[int]*time.Ticker),
tasks: make(map[int][]models.PingTask),
stopChan: make(chan struct{}),
tasks: make(map[int][]models.PingTask),
}
// Reload 重载时间表
@@ -27,60 +25,81 @@ func (m *PingTaskManager) Reload(pingTasks []models.PingTask) error {
m.mu.Lock()
defer m.mu.Unlock()
// 停止所有现有定时器
for _, ticker := range m.tickers {
ticker.Stop()
if m.cancelFunc != nil {
m.cancelFunc()
}
m.tickers = make(map[int]*time.Ticker)
ctx, cancel := context.WithCancel(context.Background())
m.cancelFunc = cancel
m.tasks = make(map[int][]models.PingTask)
// 按Interval分组任务
taskGroups := make(map[int][]models.PingTask)
for _, task := range pingTasks {
if task.Interval <= 0 {
continue
}
taskGroups[task.Interval] = append(taskGroups[task.Interval], task)
}
// 为每个唯一的Interval创建定时器
// 为每个唯一的Interval创建协程
for interval, tasks := range taskGroups {
ticker := time.NewTicker(time.Duration(interval) * time.Second)
m.tickers[interval] = ticker
m.tasks[interval] = tasks
go func(ticker *time.Ticker, tasks []models.PingTask) {
for {
select {
case <-ticker.C:
for _, task := range tasks {
go executePingTask(task)
}
case <-m.stopChan:
return
}
}
}(ticker, tasks)
go m.runPreciseLoop(ctx, time.Duration(interval)*time.Second, tasks)
}
return nil
}
func (m *PingTaskManager) runPreciseLoop(ctx context.Context, interval time.Duration, tasks []models.PingTask) {
// Start the first timer.
timer := time.NewTimer(interval)
// This will be the reference point for all future ticks.
// By adding the interval to this time, we avoid accumulating execution delays.
nextTick := time.Now().Add(interval)
defer timer.Stop()
for {
select {
case <-timer.C:
onlineClients := ws.GetConnectedClients()
for _, task := range tasks {
go executePingTask(ctx, task, onlineClients)
}
nextTick = nextTick.Add(interval)
timer.Reset(time.Until(nextTick))
case <-ctx.Done():
return
}
}
}
// executePingTask 执行单个PingTask
func executePingTask(task models.PingTask) {
clients := ws.GetConnectedClients()
func executePingTask(ctx context.Context, task models.PingTask, onlineClients map[string]*ws.SafeConn) {
var message struct {
TaskID uint `json:"ping_task_id"`
Message string `json:"message"`
Type string `json:"ping_type"`
Target string `json:"ping_target"`
}
message.Message = "ping"
message.TaskID = task.Id
message.Type = task.Type
message.Target = task.Target
for _, clientUUID := range task.Clients {
if conn, exists := clients[clientUUID]; exists {
if conn == nil {
continue
}
message.Message = "ping"
message.TaskID = task.Id
message.Type = task.Type
message.Target = task.Target
select {
case <-ctx.Done():
// Context was canceled, stop sending pings.
return
default:
// Context is still active, continue.
}
if conn, exists := onlineClients[clientUUID]; exists && conn != nil {
if err := conn.WriteJSON(message); err != nil {
continue
}
+31 -16
View File
@@ -7,17 +7,18 @@ import (
"github.com/komari-monitor/komari/database/auditlog"
"github.com/komari-monitor/komari/database/clients"
"github.com/komari-monitor/komari/database/models"
messageevent "github.com/komari-monitor/komari/database/models/messageEvent"
"github.com/komari-monitor/komari/utils/messageSender"
"github.com/komari-monitor/komari/ws"
)
func CheckAndAutoRenewal(client models.Client) {
// 自动续费检查
type renewedClient struct {
Name string
NewExpireTime time.Time
}
var renewedClients []renewedClient
//type renewedClient struct {
// Name string
// NewExpireTime time.Time
//}
//var renewedClients []renewedClient
if !client.AutoRenewal {
return
@@ -96,22 +97,36 @@ func CheckAndAutoRenewal(client models.Client) {
return
}
renewedClients = append(renewedClients, renewedClient{
Name: client.Name,
NewExpireTime: newExpireTime,
})
//renewedClients = append(renewedClients, renewedClient{
// Name: client.Name,
// NewExpireTime: newExpireTime,
//})
auditlog.EventLog("renewal", fmt.Sprintf("Auto-renewed client: %s until %s",
client.Name, newExpireTime.Format("2006-01-02")))
messageSender.SendEvent(models.EventMessage{
Event: messageevent.Renew,
Clients: []models.Client{client},
Time: time.Now(),
Emoji: "🔄",
Message: fmt.Sprintf("• %s until %s\n", client.Name, newExpireTime.Format("2006-01-02")),
})
}
}
// 发送续费通知
if len(renewedClients) > 0 {
message := "The following clients have been automatically renewed: \n\n"
for _, clientInfo := range renewedClients {
message += fmt.Sprintf("• %s until %s\n", clientInfo.Name, clientInfo.NewExpireTime.Format("2006-01-02"))
}
messageSender.SendTextMessage(message, "Komari Auto-Renewal Notification")
}
// if len(renewedClients) > 0 {
// message := ""
// for _, clientInfo := range renewedClients {
// message += fmt.Sprintf("• %s until %s\n", clientInfo.Name, clientInfo.NewExpireTime.Format("2006-01-02"))
// }
// messageSender.SendEvent(models.EventMessage{
// Event: messageevent.Renew,
// Clients: []models.Client{client},
// Time: time.Now(),
// Emoji: "🔄",
// Message: message,
// })
// }
}
+58
View File
@@ -0,0 +1,58 @@
package rpc
// context.go
// 定义 RPC 执行时可附带的上下文元信息。用于在 handler 中获取当前请求的鉴权/主体信息。
// 注意:避免出现循环依赖,这里仅引用 models.User(database/models 未引用本包)。
import (
"context"
"github.com/komari-monitor/komari/database/models"
)
// ContextMeta 保存一次 RPC 调用可用的鉴权/身份元数据。
// 为了便于扩展,字段保持冗余:既保存结构体,也保存对应 UUID / Token。
// 未来如需添加字段(如 IP、UserAgent、TraceID 等)直接在此结构体上扩展即可。
type ContextMeta struct {
// Permission 当前权限分组 guest/client/admin
Permission string
// User 登录的管理员用户(仅 admin 会话存在)
User *models.User
// UserUUID 方便无需解引用就能快速判断
UserUUID string
// ClientToken 来自客户端的私钥 / 鉴权 token(仅 client 链接可能存在)
ClientToken string
// ClientUUID 解析出的客户端 UUID(若 token 合法)
ClientUUID string
// RemoteIP 请求来源 IP(可选)
RemoteIP string
// UserAgent 请求 UA(可选)
UserAgent string
}
// 私有类型做 key,避免外部冲突
type ctxMetaKey struct{}
// NewContextWithMeta 将 meta 写入 context
func NewContextWithMeta(parent context.Context, meta *ContextMeta) context.Context {
if parent == nil {
parent = context.Background()
}
if meta == nil {
return parent
}
return context.WithValue(parent, ctxMetaKey{}, meta)
}
// MetaFromContext 读取 meta;不存在返回 nil
func MetaFromContext(ctx context.Context) *ContextMeta {
if ctx == nil {
return nil
}
if v := ctx.Value(ctxMetaKey{}); v != nil {
if m, ok := v.(*ContextMeta); ok {
return m
}
}
return nil
}
+56
View File
@@ -0,0 +1,56 @@
package rpc
import "fmt"
// JsonRpcError 错误对象
// Code 按 JSON-RPC 规范使用 -32768 ~ -32000 的预留范围,其它业务自定义可使用正数或自定义区间。
// Message 为简短描述,Data 可携带扩展信息(结构化或字符串)。
type JsonRpcError struct {
Code int `json:"code"`
Message string `json:"message"`
Data interface{} `json:"data,omitempty"`
}
// 预定义错误码 JSON-RPC 2.0 标准
const (
ParseError = -32700
InvalidRequest = -32600
MethodNotFound = -32601
InvalidParams = -32602
InternalError = -32603
)
// Komari 定义错误码
const (
NotFound = -32044
AlreadyExists = -32045
PermissionDenied = -32041
Unauthenticated = -32040 // 未登录/无身份
Cancelled = -32010 // 主动取消
DeadlineExceeded = -32011 // 超时
Aborted = -32021 // 并发冲突/事务中断
OutOfRange = -32022 // 数值/索引越界
Unimplemented = -32050 // 尚未实现
Unavailable = -32051 // 依赖服务暂不可用
DataLoss = -32052 // 不可恢复的数据丢失
)
// MakeError 便捷创建错误对象
func MakeError(code int, msg string, data any) *JsonRpcError {
return &JsonRpcError{Code: code, Message: msg, Data: data}
}
func (e *JsonRpcError) Error() string {
return fmt.Sprintf("JSON-RPC Error %d: %s", e.Code, e.Message)
}
func (e *JsonRpcError) Response() *JsonRpcResponse {
return e.ResponseWithID(nil)
}
func (e *JsonRpcError) ResponseWithID(id any) *JsonRpcResponse {
return &JsonRpcResponse{
Version: RPC_VERSION,
Error: e,
ID: id,
}
}
+91
View File
@@ -0,0 +1,91 @@
package rpc
// internal.go
// 定义并注册保留前缀 "rpc." 的内部方法及其元数据。
import (
"context"
"sort"
"strings"
)
// registerInternal 直接写入保留前缀方法。
// 假定调用点受控(init 阶段),若重复则忽略以防 panic。
func registerInternal(method string, h Handler) {
if !strings.HasPrefix(method, "rpc.") {
method = "rpc." + method
}
muHandlers.Lock()
if _, exists := handlers[method]; !exists {
handlers[method] = h
}
muHandlers.Unlock()
}
// listMethods 返回方法列表;includeInternal=false 时剔除 rpc.*
func listMethods(includeInternal bool) []string {
all := ListMethods()
out := make([]string, 0, len(all))
for _, m := range all {
if !includeInternal && strings.HasPrefix(m, "rpc.") {
continue
}
out = append(out, m)
}
sort.Strings(out)
return out
}
func init() {
// rpc.methods -> 列出方法名
registerInternal("rpc.methods", func(ctx context.Context, req *JsonRpcRequest) (any, *JsonRpcError) {
var params struct {
ShowInternal bool `json:"internal"`
}
req.BindParams(&params)
return listMethods(params.ShowInternal), nil
})
// rpc.version -> 协议版本
registerInternal("rpc.version", func(ctx context.Context, req *JsonRpcRequest) (any, *JsonRpcError) {
return RPC_VERSION, nil
})
// rpc.ping -> 健康检查
registerInternal("rpc.ping", func(ctx context.Context, req *JsonRpcRequest) (any, *JsonRpcError) {
return "pong", nil
})
// rpc.help -> 方法元数据或概览
registerInternal("rpc.help", func(ctx context.Context, req *JsonRpcRequest) (any, *JsonRpcError) {
var params struct {
Method string `json:"method"`
}
req.BindParams(&params)
if params.Method != "" {
meta := getMetaUnsafe(params.Method)
if meta == nil {
return nil, MakeError(InvalidRequest, "method not found", nil)
}
return meta, nil
}
return listMetas(true), nil
})
// 元数据注册
RegisterMeta("rpc.methods", &MethodMeta{
Name: "rpc.methods",
Summary: "List methods",
Description: "Return the list of currently callable methods. By default, internal methods are not included. Pass internal=true to include them.",
Params: []ParamMeta{{Name: "internal", Type: "bool", Description: "Whether to include internal rpc.* methods"}},
Returns: "[]string",
})
RegisterMeta("rpc.version", &MethodMeta{Name: "rpc.version", Summary: "Return the RPC version", Returns: "string"})
RegisterMeta("rpc.ping", &MethodMeta{Name: "rpc.ping", Summary: "Health check, returns pong", Returns: "string"})
RegisterMeta("rpc.help", &MethodMeta{
Name: "rpc.help",
Summary: "Get method help",
Description: "Returns detailed metadata for the specified method if given.",
Params: []ParamMeta{
{Name: "method", Type: "string", Description: "Target method name (mutually exclusive with list)"},
},
Returns: "MethodMeta",
})
}
+49
View File
@@ -0,0 +1,49 @@
package rpc
import "context"
// Invoke 便捷调用:构造请求并执行已注册方法,返回 result 或 *JsonRpcError。
// 不生成 JsonRpcResponse。
//
// @ref Call
func Invoke(method string, params any) (any, *JsonRpcError) {
req := &JsonRpcRequest{Version: RPC_VERSION, Method: method, Params: params}
if e := req.Validate(); e != nil {
return nil, e
}
muHandlers.RLock()
h, ok := handlers[method]
muHandlers.RUnlock()
if !ok {
return nil, &JsonRpcError{Code: MethodNotFound, Message: "method not found", Data: method}
}
return h(context.Background(), req)
}
// Call 执行方法并直接返回完整的 JSON-RPC Response。
// 适用于对外暴露:始终返回结构(包括错误)。
// ctx: 执行上下文;id: 请求 id;method/params: 方法与参数。
func Call(id any, method string, params any) *JsonRpcResponse {
return CallWithContext(context.Background(), id, method, params)
}
func CallWithContext(ctx context.Context, id any, method string, params any) *JsonRpcResponse {
if ctx == nil {
ctx = context.Background()
}
req := &JsonRpcRequest{Version: RPC_VERSION, Method: method, Params: params, ID: id}
if e := req.Validate(); e != nil {
return ErrorResponse(id, e.Code, e.Message, e.Data)
}
muHandlers.RLock()
h, ok := handlers[method]
muHandlers.RUnlock()
if !ok {
return ErrorResponse(id, MethodNotFound, "method not found", method)
}
result, jerr := h(ctx, req)
if jerr != nil {
return ErrorResponse(id, jerr.Code, jerr.Message, jerr.Data)
}
return SuccessResponse(id, result)
}
+106
View File
@@ -0,0 +1,106 @@
package rpc
// meta.go
// 为已注册的 RPC 方法维护可选的帮助/描述信息,供 rpc.help 使用。
// 不影响原有 Register 行为;未显式附加元数据时会自动生成仅包含名称的占位。
import (
"sort"
"strings"
"sync"
)
// ParamMeta 描述单个参数。
type ParamMeta struct {
Name string `json:"name"`
Type string `json:"type,omitempty"`
Required bool `json:"required,omitempty"`
Description string `json:"description,omitempty"`
}
// MethodMeta 描述方法的帮助信息。
type MethodMeta struct {
Name string `json:"name"`
Summary string `json:"summary,omitempty"`
Description string `json:"description,omitempty"`
Params []ParamMeta `json:"params,omitempty"`
Returns string `json:"returns,omitempty"`
Example any `json:"example,omitempty"`
}
var (
muMetas sync.RWMutex
methodMetas = map[string]*MethodMeta{}
)
// getMetaUnsafe 内部使用:调用方需已持有 handlers 的读/写锁或自行同步(此处独立锁保障并发)。
func getMetaUnsafe(name string) *MethodMeta {
muMetas.RLock()
m := methodMetas[name]
muMetas.RUnlock()
return m
}
// ensureMeta 确保存在基本元数据(最少 Name)。
func ensureMeta(name string) {
if name == "" {
return
}
muMetas.Lock()
if _, ok := methodMetas[name]; !ok {
methodMetas[name] = &MethodMeta{Name: name}
}
muMetas.Unlock()
}
// RegisterMeta 为已注册方法附加/覆盖元数据(Name 字段若为空自动填充)。
func RegisterMeta(name string, meta *MethodMeta) {
if name == "" || meta == nil {
return
}
if meta.Name == "" {
meta.Name = name
}
muMetas.Lock()
methodMetas[name] = meta
muMetas.Unlock()
}
// RegisterWithMeta 同时注册方法与元数据;若注册失败返回错误。
func RegisterWithMeta(method string, h Handler, meta *MethodMeta) error {
if err := Register(method, h); err != nil {
return err
}
if meta != nil {
RegisterMeta(method, meta)
} else {
ensureMeta(method)
}
return nil
}
// listMetas 获取所有方法(按给定过滤器)简要元数据的副本。
func listMetas(includeInternal bool) []*MethodMeta {
muHandlers.RLock()
names := make([]string, 0, len(handlers))
for n := range handlers {
names = append(names, n)
}
muHandlers.RUnlock()
out := make([]*MethodMeta, 0, len(names))
for _, n := range names {
if !includeInternal && strings.HasPrefix(n, "rpc.") {
continue
}
if m := getMetaUnsafe(n); m != nil {
out = append(out, &MethodMeta{ // 复制简要字段
Name: m.Name,
Summary: m.Summary,
})
} else {
out = append(out, &MethodMeta{Name: n})
}
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out
}
+57
View File
@@ -0,0 +1,57 @@
package rpc
import (
"bytes"
"encoding/json"
)
// ParseRequest 解析单个 JSON-RPC 请求。返回请求与错误(解析层面)。
func ParseRequest(data []byte) (*JsonRpcRequest, *JsonRpcError) {
requests, err := ParseRequests(data)
if err != nil {
return nil, err
}
if len(requests) == 0 {
return nil, &JsonRpcError{Code: InvalidRequest, Message: "no requests found"}
}
return requests[0], nil
}
// ParseRequests 解析单个或批量 JSON-RPC 请求。返回请求切片与错误(解析层面),
// 若是批量空数组则返回 InvalidRequest 错误(协议要求)。
func ParseRequests(data []byte) ([]*JsonRpcRequest, *JsonRpcError) {
data = bytes.TrimSpace(data)
if len(data) == 0 {
return nil, &JsonRpcError{Code: ParseError, Message: "empty body"}
}
first := data[0]
if first == '{' { // 单个
var r JsonRpcRequest
if err := json.Unmarshal(data, &r); err != nil {
return nil, &JsonRpcError{Code: ParseError, Message: "invalid json", Data: err.Error()}
}
if e := r.Validate(); e != nil {
return nil, e
}
return []*JsonRpcRequest{&r}, nil
}
if first == '[' { // 批量
var arr []JsonRpcRequest
if err := json.Unmarshal(data, &arr); err != nil {
return nil, &JsonRpcError{Code: ParseError, Message: "invalid json", Data: err.Error()}
}
if len(arr) == 0 {
return nil, &JsonRpcError{Code: InvalidRequest, Message: "empty batch"}
}
res := make([]*JsonRpcRequest, 0, len(arr))
for i := range arr {
rr := arr[i]
if e := rr.Validate(); e != nil {
return nil, e
}
res = append(res, &rr)
}
return res, nil
}
return nil, &JsonRpcError{Code: ParseError, Message: "invalid json: not object/array"}
}
+53
View File
@@ -0,0 +1,53 @@
package rpc
import (
"context"
"errors"
"fmt"
"strings"
"sync"
)
// Handler 方法签名:返回 result (成功) 或 *JsonRpcError (失败)
type Handler func(ctx context.Context, req *JsonRpcRequest) (any, *JsonRpcError)
var (
muHandlers sync.RWMutex
handlers = map[string]Handler{}
)
// Register 注册方法。重复注册返回错误。保留前缀 "rpc." 禁止外部注册。
func Register(method string, h Handler) error {
method = strings.TrimSpace(method)
if method == "" {
return errors.New("method empty")
}
if strings.HasPrefix(method, "rpc.") {
return errors.New("method prefix 'rpc.' is reserved")
}
muHandlers.Lock()
defer muHandlers.Unlock()
if _, exists := handlers[method]; exists {
return fmt.Errorf("method already registered: %s", method)
}
handlers[method] = h
return nil
}
// MustRegister 便捷注册(panic on error)
func MustRegister(method string, h Handler) {
if err := Register(method, h); err != nil {
panic(err)
}
}
// ListMethods 列出当前已注册的方法名(副本)
func ListMethods() []string {
muHandlers.RLock()
defer muHandlers.RUnlock()
res := make([]string, 0, len(handlers))
for k := range handlers {
res = append(res, k)
}
return res
}
+227
View File
@@ -0,0 +1,227 @@
package rpc
import (
"encoding/json"
"errors"
"reflect"
"strings"
)
// JsonRpcRequest 表示 JSON-RPC 2.0 请求(单个或批量中的一个元素)
// 说明:为了能区分 Notification(无 id),这里将 ID 定义为 any 并打上 omitempty。
// 你可以通过 req.HasID() 判断是否为普通请求(需要返回)还是通知。
type JsonRpcRequest struct {
Version string `json:"jsonrpc"` // 必须 === "2.0"
Method string `json:"method"` // 方法名;以 "rpc." 前缀的为保留内部方法
Params any `json:"params,omitempty"` // 参数(位置数组或命名对象)
ID any `json:"id,omitempty"` // 字符串 / 数值 / null;Notification 时省略
}
// NewRequest 创建一个普通请求(带 id)
func NewRequest(id any, method string, params any) *JsonRpcRequest {
return &JsonRpcRequest{Version: RPC_VERSION, Method: method, Params: params, ID: id}
}
// NewNotification 创建一个 Notification(无 id,不会收到返回)
func NewNotification(method string, params any) *JsonRpcRequest {
return &JsonRpcRequest{Version: RPC_VERSION, Method: method, Params: params}
}
// HasID 判断是否包含 id(Notification 没有 id,不需要返回)
func (r *JsonRpcRequest) HasID() bool { return r != nil && r.ID != nil }
// Validate 校验请求格式合法性(不校验方法是否存在)
func (r *JsonRpcRequest) Validate() *JsonRpcError {
if r == nil {
return &JsonRpcError{Code: InvalidRequest, Message: "invalid request: null"}
}
if r.Version != RPC_VERSION {
return &JsonRpcError{Code: InvalidRequest, Message: "invalid jsonrpc version"}
}
if strings.TrimSpace(r.Method) == "" {
return &JsonRpcError{Code: InvalidRequest, Message: "method required"}
}
return nil
}
// GetParams(兼容旧接口):按名称获取参数(仅 map Object 情况),没有则 target 不变
func (r *JsonRpcRequest) GetParams(name string, target *any) {
if r == nil || r.Params == nil || target == nil {
return
}
if m, ok := r.Params.(map[string]any); ok {
*target = m[name]
}
}
// GetParamAs 获取具名参数并尝试转换为类型 T
func GetParamAs[T any](req *JsonRpcRequest, name string) (val T, ok bool) {
if req == nil || req.Params == nil {
return
}
if m, isMap := req.Params.(map[string]any); isMap {
raw, exists := m[name]
if !exists {
return
}
if v, good := raw.(T); good {
return v, true
}
b, err := json.Marshal(raw)
if err != nil {
return
}
var t T
if err = json.Unmarshal(b, &t); err != nil {
return
}
return t, true
}
return
}
// GetPositionalParamAs 获取位置参数 idx 的值为 T
func GetPositionalParamAs[T any](req *JsonRpcRequest, idx int) (val T, ok bool) {
if req == nil || req.Params == nil {
return
}
if arr, isArr := req.Params.([]any); isArr {
if idx < 0 || idx >= len(arr) {
return
}
raw := arr[idx]
if v, good := raw.(T); good {
return v, true
}
b, err := json.Marshal(raw)
if err != nil {
return
}
var t T
if err = json.Unmarshal(b, &t); err != nil {
return
}
return t, true
}
return
}
// BindParams 将 Params 绑定到给定结构体指针。
// 支持:
// 1. object(map) -> 按字段名反序列化(标准 encoding/json 行为,大小写不敏感)
// 2. array([]any) -> 若 target 是结构体指针,按导出字段声明顺序依次填充;
// 数组更短: 剩余字段保持零值;数组更长: 忽略多余元素。
// 非结构体指针则退回原逻辑整体反序列化。
// 3. 单一标量 -> 若 target 是结构体指针,则赋值给第一个导出字段;否则整体反序列化。
// 4. 其它类型 -> 直接整体反序列化。
func (r *JsonRpcRequest) BindParams(target any) error {
if r == nil {
return errors.New("nil request")
}
if target == nil || reflect.ValueOf(target).Kind() != reflect.Ptr {
return errors.New("target must be pointer")
}
if r.Params == nil {
return nil
}
switch p := r.Params.(type) {
case map[string]any:
b, err := json.Marshal(p)
if err != nil {
return err
}
return json.Unmarshal(b, target)
case []any:
// 特殊处理:struct 指针时按字段顺序映射
rv := reflect.ValueOf(target).Elem()
if rv.Kind() == reflect.Struct {
rt := rv.Type()
ai := 0
for i := 0; i < rt.NumField() && ai < len(p); i++ {
f := rt.Field(i)
if f.PkgPath != "" { // 非导出字段跳过(不占位置)
continue
}
fv := rv.Field(i)
raw := p[ai]
ai++
// 快速路径:可直接赋值
if raw != nil {
val := reflect.ValueOf(raw)
if val.IsValid() {
if val.Type().AssignableTo(fv.Type()) {
fv.Set(val)
continue
}
if val.Type().ConvertibleTo(fv.Type()) {
fv.Set(val.Convert(fv.Type()))
continue
}
}
}
// 回退:通过 JSON 做一次精确转换(处理数字 float64 -> int 等)
b, err := json.Marshal(raw)
if err != nil {
return err
}
tmp := reflect.New(fv.Type())
if err = json.Unmarshal(b, tmp.Interface()); err != nil {
return err
}
fv.Set(tmp.Elem())
}
return nil
}
// 非 struct 情况,退回整体解码
b, err := json.Marshal(p)
if err != nil {
return err
}
return json.Unmarshal(b, target)
default:
// 单一标量到结构体首个导出字段
rv := reflect.ValueOf(target).Elem()
if rv.Kind() == reflect.Struct {
rt := rv.Type()
for i := 0; i < rt.NumField(); i++ {
f := rt.Field(i)
if f.PkgPath != "" { // 非导出
continue
}
fv := rv.Field(i)
raw := p
if raw != nil {
val := reflect.ValueOf(raw)
if val.IsValid() {
if val.Type().AssignableTo(fv.Type()) {
fv.Set(val)
return nil
}
if val.Type().ConvertibleTo(fv.Type()) {
fv.Set(val.Convert(fv.Type()))
return nil
}
}
}
// fallback json 转换
b, err := json.Marshal(raw)
if err != nil {
return err
}
tmp := reflect.New(fv.Type())
if err = json.Unmarshal(b, tmp.Interface()); err != nil {
return err
}
fv.Set(tmp.Elem())
return nil
}
// 无导出字段
return nil
}
b, err := json.Marshal(p)
if err != nil {
return err
}
return json.Unmarshal(b, target)
}
}
+30
View File
@@ -0,0 +1,30 @@
package rpc
// JsonRpcResponse JSON-RPC 2.0 响应
// 成功时包含 result,失败时包含 error;二者互斥。
// 在 Notification 情况下服务器不会发送任何响应。
type JsonRpcResponse struct {
Version string `json:"jsonrpc"`
ID any `json:"id,omitempty"`
Result any `json:"result,omitempty"`
Error *JsonRpcError `json:"error,omitempty"`
}
// SuccessResponse 构造成功响应
func SuccessResponse(id any, result any) *JsonRpcResponse {
return &JsonRpcResponse{Version: RPC_VERSION, ID: id, Result: result}
}
// ErrorResponse 构造失败响应
func ErrorResponse(id any, code int, msg string, data any) *JsonRpcResponse {
return &JsonRpcResponse{Version: RPC_VERSION, ID: id, Error: &JsonRpcError{Code: code, Message: msg, Data: data}}
}
// InternalErrorResponse 统一内部错误
func InternalErrorResponse(id any, err error) *JsonRpcResponse {
msg := "internal error"
if err != nil {
msg = err.Error()
}
return ErrorResponse(id, InternalError, msg, nil)
}
+5
View File
@@ -0,0 +1,5 @@
package rpc
const (
RPC_VERSION = "2.0"
)
+108
View File
@@ -0,0 +1,108 @@
package rpc
import (
"context"
"testing"
)
// helper to fetch handler (test only)
func getHandler(name string) Handler {
muHandlers.RLock()
defer muHandlers.RUnlock()
return handlers[name]
}
// 重复注册
func TestRegisterDuplicateAndReserved(t *testing.T) {
name := "sample.method"
// first register should succeed
if err := Register(name, func(ctx context.Context, req *JsonRpcRequest) (any, *JsonRpcError) { return "ok", nil }); err != nil {
t.Fatalf("first Register failed: %v", err)
}
// duplicate register should error
if err := Register(name, func(ctx context.Context, req *JsonRpcRequest) (any, *JsonRpcError) { return "ok", nil }); err == nil {
t.Fatalf("expected duplicate register error")
}
// reserved prefix
if err := Register("rpc.test", func(ctx context.Context, req *JsonRpcRequest) (any, *JsonRpcError) { return nil, nil }); err == nil {
t.Fatalf("expected reserved prefix error")
}
}
// 内部函数
func TestInternalMethods(t *testing.T) {
res, err := Invoke("rpc.ping", nil)
if err != nil {
t.Fatalf("rpc.ping returned error: %+v", err)
}
if res != "pong" {
t.Fatalf("expected pong got %v", res)
}
// version
res, err = Invoke("rpc.version", nil)
if err != nil {
t.Fatalf("rpc.version returned error: %+v", err)
}
if res != RPC_VERSION {
t.Fatalf("expected version %s got %v", RPC_VERSION, res)
}
}
// Test rpc.help functionality
func TestRpcHelp(t *testing.T) {
// Register a method with metadata
method := "demo.echo"
RegisterWithMeta(method, func(ctx context.Context, req *JsonRpcRequest) (any, *JsonRpcError) {
v, _ := GetParamAs[string](req, "v")
return v, nil
}, &MethodMeta{Summary: "echo back string", Description: "Returns the provided string parameter v", Params: []ParamMeta{{Name: "v", Type: "string", Required: false, Description: "value to echo"}}, Returns: "string"})
// Query single method
resp := Call(1, "rpc.help", map[string]any{"method": method})
if resp.Error != nil {
t.Fatalf("rpc.help single error: %+v", resp.Error)
}
resp = Call(2, "rpc.help", "rpc.help")
if resp.Error != nil {
t.Fatalf("rpc.help multiple error: %+v", resp.Error)
}
resp = Call(3, "rpc.help", nil)
if resp.Error != nil {
t.Fatalf("rpc.help nil params error: %+v", resp.Error)
}
}
// Test BindParams positional array -> struct mapping
func TestBindParamsPositionalStruct(t *testing.T) {
type Pair struct {
First int
Second int
}
req := &JsonRpcRequest{Version: RPC_VERSION, Method: "x", Params: []any{1, 2}}
var p Pair
if err := req.BindParams(&p); err != nil {
t.Fatalf("BindParams failed: %v", err)
}
if p.First != 1 || p.Second != 2 {
t.Fatalf("unexpected struct values: %+v", p)
}
// shorter array
req2 := &JsonRpcRequest{Version: RPC_VERSION, Method: "x", Params: []any{7}}
p = Pair{}
if err := req2.BindParams(&p); err != nil {
t.Fatalf("BindParams short failed: %v", err)
}
if p.First != 7 || p.Second != 0 {
t.Fatalf("expected (7,0) got %+v", p)
}
// longer array (extra ignored)
req3 := &JsonRpcRequest{Version: RPC_VERSION, Method: "x", Params: []any{9, 8, 100}}
p = Pair{}
if err := req3.BindParams(&p); err != nil {
t.Fatalf("BindParams long failed: %v", err)
}
if p.First != 9 || p.Second != 8 {
t.Fatalf("expected (9,8) got %+v", p)
}
}
+292 -2
View File
@@ -2,6 +2,7 @@ package utils
import (
"sort"
"strings"
"time"
"github.com/komari-monitor/komari/common"
@@ -48,13 +49,19 @@ func AverageReport(uuid string, time time.Time, records []common.Report, topPerc
}
}
var sumCPU, sumLOAD float32
var sumCPU, sumLOAD, sumGPU float32
var sumRAM, sumRAMTotal, sumSWAP, sumSWAPTotal, sumDISK, sumDISKTotal, sumNETIn, sumNETOut, sumNETTotalUp, sumNETTotalDown int64
var sumPROCESS, sumConnections, sumConnectionsUDP int
if topPercentage > 0 && topPercentage <= 1 {
sumCPU, _ = sumAndSort(func(r common.Report) float32 { return float32(r.CPU.Usage) }, nil, true)
sumLOAD, _ = sumAndSort(func(r common.Report) float32 { return float32(r.Load.Load1) }, nil, true)
sumGPU, _ = sumAndSort(func(r common.Report) float32 {
if r.GPU != nil {
return float32(r.GPU.AverageUsage)
}
return 0
}, nil, true)
_, sumRAM = sumAndSort(nil, func(r common.Report) int64 { return r.Ram.Used }, false)
//_, sumRAMTotal = sumAndSort(nil, nil, func(r common.Report) int64 { return r.Ram.Total }, false)
@@ -78,6 +85,9 @@ func AverageReport(uuid string, time time.Time, records []common.Report, topPerc
for _, r := range records {
sumCPU += float32(r.CPU.Usage)
sumLOAD += float32(r.Load.Load1)
if r.GPU != nil {
sumGPU += float32(r.GPU.AverageUsage)
}
sumRAM += r.Ram.Used
sumRAMTotal += r.Ram.Total
sumSWAP += r.Swap.Used
@@ -99,7 +109,7 @@ func AverageReport(uuid string, time time.Time, records []common.Report, topPerc
Client: uuid,
Time: models.FromTime(time),
Cpu: sumCPU / float32(recordsToAverageCount),
Gpu: 0, // 保持原始行为
Gpu: sumGPU / float32(recordsToAverageCount), // 计算GPU平均使用率
Ram: sumRAM / int64(recordsToAverageCount),
RamTotal: records[0].Ram.Total,
Swap: sumSWAP / int64(recordsToAverageCount),
@@ -118,3 +128,283 @@ func AverageReport(uuid string, time time.Time, records []common.Report, topPerc
}
return newRecord
}
// AverageGPUReports 使用与 AverageReport 相同的聚合逻辑处理GPU数据
// 返回每个GPU设备的聚合记录
func AverageGPUReports(uuid string, time time.Time, reports []common.Report, topPercentage float64) []models.GPURecord {
if len(reports) == 0 {
return []models.GPURecord{}
}
// 收集所有GPU设备数据
deviceData := make(map[int]struct {
DeviceName string
MemTotal []int64
MemUsed []int64
Utilization []float64
Temperature []int
})
for _, report := range reports {
if report.GPU != nil && len(report.GPU.DetailedInfo) > 0 {
for idx, gpu := range report.GPU.DetailedInfo {
if _, exists := deviceData[idx]; !exists {
deviceData[idx] = struct {
DeviceName string
MemTotal []int64
MemUsed []int64
Utilization []float64
Temperature []int
}{DeviceName: gpu.Name}
}
data := deviceData[idx]
data.MemTotal = append(data.MemTotal, gpu.MemoryTotal)
data.MemUsed = append(data.MemUsed, gpu.MemoryUsed)
data.Utilization = append(data.Utilization, gpu.Utilization)
data.Temperature = append(data.Temperature, gpu.Temperature)
deviceData[idx] = data
}
}
}
// 复用现有的聚合逻辑
sumAndSort := func(values []float64, topPerc float64) float64 {
if len(values) == 0 {
return 0
}
count := len(values)
recordsToAverageCount := count
if topPerc > 0 && topPerc <= 1 {
recordsToAverageCount = int(float64(count) * topPerc)
if recordsToAverageCount == 0 && count > 0 {
recordsToAverageCount = 1
}
}
if topPerc > 0 && topPerc <= 1 {
sort.Float64s(values)
// 取最高的值
var sum float64
for i := count - recordsToAverageCount; i < count; i++ {
sum += values[i]
}
return sum / float64(recordsToAverageCount)
} else {
var sum float64
for _, val := range values {
sum += val
}
return sum / float64(count)
}
}
sumAndSortInt64 := func(values []int64, topPerc float64) int64 {
if len(values) == 0 {
return 0
}
count := len(values)
recordsToAverageCount := count
if topPerc > 0 && topPerc <= 1 {
recordsToAverageCount = int(float64(count) * topPerc)
if recordsToAverageCount == 0 && count > 0 {
recordsToAverageCount = 1
}
}
if topPerc > 0 && topPerc <= 1 {
sort.Slice(values, func(i, j int) bool { return values[i] > values[j] })
var sum int64
for i := 0; i < recordsToAverageCount; i++ {
sum += values[i]
}
return sum / int64(recordsToAverageCount)
} else {
var sum int64
for _, val := range values {
sum += val
}
return sum / int64(count)
}
}
sumAndSortInt := func(values []int, topPerc float64) int {
if len(values) == 0 {
return 0
}
int64Values := make([]int64, len(values))
for i, v := range values {
int64Values[i] = int64(v)
}
return int(sumAndSortInt64(int64Values, topPerc))
}
// 生成每个设备的聚合记录
var result []models.GPURecord
for deviceIndex, data := range deviceData {
if len(data.MemTotal) > 0 {
record := models.GPURecord{
Client: uuid,
Time: models.FromTime(time),
DeviceIndex: deviceIndex,
DeviceName: data.DeviceName,
MemTotal: sumAndSortInt64(data.MemTotal, topPercentage),
MemUsed: sumAndSortInt64(data.MemUsed, topPercentage),
Utilization: float32(sumAndSort(data.Utilization, topPercentage)),
Temperature: sumAndSortInt(data.Temperature, topPercentage),
}
result = append(result, record)
}
}
return result
}
func DataMasking(str string, private []string) string {
if str == "" || len(private) == 0 {
return str
}
mask := "********"
// 相似度阈值,可根据需要调节(0~1,越大越严格)
const threshold = 0.8
runes := []rune(str)
n := len(runes)
toMask := make([]bool, n)
// 预处理 private 中的词,去掉空、重复
uniq := make(map[string]struct{})
var words []string
for _, w := range private {
w = strings.TrimSpace(w)
if w == "" {
continue
}
if _, ok := uniq[w]; ok {
continue
}
uniq[w] = struct{}{}
words = append(words, w)
}
if len(words) == 0 {
return str
}
// 逐词进行滑动窗口匹配 + 模糊匹配(Levenshtein 相似度)
for _, w := range words {
wRunes := []rune(w)
wl := len(wRunes)
if wl == 0 || wl > n {
continue
}
// 滑动窗口大小采用敏感词长度
for i := 0; i <= n-wl; i++ {
if allMasked(toMask[i : i+wl]) { // 已全被标记则跳过
continue
}
sub := string(runes[i : i+wl])
sim := similarity(sub, w)
if sim >= threshold {
for k := 0; k < wl; k++ {
toMask[i+k] = true
}
}
}
}
// 构造输出:连续的掩码段只输出一次;如果原始被遮蔽长度>5,展示首尾字符
var b strings.Builder
i := 0
for i < n {
if toMask[i] {
start := i
for i < n && toMask[i] {
i++
}
end := i // 不包含
segLen := end - start
if segLen > 5 {
b.WriteRune(runes[start])
b.WriteString(mask)
b.WriteRune(runes[end-1])
} else {
b.WriteString(mask)
}
} else {
b.WriteRune(runes[i])
i++
}
}
return b.String()
}
// allMasked 判断一个区间是否全部已经被标记
func allMasked(bools []bool) bool {
for _, v := range bools {
if !v {
return false
}
}
return true
}
// similarity 返回两个字符串的相似度 (0~1),基于 Levenshtein 距离
func similarity(a, b string) float64 {
if a == b {
return 1
}
ar := []rune(a)
br := []rune(b)
dist := levenshtein(ar, br)
maxLen := len(ar)
if len(br) > maxLen {
maxLen = len(br)
}
if maxLen == 0 {
return 1
}
return 1 - float64(dist)/float64(maxLen)
}
// levenshtein 计算两个 rune slice 的编辑距离
func levenshtein(a, b []rune) int {
la, lb := len(a), len(b)
if la == 0 {
return lb
}
if lb == 0 {
return la
}
// 使用滚动数组降低空间复杂度
prev := make([]int, lb+1)
curr := make([]int, lb+1)
for j := 0; j <= lb; j++ {
prev[j] = j
}
for i := 1; i <= la; i++ {
curr[0] = i
for j := 1; j <= lb; j++ {
cost := 0
if a[i-1] != b[j-1] {
cost = 1
}
del := prev[j] + 1
ins := curr[j-1] + 1
sub := prev[j-1] + cost
curr[j] = minInt(del, ins, sub)
}
prev, curr = curr, prev
}
return prev[lb]
}
func minInt(vals ...int) int {
m := vals[0]
for _, v := range vals[1:] {
if v < m {
m = v
}
}
return m
}
-69
View File
@@ -1,69 +0,0 @@
package ws
import (
"net/http"
"github.com/gorilla/websocket"
"github.com/komari-monitor/komari/common"
"github.com/gin-gonic/gin"
)
func GetClients(c *gin.Context) {
// 升级到ws
if !websocket.IsWebSocketUpgrade(c.Request) {
c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "Require WebSocket upgrade"})
return
}
upgrader := websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
return true
},
}
// Upgrade the HTTP connection to a WebSocket connection
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "Failed to upgrade to WebSocket"})
return
}
defer conn.Close()
// 请求
for {
var resp struct {
Online []string `json:"online"` // 已建立连接的客户端uuid列表
Data map[string]*common.Report `json:"data"` // 最后上报的数据
}
resp.Online = []string{}
_, data, err := conn.ReadMessage()
if err != nil {
//log.Println("Error reading message:", err)
return
}
message := string(data)
if message != "get" {
conn.WriteJSON(gin.H{"status": "error", "error": "Invalid message"})
continue
}
// 已建立连接的客户端uuid列表
for key := range GetConnectedClients() {
resp.Online = append(resp.Online, key)
}
// 清除UUID,简化报告单
resp.Data = GetLatestReport()
for _, report := range resp.Data {
report.UUID = ""
}
for _, report := range resp.Data {
if report.CPU.Usage == 0 {
report.CPU.Usage = 0.01
}
}
err = conn.WriteJSON(gin.H{"status": "success", "data": resp})
if err != nil {
return
}
}
}
+69 -4
View File
@@ -2,6 +2,7 @@ package ws
import (
"sync"
"time"
"github.com/gorilla/websocket"
"github.com/komari-monitor/komari/common"
@@ -11,7 +12,13 @@ var (
connectedClients = make(map[string]*SafeConn)
ConnectedUsers = []*websocket.Conn{}
latestReport = make(map[string]*common.Report)
mu = sync.RWMutex{}
// presenceOnly stores online state for non-WebSocket agents (e.g., Nezha gRPC)
// value keeps connectionID and a soft expiration to avoid flicker
presenceOnly = make(map[string]struct {
id int64
expire time.Time
})
mu = sync.RWMutex{}
)
func GetConnectedClients() map[string]*SafeConn {
@@ -29,14 +36,72 @@ func SetConnectedClients(uuid string, conn *SafeConn) {
defer mu.Unlock()
connectedClients[uuid] = conn
}
func DeleteClientConditionally(uuid string, connToRemove *SafeConn) {
mu.Lock()
defer mu.Unlock()
// 检查当前 map 里的 conn 是否就是要删除的这一个
if currentConn, exists := connectedClients[uuid]; exists && currentConn == connToRemove {
delete(connectedClients, uuid)
}
}
func DeleteConnectedClients(uuid string) {
mu.Lock()
defer mu.Unlock()
if conn, exists := connectedClients[uuid]; exists {
conn.Close()
}
// 只从 map 中删除,不再负责关闭连接
delete(connectedClients, uuid)
}
// SetPresence sets or clears presence for non-WebSocket agents.
// When present=false, it only clears if the connectionID matches current one.
// KeepAlivePresence sets presence with TTL for non-WebSocket agents.
func KeepAlivePresence(uuid string, connectionID int64, ttl time.Duration) {
mu.Lock()
defer mu.Unlock()
presenceOnly[uuid] = struct {
id int64
expire time.Time
}{id: connectionID, expire: time.Now().Add(ttl)}
}
var defaultPresenceTTL = 20 * time.Second
// SetPresence keeps compatibility with existing callers.
func SetPresence(uuid string, connectionID int64, present bool) {
mu.Lock()
defer mu.Unlock()
if present {
presenceOnly[uuid] = struct {
id int64
expire time.Time
}{id: connectionID, expire: time.Now().Add(defaultPresenceTTL)}
return
}
if cur, ok := presenceOnly[uuid]; ok && cur.id == connectionID {
delete(presenceOnly, uuid)
}
}
// GetAllOnlineUUIDs returns a de-duplicated list of online UUIDs from both WebSocket and non-WebSocket agents.
func GetAllOnlineUUIDs() []string {
mu.RLock()
defer mu.RUnlock()
set := make(map[string]struct{})
for k := range connectedClients {
set[k] = struct{}{}
}
now := time.Now()
for k, v := range presenceOnly {
if v.expire.After(now) {
set[k] = struct{}{}
}
}
res := make([]string, 0, len(set))
for k := range set {
res = append(res, k)
}
return res
}
func GetLatestReport() map[string]*common.Report {
mu.RLock()
defer mu.RUnlock()
+25
View File
@@ -0,0 +1,25 @@
package ws
import (
"net/http"
"net/url"
"os"
"strings"
)
func CheckOrigin(r *http.Request) bool {
origin := r.Header.Get("Origin")
// 显式关闭校验
if strings.EqualFold(os.Getenv("KOMARI_WS_DISABLE_ORIGIN"), "true") {
return true
}
if origin == "" {
return false
}
host := r.Host
originUrl, err := url.Parse(origin)
if err != nil {
return false
}
return originUrl.Host == host
}
+2
View File
@@ -10,12 +10,14 @@ import (
type SafeConn struct {
conn *websocket.Conn
mu sync.Mutex
ID int64
}
func NewSafeConn(conn *websocket.Conn) *SafeConn {
return &SafeConn{
conn: conn,
mu: sync.Mutex{},
ID: time.Now().UnixNano(),
}
}
Loaded 100 of 101 files, more files were not shown because too many files have changed in this diff. Show more