Compare commits

..
Author SHA1 Message Date
tomsun28andDevin 17bd9f7e73 ci(doc-deploy): fix pnpm cache setup and upgrade Node to 22
The doc-deploy workflow failed with "Unable to locate executable file:
pnpm" because actions/setup-node (with cache: pnpm) ran before pnpm was
installed. Reorder the steps so pnpm/action-setup runs first, matching
the order already used in doc-build-test.yml. Also pin pnpm to v10 and
upgrade Node.js from 20 (deprecated on GitHub Actions runners) to 22
in both doc workflows to clear the deprecation warning.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-07-25 21:09:27 +08:00
Tomsun28 c805d633b6 docs: add new contributors and update notice year (#4245) 2026-07-25 20:22:02 +08:00
NekoPunchandTomsun28 c12372d82d fix(web-app): replace removed stylelint --syntax flag (#4241)
Co-authored-by: Tomsun28 <tomsun28@outlook.com>
2026-07-25 14:28:14 +08:00
NekoPunchandTomsun28 63cc017bba fix(web-app): point threshold user guide link to correct docs (#4239)
Co-authored-by: Tomsun28 <tomsun28@outlook.com>
2026-07-25 14:19:44 +08:00
NekoPunchandTomsun28 9531a2fcd3 fix(web-app): add missing Korean observability menu translations (#4240)
Co-authored-by: Tomsun28 <tomsun28@outlook.com>
2026-07-25 14:13:10 +08:00
Yang Chen 717521369c fix: update GreptimeDB Docker Compose to v1.1.3 (#4244) 2026-07-25 14:08:51 +08:00
Yang Chen c8f2da472e fix: add SSE authentication and correct GreptimeDB SQL (#4243) 2026-07-25 13:09:13 +08:00
Logic 6152c07320 refactor: evolve common-core payload compatibility (#4237) 2026-07-24 15:07:35 +08:00
Duansg b832d7ace4 [fix] fix horizontal scroll not working in periodic threshold preview table (#4238) 2026-07-24 10:39:36 +08:00
DuansgandTomsun28 3ab68b5814 [improve] degrade observability consoles gracefully when storage lacks log/trace/metric support (#4233)
Co-authored-by: Tomsun28 <tomsun28@outlook.com>
2026-07-24 00:54:05 +08:00
shown 1d9edb244c feat: use spring ai MethodToolCallbackProvider replace custom reflect… (#4234)
Signed-off-by: yuluo-yx <yuluo08290126@gmail.com>
2026-07-23 20:41:28 +08:00
Duansg 1429115282 [improve] pre-create hertzbeat_logs table during greptime signal initialization (#4231) 2026-07-23 11:34:35 +08:00
4b217e7097 feat(status-page): add configurable time range for component history (#4222)
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: Duansg <siguoduan@gmail.com>
Co-authored-by: Tomsun28 <tomsun28@outlook.com>
2026-07-23 00:23:37 +08:00
shown 032a4852f7 fix(ai): propagate security context to tool callbacks (#4230)
Signed-off-by: yuluo-yx <yuluo08290126@gmail.com>
2026-07-22 23:46:33 +08:00
Duansg 6b78b784a2 [improve] remove unused log management translations (#4229) 2026-07-22 14:33:17 +08:00
Duansg 11cd5bb0c7 [improve] reject masked receiver edit when the stored receiver does not exist (#4225) 2026-07-22 01:32:24 +08:00
hengyussandTomsun28 a9eb231989 fix:prometheus timeout monitor key mismatch (#4215)
Co-authored-by: Tomsun28 <tomsun28@outlook.com>
2026-07-21 23:59:29 +08:00
shownandTomsun28 9b31baec8a fix(ai): improve SOP execution and provider config handling (#4224)
Signed-off-by: yuluo-yx <yuluo08290126@gmail.com>
Co-authored-by: Tomsun28 <tomsun28@outlook.com>
2026-07-21 23:34:06 +08:00
DuansgandTomsun28 0a38a73756 [improve] form-urlencode SQL request body in GreptimeSqlQueryExecutor (#4223)
Co-authored-by: Tomsun28 <tomsun28@outlook.com>
2026-07-21 23:19:43 +08:00
Duansg c9e3338c9f [improve] mask secret fields of notice receiver in rest api response (#4220) 2026-07-21 23:11:30 +08:00
shown 107f7d02ff fix(log): send initial SSE event and periodic heartbeat (#4221)
Signed-off-by: yuluo-yx <yuluo08290126@gmail.com>
2026-07-20 22:42:25 +08:00
shown e3acbbd6db chore(ci): Add workflow_dispatch trigger to nightly build (#4213) 2026-07-18 01:45:13 +08:00
31bc7d8a51 [feature] Add Korean i18n (#4197)
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: Duansg <siguoduan@gmail.com>
Co-authored-by: Tomsun28 <tomsun28@outlook.com>
2026-07-17 21:51:27 +08:00
Duansg 79427099a2 [improve] bump grpc-java from 1.56.1 to 1.76.3 (#4211) 2026-07-17 10:40:17 +08:00
Duansg 0edc363bcc fix: remove duplicated ASF license headers left by license-eye auto-fix (#4210) 2026-07-16 22:58:42 +08:00
47538dbc1a feat(observability): add Angular three-signal transition (#4195)
Signed-off-by: Duansg <siguoduan@gmail.com>
Signed-off-by: Tomsun28 <tomsun28@outlook.com>
Co-authored-by: Duansg <siguoduan@gmail.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Tomsun28 <tomsun28@outlook.com>
Co-authored-by: aias00 <liuhongyu@apache.org>
2026-07-16 20:39:17 +08:00
122 changed files with 5537 additions and 1273 deletions
+204
View File
@@ -2713,6 +2713,210 @@
"contributions": [
"code"
]
},
{
"login": "orangeCatDeveloper",
"name": "NekoPunch",
"avatar_url": "https://avatars.githubusercontent.com/u/95899648?v=4",
"profile": "https://github.com/orangeCatDeveloper",
"contributions": [
"code",
"doc"
]
},
{
"login": "wy471x",
"name": "wy471x",
"avatar_url": "https://avatars.githubusercontent.com/u/52033069?v=4",
"profile": "https://wy471x.github.io/",
"contributions": [
"code"
]
},
{
"login": "hengyuss",
"name": "hengyuss",
"avatar_url": "https://avatars.githubusercontent.com/u/81064732?v=4",
"profile": "https://github.com/hengyuss",
"contributions": [
"code"
]
},
{
"login": "moduvoice",
"name": "moduvoice",
"avatar_url": "https://avatars.githubusercontent.com/u/291867022?v=4",
"profile": "https://moduvoice.com/",
"contributions": [
"doc"
]
},
{
"login": "hutiefang76",
"name": "hutiefang76",
"avatar_url": "https://avatars.githubusercontent.com/u/137664623?v=4",
"profile": "https://github.com/hutiefang76",
"contributions": [
"code"
]
},
{
"login": "ZhouYinLong-lab",
"name": "柳含知 Liu Hanzhi",
"avatar_url": "https://avatars.githubusercontent.com/u/250777154?v=4",
"profile": "https://zylatent.com/",
"contributions": [
"doc"
]
},
{
"login": "wilmerdooley",
"name": "wilmerdooley",
"avatar_url": "https://avatars.githubusercontent.com/u/259930736?v=4",
"profile": "https://github.com/wilmerdooley",
"contributions": [
"code"
]
},
{
"login": "Zmjjeff7",
"name": "Zhenhong Guo",
"avatar_url": "https://avatars.githubusercontent.com/u/175370943?v=4",
"profile": "https://github.com/Zmjjeff7",
"contributions": [
"code"
]
},
{
"login": "abhyudayareddy",
"name": "abhyudayareddy",
"avatar_url": "https://avatars.githubusercontent.com/u/54602866?v=4",
"profile": "https://github.com/abhyudayareddy",
"contributions": [
"code"
]
},
{
"login": "neon-hippo",
"name": "neon-hippo",
"avatar_url": "https://avatars.githubusercontent.com/u/165560498?v=4",
"profile": "https://github.com/neon-hippo",
"contributions": [
"code"
]
},
{
"login": "P-Peaceful",
"name": "P_Peaceful",
"avatar_url": "https://avatars.githubusercontent.com/u/52856161?v=4",
"profile": "https://github.com/P-Peaceful",
"contributions": [
"code",
"doc"
]
},
{
"login": "zhusaidong",
"name": "zhusaidong",
"avatar_url": "https://avatars.githubusercontent.com/u/3039961?v=4",
"profile": "https://github.com/zhusaidong",
"contributions": [
"code"
]
},
{
"login": "zhehenlu",
"name": "zhlu",
"avatar_url": "https://avatars.githubusercontent.com/u/31504542?v=4",
"profile": "https://github.com/zhehenlu",
"contributions": [
"code",
"doc"
]
},
{
"login": "brettgervasoni",
"name": "brettgervasoni",
"avatar_url": "https://avatars.githubusercontent.com/u/34056000?v=4",
"profile": "https://github.com/brettgervasoni",
"contributions": [
"code"
]
},
{
"login": "Darshan-paul",
"name": "Darshan-paul",
"avatar_url": "https://avatars.githubusercontent.com/u/211450705?v=4",
"profile": "https://github.com/Darshan-paul",
"contributions": [
"code"
]
},
{
"login": "04cb",
"name": "layla",
"avatar_url": "https://avatars.githubusercontent.com/u/111667698?v=4",
"profile": "https://github.com/04cb",
"contributions": [
"doc"
]
},
{
"login": "miantalha45",
"name": "Talha Amjad",
"avatar_url": "https://avatars.githubusercontent.com/u/155809113?v=4",
"profile": "https://github.com/miantalha45",
"contributions": [
"code"
]
},
{
"login": "turanalmammadov",
"name": "Turan Almammadov",
"avatar_url": "https://avatars.githubusercontent.com/u/16321061?v=4",
"profile": "https://turanalmammadov.com/",
"contributions": [
"code",
"doc"
]
},
{
"login": "zhaoyangplus",
"name": "zhaoyangplus",
"avatar_url": "https://avatars.githubusercontent.com/u/245090302?v=4",
"profile": "https://github.com/zhaoyangplus",
"contributions": [
"code"
]
},
{
"login": "yexuanyang",
"name": "Yang Yexuan",
"avatar_url": "https://avatars.githubusercontent.com/u/73885401?v=4",
"profile": "https://github.com/yexuanyang",
"contributions": [
"code"
]
},
{
"login": "markguo123",
"name": "markguo123",
"avatar_url": "https://avatars.githubusercontent.com/u/155072651?v=4",
"profile": "https://github.com/markguo123",
"contributions": [
"doc"
]
},
{
"login": "leo-934",
"name": "leo",
"avatar_url": "https://avatars.githubusercontent.com/u/55838224?v=4",
"profile": "https://github.com/leo-934",
"contributions": [
"code",
"doc",
"blog"
]
}
],
"contributorsPerLine": 7,
+1 -1
View File
@@ -43,7 +43,7 @@ jobs:
version: 10
- uses: actions/setup-node@v4
with:
node-version: '20'
node-version: '22'
cache: pnpm
cache-dependency-path: home/pnpm-lock.yaml
- uses: actions/setup-python@v4
+7 -7
View File
@@ -47,20 +47,20 @@ jobs:
with:
fetch-depth: 0
# Setup pnpm (must run before setup-node so the pnpm cache can be configured)
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10
# Setup Node.js environment
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
node-version: '22'
cache: pnpm
cache-dependency-path: home/pnpm-lock.yaml
# Setup pnpm
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: latest
# Install dependencies in home directory
- name: Install Dependencies
working-directory: home
+1
View File
@@ -23,6 +23,7 @@ on:
- cron: '0 0 * * *'
push:
branches: [ action* ]
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
+4
View File
@@ -56,3 +56,7 @@ application-mysql.yml
application-pg.yml
/package.json
/yarn.lock
# Local Codex workspace files
.codex/
AGENTS.md
+1 -1
View File
@@ -1,5 +1,5 @@
Apache HertzBeat™
Copyright 2024-2025 The Apache Software Foundation
Copyright 2024-2026 The Apache Software Foundation
This product includes software developed at
The Apache Software Foundation (http://www.apache.org/).
+28 -46
View File
@@ -49,52 +49,6 @@
![HertzBeat](home/static/img/docs/hertzbeat-arch.png)
## ⛄ Supported
> We define all metrics collection types such as `mysql`, `jvm`, and `k8s` as `YML` templates, allowing users to import them to support corresponding types of monitoring.
> Welcome everyone to contribute your customized general metrics type YML template during use.
- [Website](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-website.yml), [Port Telnet](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-port.yml),
[Http Api](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-api.yml), [Ping Connect](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-ping.yml),
[Jvm](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-jvm.yml), [SiteMap](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-fullsite.yml),
[Ssl Certificate](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-ssl_cert.yml), [SpringBoot2](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-springboot2.yml),
[FTP Server](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-ftp.yml), [SpringBoot3](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-springboot3.yml),
[Udp Port](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-udp_port.yml), [Dns](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-dns.yml),
[Pop3](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-pop3.yml), [Ntp](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-ntp.yml),
[Api Code](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-api_code.yml), [Smtp](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-smtp.yml),
[Nginx](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-nginx.yml)
- [Mysql](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-mysql.yml), [PostgreSQL](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-postgresql.yml),
[MariaDB](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-mariadb.yml), [Redis](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-redis.yml),
[ElasticSearch](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-elasticsearch.yml), [SqlServer](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-sqlserver.yml),
[Oracle](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-oracle.yml), [MongoDB](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-mongodb.yml),
[DM](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-dm.yml), [OpenGauss](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-opengauss.yml),
[ClickHouse](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-clickhouse.yml), [IoTDB](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-iotdb.yml),
[Redis Cluster](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-redis_cluster.yml), [Redis Sentinel](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-redis_sentinel.yml)
[Doris BE](https://github.com/apache/hertzbeat/blob/master/hertzbeat-manager/src/main/resources/define/app-doris_be.yml), [Doris FE](https://github.com/apache/hertzbeat/blob/master/hertzbeat-manager/src/main/resources/define/app-doris_fe.yml),
[Memcached](https://github.com/apache/hertzbeat/blob/master/hertzbeat-manager/src/main/resources/define/app-memcached.yml), [NebulaGraph](https://github.com/apache/hertzbeat/blob/master/hertzbeat-manager/src/main/resources/define/app-nebula_graph.yml)
- [Linux](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-linux.yml), [Ubuntu](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-ubuntu.yml),
[CentOS](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-centos.yml), [Windows](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-windows.yml),
[EulerOS](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-euleros.yml), [Fedora CoreOS](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-coreos.yml),
[OpenSUSE](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-opensuse.yml), [Rocky Linux](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-rockylinux.yml),
[Red Hat](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-redhat.yml), [FreeBSD](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-freebsd.yml),
[AlmaLinux](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-almalinux.yml), [Debian Linux](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-debian.yml)
- [Tomcat](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-tomcat.yml), [Nacos](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-nacos.yml),
[Zookeeper](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-zookeeper.yml), [RabbitMQ](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-rabbitmq.yml),
[Flink](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-flink.yml), [Kafka](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-kafka.yml),
[ShenYu](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-shenyu.yml), [DynamicTp](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-dynamic_tp.yml),
[Jetty](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-jetty.yml), [ActiveMQ](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-activemq.yml),
[Spring Gateway](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-spring_gateway.yml), [EMQX MQTT](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-emqx.yml),
[AirFlow](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-airflow.yml), [Hive](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-hive.yml),
[Spark](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-spark.yml), [Hadoop](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-hadoop.yml)
- [Kubernetes](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-kubernetes.yml), [Docker](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-docker.yml)
- [CiscoSwitch](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-cisco_switch.yml), [HpeSwitch](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-hpe_switch.yml),
[HuaweiSwitch](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-huawei_switch.yml), [TpLinkSwitch](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-tplink_switch.yml),
[H3cSwitch](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-h3c_switch.yml)
- And More Your Custom Template.
- Notified Support `Discord` `Slack` `Telegram` `Email` `DingTalk` `WeChat` `FeiShu` `Webhook` `SMS` `ServerChan`.
## 🐕 Quick Start
- If you wish to deploy HertzBeat locally, please refer to the following Deployment Documentation for instructions.
@@ -551,6 +505,34 @@ Thanks to these wonderful people, welcome to join us:
<td align="center" valign="top" width="14.28%"><a href="https://shadwal.space/"><img src="https://avatars.githubusercontent.com/u/119167601?v=4?s=100" width="100px;" alt="Sahil Shadwal"/><br /><sub><b>Sahil Shadwal</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=Sahil-Shadwal" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/BhanuNidumolu"><img src="https://avatars.githubusercontent.com/u/180380413?v=4?s=100" width="100px;" alt="N.Bhanu Prasad"/><br /><sub><b>N.Bhanu Prasad</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=BhanuNidumolu" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://prakashh-portfolio.vercel.app/"><img src="https://avatars.githubusercontent.com/u/183058331?v=4?s=100" width="100px;" alt="Prakash Kumar"/><br /><sub><b>Prakash Kumar</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=Prakash1185" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/orangeCatDeveloper"><img src="https://avatars.githubusercontent.com/u/95899648?v=4?s=100" width="100px;" alt="NekoPunch"/><br /><sub><b>NekoPunch</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=orangeCatDeveloper" title="Code">💻</a> <a href="https://github.com/apache/hertzbeat/commits?author=orangeCatDeveloper" title="Documentation">📖</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://wy471x.github.io/"><img src="https://avatars.githubusercontent.com/u/52033069?v=4?s=100" width="100px;" alt="wy471x"/><br /><sub><b>wy471x</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=wy471x" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/hengyuss"><img src="https://avatars.githubusercontent.com/u/81064732?v=4?s=100" width="100px;" alt="hengyuss"/><br /><sub><b>hengyuss</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=hengyuss" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://moduvoice.com/"><img src="https://avatars.githubusercontent.com/u/291867022?v=4?s=100" width="100px;" alt="moduvoice"/><br /><sub><b>moduvoice</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=moduvoice" title="Documentation">📖</a></td>
</tr>
<tr>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/hutiefang76"><img src="https://avatars.githubusercontent.com/u/137664623?v=4?s=100" width="100px;" alt="hutiefang76"/><br /><sub><b>hutiefang76</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=hutiefang76" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://zylatent.com/"><img src="https://avatars.githubusercontent.com/u/250777154?v=4?s=100" width="100px;" alt="柳含知 Liu Hanzhi"/><br /><sub><b>柳含知 Liu Hanzhi</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=ZhouYinLong-lab" title="Documentation">📖</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/wilmerdooley"><img src="https://avatars.githubusercontent.com/u/259930736?v=4?s=100" width="100px;" alt="wilmerdooley"/><br /><sub><b>wilmerdooley</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=wilmerdooley" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Zmjjeff7"><img src="https://avatars.githubusercontent.com/u/175370943?v=4?s=100" width="100px;" alt="Zhenhong Guo"/><br /><sub><b>Zhenhong Guo</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=Zmjjeff7" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/abhyudayareddy"><img src="https://avatars.githubusercontent.com/u/54602866?v=4?s=100" width="100px;" alt="abhyudayareddy"/><br /><sub><b>abhyudayareddy</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=abhyudayareddy" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/neon-hippo"><img src="https://avatars.githubusercontent.com/u/165560498?v=4?s=100" width="100px;" alt="neon-hippo"/><br /><sub><b>neon-hippo</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=neon-hippo" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/P-Peaceful"><img src="https://avatars.githubusercontent.com/u/52856161?v=4?s=100" width="100px;" alt="P_Peaceful"/><br /><sub><b>P_Peaceful</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=P-Peaceful" title="Code">💻</a> <a href="https://github.com/apache/hertzbeat/commits?author=P-Peaceful" title="Documentation">📖</a></td>
</tr>
<tr>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/zhusaidong"><img src="https://avatars.githubusercontent.com/u/3039961?v=4?s=100" width="100px;" alt="zhusaidong"/><br /><sub><b>zhusaidong</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=zhusaidong" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/zhehenlu"><img src="https://avatars.githubusercontent.com/u/31504542?v=4?s=100" width="100px;" alt="zhlu"/><br /><sub><b>zhlu</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=zhehenlu" title="Code">💻</a> <a href="https://github.com/apache/hertzbeat/commits?author=zhehenlu" title="Documentation">📖</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/brettgervasoni"><img src="https://avatars.githubusercontent.com/u/34056000?v=4?s=100" width="100px;" alt="brettgervasoni"/><br /><sub><b>brettgervasoni</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=brettgervasoni" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Darshan-paul"><img src="https://avatars.githubusercontent.com/u/211450705?v=4?s=100" width="100px;" alt="Darshan-paul"/><br /><sub><b>Darshan-paul</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=Darshan-paul" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/04cb"><img src="https://avatars.githubusercontent.com/u/111667698?v=4?s=100" width="100px;" alt="layla"/><br /><sub><b>layla</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=04cb" title="Documentation">📖</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/miantalha45"><img src="https://avatars.githubusercontent.com/u/155809113?v=4?s=100" width="100px;" alt="Talha Amjad"/><br /><sub><b>Talha Amjad</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=miantalha45" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://turanalmammadov.com/"><img src="https://avatars.githubusercontent.com/u/16321061?v=4?s=100" width="100px;" alt="Turan Almammadov"/><br /><sub><b>Turan Almammadov</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=turanalmammadov" title="Code">💻</a> <a href="https://github.com/apache/hertzbeat/commits?author=turanalmammadov" title="Documentation">📖</a></td>
</tr>
<tr>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/zhaoyangplus"><img src="https://avatars.githubusercontent.com/u/245090302?v=4?s=100" width="100px;" alt="zhaoyangplus"/><br /><sub><b>zhaoyangplus</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=zhaoyangplus" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/yexuanyang"><img src="https://avatars.githubusercontent.com/u/73885401?v=4?s=100" width="100px;" alt="Yang Yexuan"/><br /><sub><b>Yang Yexuan</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=yexuanyang" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/markguo123"><img src="https://avatars.githubusercontent.com/u/155072651?v=4?s=100" width="100px;" alt="markguo123"/><br /><sub><b>markguo123</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=markguo123" title="Documentation">📖</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/leo-934"><img src="https://avatars.githubusercontent.com/u/55838224?v=4?s=100" width="100px;" alt="leo"/><br /><sub><b>leo</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=leo-934" title="Code">💻</a> <a href="https://github.com/apache/hertzbeat/commits?author=leo-934" title="Documentation">📖</a> <a href="#blog-leo-934" title="Blogposts">📝</a></td>
</tr>
</tbody>
</table>
+42 -58
View File
@@ -19,13 +19,13 @@
[![Contribute with Gitpod](https://img.shields.io/badge/Contribute%20with-Gitpod-908a85?logo=gitpod&color=green)](https://gitpod.io/#https://github.com/apache/hertzbeat)
**官网: [hertzbeat.apache.org](https://hertzbeat.apache.org)**
**官网: [hertzbeat.apache.org](https://hertzbeat.apache.org)**
**邮件: <a href="mailto:dev-subscribe@hertzbeat.apache.org">发送至 ```dev-subscribe@hertzbeat.apache.org```</a>** 订阅邮件列表
## 🎡 <font color="green">介绍</font>
[Apache HertzBeat™](https://github.com/apache/hertzbeat) 是 AI 驱动的下一代开源实时观测系统。指标日志统一收集,告警一站分发,智能管控分析。无需 Agent,高性能集群,提供强大的自定义监控和状态页构建能力。
[Apache HertzBeat™](https://github.com/apache/hertzbeat) 是 AI 驱动的下一代开源实时观测系统。指标日志统一收集,告警一站分发,智能管控分析。无需 Agent,高性能集群,提供强大的自定义监控和状态页构建能力。
### 特点
@@ -39,70 +39,26 @@
- 提供强大的状态页构建能力,轻松向用户传达您产品服务的实时状态。
> `HertzBeat`的统一平台,AI智能,强大自定义,多类型支持,高性能,易扩展,希望能帮助用户快速方便实现观测需求。
> `HertzBeat`的统一平台,AI智能,强大自定义,多类型支持,高性能,易扩展,希望能帮助用户快速方便实现观测需求。
----
----
## 🥐 模块
## 🥐 模块
![hertzBeat](home/static/img/docs/hertzbeat-arch.png)
![hertzBeat](home/static/img/docs/hertzbeat-arch.png)
## ⛄ 已支持
## 🐕 快速开始
> 我们将监控采集类型(mysql,jvm,k8s)都定义为 `yml` 模板,用户可以导入这些模板来支持对应类型的监控!
> 欢迎大家一起贡献你使用过程中自定义的通用指标模板。
- [Website](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-website.yml), [Port Telnet](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-port.yml),
[Http Api](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-api.yml), [Ping Connect](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-ping.yml),
[Jvm](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-jvm.yml), [SiteMap](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-fullsite.yml),
[Ssl Certificate](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-ssl_cert.yml), [SpringBoot2](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-springboot2.yml),
[FTP Server](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-ftp.yml), [SpringBoot3](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-springboot3.yml),
[Udp Port](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-udp_port.yml), [Dns](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-dns.yml),
[Pop3](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-pop3.yml), [Ntp](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-ntp.yml),
[Api Code](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-api_code.yml), [Smtp](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-smtp.yml),
[Nginx](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-nginx.yml)
- [Mysql](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-mysql.yml), [PostgreSQL](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-postgresql.yml),
[MariaDB](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-mariadb.yml), [Redis](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-redis.yml),
[ElasticSearch](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-elasticsearch.yml), [SqlServer](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-sqlserver.yml),
[Oracle](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-oracle.yml), [MongoDB](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-mongodb.yml),
[DM](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-dm.yml), [OpenGauss](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-opengauss.yml),
[ClickHouse](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-clickhouse.yml), [IoTDB](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-iotdb.yml),
[Redis Cluster](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-redis_cluster.yml), [Redis Sentinel](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-redis_sentinel.yml)
[Doris BE](https://github.com/apache/hertzbeat/blob/master/hertzbeat-manager/src/main/resources/define/app-doris_be.yml), [Doris FE](https://github.com/apache/hertzbeat/blob/master/hertzbeat-manager/src/main/resources/define/app-doris_fe.yml),
[Memcached](https://github.com/apache/hertzbeat/blob/master/hertzbeat-manager/src/main/resources/define/app-memcached.yml), [NebulaGraph](https://github.com/apache/hertzbeat/blob/master/hertzbeat-manager/src/main/resources/define/app-nebula_graph.yml)
- [Linux](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-linux.yml), [Ubuntu](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-ubuntu.yml),
[CentOS](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-centos.yml), [Windows](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-windows.yml),
[EulerOS](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-euleros.yml), [Fedora CoreOS](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-coreos.yml),
[OpenSUSE](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-opensuse.yml), [Rocky Linux](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-rockylinux.yml),
[Red Hat](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-redhat.yml), [FreeBSD](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-freebsd.yml),
[AlmaLinux](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-almalinux.yml), [Debian Linux](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-debian.yml)
- [Tomcat](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-tomcat.yml), [Nacos](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-nacos.yml),
[Zookeeper](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-zookeeper.yml), [RabbitMQ](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-rabbitmq.yml),
[Flink](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-flink.yml), [Kafka](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-kafka.yml),
[ShenYu](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-shenyu.yml), [DynamicTp](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-dynamic_tp.yml),
[Jetty](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-jetty.yml), [ActiveMQ](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-activemq.yml),
[Spring Gateway](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-spring_gateway.yml), [EMQX MQTT](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-emqx.yml),
[AirFlow](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-airflow.yml), [Hive](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-hive.yml),
[Spark](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-spark.yml), [Hadoop](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-hadoop.yml)
- [Kubernetes](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-kubernetes.yml), [Docker](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-docker.yml)
- [CiscoSwitch](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-cisco_switch.yml), [HpeSwitch](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-hpe_switch.yml),
[HuaweiSwitch](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-huawei_switch.yml), [TpLinkSwitch](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-tplink_switch.yml),
[H3cSwitch](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-h3c_switch.yml)
- 和更多自定义指标模板。
- 通知支持 `Discord` `Slack` `Telegram` `邮件` `钉钉` `微信` `飞书` `短信` `Webhook` `Server酱`
## 🐕 快速开始
- 如果您是想将 HertzBeat 部署到内网环境搭建监控系统,请参考下面的部署文档进行操作。
- 如果您是想将 HertzBeat 部署到内网环境搭建监控系统,请参考下面的部署文档进行操作。
### 🍞 HertzBeat 安装
> HertzBeat 支持通过源码安装启动,Docker 容器运行和安装包方式安装部署,CPU 架构支持 x86/arm64。
##### 方式一:Docker 方式快速安装
##### 方式一:Docker 方式快速安装
1. `docker` 环境仅需一条命令即可开始
1. `docker` 环境仅需一条命令即可开始
```shell
docker run -d -p 1157:1157 -p 1158:1158 --name hertzbeat apache/hertzbeat
@@ -164,7 +120,7 @@
通过 [Docker-Compose 部署脚本](script/docker-compose) 一次性把 postgresql/mysql 数据库, victoria-metrics/iotdb/tdengine 时序数据库和 hertzbeat 安装部署。
详细步骤参考 [通过 Docker-Compose 安装 HertzBeat](script/docker-compose/README.md)
详细步骤参考 [通过 Docker-Compose 安装 HertzBeat](script/docker-compose/README.md)
##### 方式五:Kubernetes Helm Charts 部署 hertzbeat+collector+postgresql+tsdb
@@ -176,8 +132,8 @@
## ✨ Contributors
Thanks these wonderful people, welcome to join us:
[贡献者指南](CONTRIBUTING.md)
Thanks these wonderful people, welcome to join us:
[贡献者指南](CONTRIBUTING.md)
<!-- ALL-CONTRIBUTORS-LIST:START - Do not remove or modify this section -->
<!-- prettier-ignore-start -->
@@ -548,6 +504,34 @@ Thanks these wonderful people, welcome to join us:
<td align="center" valign="top" width="14.28%"><a href="https://shadwal.space/"><img src="https://avatars.githubusercontent.com/u/119167601?v=4?s=100" width="100px;" alt="Sahil Shadwal"/><br /><sub><b>Sahil Shadwal</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=Sahil-Shadwal" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/BhanuNidumolu"><img src="https://avatars.githubusercontent.com/u/180380413?v=4?s=100" width="100px;" alt="N.Bhanu Prasad"/><br /><sub><b>N.Bhanu Prasad</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=BhanuNidumolu" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://prakashh-portfolio.vercel.app/"><img src="https://avatars.githubusercontent.com/u/183058331?v=4?s=100" width="100px;" alt="Prakash Kumar"/><br /><sub><b>Prakash Kumar</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=Prakash1185" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/orangeCatDeveloper"><img src="https://avatars.githubusercontent.com/u/95899648?v=4?s=100" width="100px;" alt="NekoPunch"/><br /><sub><b>NekoPunch</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=orangeCatDeveloper" title="Code">💻</a> <a href="https://github.com/apache/hertzbeat/commits?author=orangeCatDeveloper" title="Documentation">📖</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://wy471x.github.io/"><img src="https://avatars.githubusercontent.com/u/52033069?v=4?s=100" width="100px;" alt="wy471x"/><br /><sub><b>wy471x</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=wy471x" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/hengyuss"><img src="https://avatars.githubusercontent.com/u/81064732?v=4?s=100" width="100px;" alt="hengyuss"/><br /><sub><b>hengyuss</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=hengyuss" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://moduvoice.com/"><img src="https://avatars.githubusercontent.com/u/291867022?v=4?s=100" width="100px;" alt="moduvoice"/><br /><sub><b>moduvoice</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=moduvoice" title="Documentation">📖</a></td>
</tr>
<tr>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/hutiefang76"><img src="https://avatars.githubusercontent.com/u/137664623?v=4?s=100" width="100px;" alt="hutiefang76"/><br /><sub><b>hutiefang76</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=hutiefang76" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://zylatent.com/"><img src="https://avatars.githubusercontent.com/u/250777154?v=4?s=100" width="100px;" alt="柳含知 Liu Hanzhi"/><br /><sub><b>柳含知 Liu Hanzhi</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=ZhouYinLong-lab" title="Documentation">📖</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/wilmerdooley"><img src="https://avatars.githubusercontent.com/u/259930736?v=4?s=100" width="100px;" alt="wilmerdooley"/><br /><sub><b>wilmerdooley</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=wilmerdooley" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Zmjjeff7"><img src="https://avatars.githubusercontent.com/u/175370943?v=4?s=100" width="100px;" alt="Zhenhong Guo"/><br /><sub><b>Zhenhong Guo</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=Zmjjeff7" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/abhyudayareddy"><img src="https://avatars.githubusercontent.com/u/54602866?v=4?s=100" width="100px;" alt="abhyudayareddy"/><br /><sub><b>abhyudayareddy</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=abhyudayareddy" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/neon-hippo"><img src="https://avatars.githubusercontent.com/u/165560498?v=4?s=100" width="100px;" alt="neon-hippo"/><br /><sub><b>neon-hippo</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=neon-hippo" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/P-Peaceful"><img src="https://avatars.githubusercontent.com/u/52856161?v=4?s=100" width="100px;" alt="P_Peaceful"/><br /><sub><b>P_Peaceful</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=P-Peaceful" title="Code">💻</a> <a href="https://github.com/apache/hertzbeat/commits?author=P-Peaceful" title="Documentation">📖</a></td>
</tr>
<tr>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/zhusaidong"><img src="https://avatars.githubusercontent.com/u/3039961?v=4?s=100" width="100px;" alt="zhusaidong"/><br /><sub><b>zhusaidong</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=zhusaidong" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/zhehenlu"><img src="https://avatars.githubusercontent.com/u/31504542?v=4?s=100" width="100px;" alt="zhlu"/><br /><sub><b>zhlu</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=zhehenlu" title="Code">💻</a> <a href="https://github.com/apache/hertzbeat/commits?author=zhehenlu" title="Documentation">📖</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/brettgervasoni"><img src="https://avatars.githubusercontent.com/u/34056000?v=4?s=100" width="100px;" alt="brettgervasoni"/><br /><sub><b>brettgervasoni</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=brettgervasoni" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Darshan-paul"><img src="https://avatars.githubusercontent.com/u/211450705?v=4?s=100" width="100px;" alt="Darshan-paul"/><br /><sub><b>Darshan-paul</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=Darshan-paul" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/04cb"><img src="https://avatars.githubusercontent.com/u/111667698?v=4?s=100" width="100px;" alt="layla"/><br /><sub><b>layla</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=04cb" title="Documentation">📖</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/miantalha45"><img src="https://avatars.githubusercontent.com/u/155809113?v=4?s=100" width="100px;" alt="Talha Amjad"/><br /><sub><b>Talha Amjad</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=miantalha45" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://turanalmammadov.com/"><img src="https://avatars.githubusercontent.com/u/16321061?v=4?s=100" width="100px;" alt="Turan Almammadov"/><br /><sub><b>Turan Almammadov</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=turanalmammadov" title="Code">💻</a> <a href="https://github.com/apache/hertzbeat/commits?author=turanalmammadov" title="Documentation">📖</a></td>
</tr>
<tr>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/zhaoyangplus"><img src="https://avatars.githubusercontent.com/u/245090302?v=4?s=100" width="100px;" alt="zhaoyangplus"/><br /><sub><b>zhaoyangplus</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=zhaoyangplus" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/yexuanyang"><img src="https://avatars.githubusercontent.com/u/73885401?v=4?s=100" width="100px;" alt="Yang Yexuan"/><br /><sub><b>Yang Yexuan</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=yexuanyang" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/markguo123"><img src="https://avatars.githubusercontent.com/u/155072651?v=4?s=100" width="100px;" alt="markguo123"/><br /><sub><b>markguo123</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=markguo123" title="Documentation">📖</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/leo-934"><img src="https://avatars.githubusercontent.com/u/55838224?v=4?s=100" width="100px;" alt="leo"/><br /><sub><b>leo</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=leo-934" title="Code">💻</a> <a href="https://github.com/apache/hertzbeat/commits?author=leo-934" title="Documentation">📖</a> <a href="#blog-leo-934" title="Blogposts">📝</a></td>
</tr>
</tbody>
</table>
@@ -555,11 +539,11 @@ Thanks these wonderful people, welcome to join us:
<!-- markdownlint-restore -->
<!-- prettier-ignore-end -->
<!-- ALL-CONTRIBUTORS-LIST:END -->
<!-- ALL-CONTRIBUTORS-LIST:END -->
## 💬 社区交流
##### Channel
##### Channel
[订阅邮件列表](https://lists.apache.org/list.html?dev@hertzbeat.apache.org) : 发送邮件至 `dev-subscribe@hertzbeat.apache.org` 来订阅邮件列表.
+28 -44
View File
@@ -48,50 +48,6 @@
![hertzBeat](home/static/img/docs/hertzbeat-arch.png)
## ⛄ 利用可能
> `mysql`、`Java仮想マシン`、`k8s` などのすべての監視コレクション タイプを `YML` 監視テンプレートとして定義し、ユーザーがそれらをインポートして対応するタイプの監視をサポートできるようにしています。
> カスタマイズされた一般的なモニターのテンプレートを提供することを歓迎します。
- [Website](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-website.yml), [Port Telnet](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-port.yml),
[Http Api](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-api.yml), [Ping Connect](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-ping.yml),
[Jvm](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-jvm.yml), [SiteMap](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-fullsite.yml),
[Ssl Certificate](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-ssl_cert.yml), [SpringBoot2](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-springboot2.yml),
[FTP Server](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-ftp.yml), [SpringBoot3](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-springboot3.yml),
[Udp Port](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-udp_port.yml), [Dns](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-dns.yml),
[Pop3](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-pop3.yml), [Ntp](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-ntp.yml),
[Api Code](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-api_code.yml), [Smtp](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-smtp.yml),
[Nginx](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-nginx.yml)
- [Mysql](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-mysql.yml), [PostgreSQL](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-postgresql.yml),
[MariaDB](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-mariadb.yml), [Redis](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-redis.yml),
[ElasticSearch](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-elasticsearch.yml), [SqlServer](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-sqlserver.yml),
[Oracle](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-oracle.yml), [MongoDB](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-mongodb.yml),
[DM](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-dm.yml), [OpenGauss](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-opengauss.yml),
[ClickHouse](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-clickhouse.yml), [IoTDB](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-iotdb.yml),
[Redis Cluster](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-redis_cluster.yml), [Redis Sentinel](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-redis_sentinel.yml)
[Doris BE](https://github.com/apache/hertzbeat/blob/master/hertzbeat-manager/src/main/resources/define/app-doris_be.yml), [Doris FE](https://github.com/apache/hertzbeat/blob/master/hertzbeat-manager/src/main/resources/define/app-doris_fe.yml),
[Memcached](https://github.com/apache/hertzbeat/blob/master/hertzbeat-manager/src/main/resources/define/app-memcached.yml), [NebulaGraph](https://github.com/apache/hertzbeat/blob/master/hertzbeat-manager/src/main/resources/define/app-nebula_graph.yml)
- [Linux](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-linux.yml), [Ubuntu](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-ubuntu.yml),
[CentOS](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-centos.yml), [Windows](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-windows.yml),
[EulerOS](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-euleros.yml), [Fedora CoreOS](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-coreos.yml),
[OpenSUSE](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-opensuse.yml), [Rocky Linux](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-rockylinux.yml),
[Red Hat](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-redhat.yml), [FreeBSD](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-freebsd.yml),
[AlmaLinux](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-almalinux.yml), [Debian Linux](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-debian.yml)
- [Tomcat](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-tomcat.yml), [Nacos](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-nacos.yml),
[Zookeeper](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-zookeeper.yml), [RabbitMQ](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-rabbitmq.yml),
[Flink](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-flink.yml), [Kafka](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-kafka.yml),
[ShenYu](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-shenyu.yml), [DynamicTp](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-dynamic_tp.yml),
[Jetty](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-jetty.yml), [ActiveMQ](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-activemq.yml),
[Spring Gateway](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-spring_gateway.yml), [EMQX MQTT](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-emqx.yml),
[AirFlow](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-airflow.yml), [Hive](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-hive.yml),
[Spark](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-spark.yml), [Hadoop](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-hadoop.yml)
- [Kubernetes](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-kubernetes.yml), [Docker](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-docker.yml)
- [CiscoSwitch](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-cisco_switch.yml), [HpeSwitch](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-hpe_switch.yml),
[HuaweiSwitch](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-huawei_switch.yml), [TpLinkSwitch](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-tplink_switch.yml),
[H3cSwitch](https://raw.githubusercontent.com/apache/hertzbeat/master/hertzbeat-manager/src/main/resources/define/app-h3c_switch.yml)
- その他、カスタマイズされたモニターテンプレート。
- 通知利用可能 `Discord` `Slack` `Telegram` `Email` `DingTalk` `WeChat` `FeiShu` `Webhook` `SMS` `ServerChan`
## 🐕 クイックスタート
- HertzBeat をローカルに展開する場合は、以下のデプロイメントドキュメントを参照してください。
@@ -551,6 +507,34 @@ Thanks these wonderful people, welcome to join us:
<td align="center" valign="top" width="14.28%"><a href="https://shadwal.space/"><img src="https://avatars.githubusercontent.com/u/119167601?v=4?s=100" width="100px;" alt="Sahil Shadwal"/><br /><sub><b>Sahil Shadwal</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=Sahil-Shadwal" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/BhanuNidumolu"><img src="https://avatars.githubusercontent.com/u/180380413?v=4?s=100" width="100px;" alt="N.Bhanu Prasad"/><br /><sub><b>N.Bhanu Prasad</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=BhanuNidumolu" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://prakashh-portfolio.vercel.app/"><img src="https://avatars.githubusercontent.com/u/183058331?v=4?s=100" width="100px;" alt="Prakash Kumar"/><br /><sub><b>Prakash Kumar</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=Prakash1185" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/orangeCatDeveloper"><img src="https://avatars.githubusercontent.com/u/95899648?v=4?s=100" width="100px;" alt="NekoPunch"/><br /><sub><b>NekoPunch</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=orangeCatDeveloper" title="Code">💻</a> <a href="https://github.com/apache/hertzbeat/commits?author=orangeCatDeveloper" title="Documentation">📖</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://wy471x.github.io/"><img src="https://avatars.githubusercontent.com/u/52033069?v=4?s=100" width="100px;" alt="wy471x"/><br /><sub><b>wy471x</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=wy471x" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/hengyuss"><img src="https://avatars.githubusercontent.com/u/81064732?v=4?s=100" width="100px;" alt="hengyuss"/><br /><sub><b>hengyuss</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=hengyuss" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://moduvoice.com/"><img src="https://avatars.githubusercontent.com/u/291867022?v=4?s=100" width="100px;" alt="moduvoice"/><br /><sub><b>moduvoice</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=moduvoice" title="Documentation">📖</a></td>
</tr>
<tr>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/hutiefang76"><img src="https://avatars.githubusercontent.com/u/137664623?v=4?s=100" width="100px;" alt="hutiefang76"/><br /><sub><b>hutiefang76</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=hutiefang76" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://zylatent.com/"><img src="https://avatars.githubusercontent.com/u/250777154?v=4?s=100" width="100px;" alt="柳含知 Liu Hanzhi"/><br /><sub><b>柳含知 Liu Hanzhi</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=ZhouYinLong-lab" title="Documentation">📖</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/wilmerdooley"><img src="https://avatars.githubusercontent.com/u/259930736?v=4?s=100" width="100px;" alt="wilmerdooley"/><br /><sub><b>wilmerdooley</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=wilmerdooley" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Zmjjeff7"><img src="https://avatars.githubusercontent.com/u/175370943?v=4?s=100" width="100px;" alt="Zhenhong Guo"/><br /><sub><b>Zhenhong Guo</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=Zmjjeff7" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/abhyudayareddy"><img src="https://avatars.githubusercontent.com/u/54602866?v=4?s=100" width="100px;" alt="abhyudayareddy"/><br /><sub><b>abhyudayareddy</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=abhyudayareddy" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/neon-hippo"><img src="https://avatars.githubusercontent.com/u/165560498?v=4?s=100" width="100px;" alt="neon-hippo"/><br /><sub><b>neon-hippo</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=neon-hippo" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/P-Peaceful"><img src="https://avatars.githubusercontent.com/u/52856161?v=4?s=100" width="100px;" alt="P_Peaceful"/><br /><sub><b>P_Peaceful</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=P-Peaceful" title="Code">💻</a> <a href="https://github.com/apache/hertzbeat/commits?author=P-Peaceful" title="Documentation">📖</a></td>
</tr>
<tr>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/zhusaidong"><img src="https://avatars.githubusercontent.com/u/3039961?v=4?s=100" width="100px;" alt="zhusaidong"/><br /><sub><b>zhusaidong</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=zhusaidong" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/zhehenlu"><img src="https://avatars.githubusercontent.com/u/31504542?v=4?s=100" width="100px;" alt="zhlu"/><br /><sub><b>zhlu</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=zhehenlu" title="Code">💻</a> <a href="https://github.com/apache/hertzbeat/commits?author=zhehenlu" title="Documentation">📖</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/brettgervasoni"><img src="https://avatars.githubusercontent.com/u/34056000?v=4?s=100" width="100px;" alt="brettgervasoni"/><br /><sub><b>brettgervasoni</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=brettgervasoni" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/Darshan-paul"><img src="https://avatars.githubusercontent.com/u/211450705?v=4?s=100" width="100px;" alt="Darshan-paul"/><br /><sub><b>Darshan-paul</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=Darshan-paul" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/04cb"><img src="https://avatars.githubusercontent.com/u/111667698?v=4?s=100" width="100px;" alt="layla"/><br /><sub><b>layla</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=04cb" title="Documentation">📖</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/miantalha45"><img src="https://avatars.githubusercontent.com/u/155809113?v=4?s=100" width="100px;" alt="Talha Amjad"/><br /><sub><b>Talha Amjad</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=miantalha45" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://turanalmammadov.com/"><img src="https://avatars.githubusercontent.com/u/16321061?v=4?s=100" width="100px;" alt="Turan Almammadov"/><br /><sub><b>Turan Almammadov</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=turanalmammadov" title="Code">💻</a> <a href="https://github.com/apache/hertzbeat/commits?author=turanalmammadov" title="Documentation">📖</a></td>
</tr>
<tr>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/zhaoyangplus"><img src="https://avatars.githubusercontent.com/u/245090302?v=4?s=100" width="100px;" alt="zhaoyangplus"/><br /><sub><b>zhaoyangplus</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=zhaoyangplus" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/yexuanyang"><img src="https://avatars.githubusercontent.com/u/73885401?v=4?s=100" width="100px;" alt="Yang Yexuan"/><br /><sub><b>Yang Yexuan</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=yexuanyang" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/markguo123"><img src="https://avatars.githubusercontent.com/u/155072651?v=4?s=100" width="100px;" alt="markguo123"/><br /><sub><b>markguo123</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=markguo123" title="Documentation">📖</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/leo-934"><img src="https://avatars.githubusercontent.com/u/55838224?v=4?s=100" width="100px;" alt="leo"/><br /><sub><b>leo</b></sub></a><br /><a href="https://github.com/apache/hertzbeat/commits?author=leo-934" title="Code">💻</a> <a href="https://github.com/apache/hertzbeat/commits?author=leo-934" title="Documentation">📖</a> <a href="#blog-leo-934" title="Blogposts">📝</a></td>
</tr>
</tbody>
</table>
@@ -18,14 +18,21 @@
package org.apache.hertzbeat.ai.config;
import com.usthe.sureness.subject.SubjectSum;
import org.springframework.core.NamedInheritableThreadLocal;
import com.usthe.sureness.util.SurenessContextHolder;
import java.util.Map;
import java.util.function.Supplier;
import org.springframework.ai.chat.model.ToolContext;
import org.springframework.core.NamedThreadLocal;
/**
* Context holder for AI agent security context.
*/
public final class McpContextHolder {
static final String SUBJECT_CONTEXT_KEY = McpContextHolder.class.getName() + ".subject";
private static final ThreadLocal<SubjectSum> subjectHolder =
new NamedInheritableThreadLocal<>("MCP Security and User Identification Context");
new NamedThreadLocal<>("MCP Security and User Identification Context");
private McpContextHolder() {}
@@ -44,10 +51,73 @@ public final class McpContextHolder {
return subjectHolder.get();
}
/**
* Creates a security context that is propagated only through the Spring AI tool invocation chain.
*
* @param subject current authenticated subject, may be {@code null}
* @return tool context without null values
*/
public static Map<String, Object> createToolContext(SubjectSum subject) {
return subject == null ? Map.of() : Map.of(SUBJECT_CONTEXT_KEY, subject);
}
/**
* Retrieves and validates the authenticated subject from the Spring AI tool context.
*
* @param toolContext Spring AI tool context
* @return authenticated subject, or {@code null} when absent
*/
public static SubjectSum getSubject(ToolContext toolContext) {
if (toolContext == null) {
return null;
}
Object subject = toolContext.getContext().get(SUBJECT_CONTEXT_KEY);
return subject instanceof SubjectSum subjectSum ? subjectSum : null;
}
/**
* Executes a synchronous tool invocation within the given subject scope and restores the original thread
* contexts afterward.
*
* <p>Spring AI tool callbacks may run on pooled threads, so they cannot rely on a {@link ThreadLocal} left on
* the Servlet request thread. This method binds both MCP and Sureness contexts so the tool and downstream
* services observe the same user identity.</p>
*
* @param subject current request subject, may be {@code null}
* @param operation synchronous tool invocation
* @param <T> tool result type
* @return tool invocation result
*/
public static <T> T callWithSubject(SubjectSum subject, Supplier<T> operation) {
SubjectSum previousMcpSubject = getSubject();
SubjectSum previousSurenessSubject = SurenessContextHolder.getBindSubject();
replaceSubjects(subject);
try {
return operation.get();
} finally {
replaceSubjects(previousMcpSubject, previousSurenessSubject);
}
}
/**
* Clears the context from the thread to prevent memory leaks.
*/
public static void clear() {
subjectHolder.remove();
}
private static void replaceSubjects(SubjectSum subject) {
replaceSubjects(subject, subject);
}
private static void replaceSubjects(SubjectSum mcpSubject, SubjectSum surenessSubject) {
clear();
SurenessContextHolder.unbindSubject();
if (mcpSubject != null) {
setSubject(mcpSubject);
}
if (surenessSubject != null) {
SurenessContextHolder.bindSubject(surenessSubject);
}
}
}
@@ -0,0 +1,60 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.hertzbeat.ai.config;
import org.springframework.ai.chat.model.ToolContext;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.ai.tool.definition.ToolDefinition;
import org.springframework.ai.tool.metadata.ToolMetadata;
/**
* Establishes a request-scoped security context for Spring AI tool callbacks.
*
* <p>The wrapper keeps the tool definition and metadata unchanged. It binds the identity only for the delegated
* call and restores the worker thread's original state after both successful and failed invocations.</p>
*/
public final class SecurityContextToolCallback implements ToolCallback {
private final ToolCallback delegate;
public SecurityContextToolCallback(ToolCallback delegate) {
this.delegate = delegate;
}
@Override
public ToolDefinition getToolDefinition() {
return delegate.getToolDefinition();
}
@Override
public ToolMetadata getToolMetadata() {
return delegate.getToolMetadata();
}
@Override
public String call(String toolInput) {
return call(toolInput, null);
}
@Override
public String call(String toolInput, ToolContext toolContext) {
return McpContextHolder.callWithSubject(
McpContextHolder.getSubject(toolContext),
() -> delegate.call(toolInput, toolContext));
}
}
@@ -17,13 +17,10 @@
package org.apache.hertzbeat.ai.controller;
import com.usthe.sureness.subject.SubjectSum;
import com.usthe.sureness.util.SurenessContextHolder;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.extern.slf4j.Slf4j;
import org.apache.hertzbeat.ai.config.McpContextHolder;
import org.apache.hertzbeat.ai.pojo.dto.ChatRequestContext;
import org.apache.hertzbeat.ai.pojo.dto.ChatResponseChunk;
import org.apache.hertzbeat.ai.pojo.dto.SecurityData;
@@ -75,8 +72,6 @@ public class ChatController {
public Flux<ServerSentEvent<ChatResponseChunk>> streamChat(@Valid @RequestBody ChatRequestContext context) {
try {
// Validate message is not empty
SubjectSum subject = SurenessContextHolder.getBindSubject();
McpContextHolder.setSubject(subject);
if (context.getMessage() == null || context.getMessage().trim().isEmpty()) {
ChatResponseChunk errorResponse = ChatResponseChunk.builder()
.conversationId(context.getConversationId())
@@ -18,12 +18,13 @@
package org.apache.hertzbeat.ai.pojo.dto;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.usthe.sureness.subject.SubjectSum;
import java.util.List;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
import org.apache.hertzbeat.common.entity.ai.ChatMessage;
/**
@@ -47,4 +48,11 @@ public class ChatRequestContext {
* Conversation history messages for context
*/
private List<ChatMessage> conversationHistory;
/**
* Authenticated subject captured by the server. It cannot be supplied through client JSON and is never included
* in model messages.
*/
@JsonIgnore
private SubjectSum subject;
}
@@ -19,10 +19,13 @@
package org.apache.hertzbeat.ai.service.impl;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import lombok.extern.slf4j.Slf4j;
import org.apache.hertzbeat.ai.config.McpContextHolder;
import org.apache.hertzbeat.ai.config.SecurityContextToolCallback;
import org.apache.hertzbeat.ai.sop.model.SopDefinition;
import org.apache.hertzbeat.ai.sop.model.SopParameter;
import org.apache.hertzbeat.ai.sop.registry.SkillRegistry;
@@ -45,9 +48,11 @@ import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.tool.ToolCallbackProvider;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.ApplicationContext;
import org.springframework.util.StringUtils;
import reactor.core.publisher.Flux;
import java.io.IOException;
@@ -69,7 +74,7 @@ public class ChatClientProviderServiceImpl implements ChatClientProviderService
private final GeneralConfigDao generalConfigDao;
private ModelProviderConfig modelProviderConfig;
private volatile ModelProviderConfig modelProviderConfig;
private final SkillRegistry skillRegistry;
@@ -78,7 +83,9 @@ public class ChatClientProviderServiceImpl implements ChatClientProviderService
@Qualifier("hertzbeatTools")
private ToolCallbackProvider toolCallbackProvider;
private boolean isConfigured = false;
private volatile boolean configured;
private volatile boolean configurationLoaded;
@Value("classpath:/prompt/system-message.st")
private Resource systemResource;
@@ -122,11 +129,15 @@ public class ChatClientProviderServiceImpl implements ChatClientProviderService
// Build system prompt with dynamic skills list and conversation ID
// The conversationId is injected into the prompt so AI can pass it to schedule tools
String systemPrompt = buildSystemPrompt(context.getConversationId());
ToolCallback[] toolCallbacks = Arrays.stream(toolCallbackProvider.getToolCallbacks())
.map(SecurityContextToolCallback::new)
.toArray(ToolCallback[]::new);
return chatClient.prompt()
.messages(messages)
.system(systemPrompt)
.tools(toolCallbackProvider)
.tools((Object[]) toolCallbacks)
.toolContext(McpContextHolder.createToolContext(context.getSubject()))
.stream()
.content()
.doOnComplete(() -> log.info("Streaming completed for conversation: {}", context.getConversationId()))
@@ -150,7 +161,8 @@ public class ChatClientProviderServiceImpl implements ChatClientProviderService
.replace(CONVERSATION_ID_PLACEHOLDER, String.valueOf(conversationId));
// add extra prompt for protected model to guide it to use protected tools
if (Objects.equals(modelProviderConfig.getParticipationModel(), "PROTECTED")) {
ModelProviderConfig currentConfig = modelProviderConfig;
if (currentConfig != null && Objects.equals(currentConfig.getParticipationModel(), "PROTECTED")) {
Map<String, Object> metadata = new HashMap<>();
metadata.put("conversationId", conversationId);
return template + SystemPromptTemplate.builder().resource(extraResourceProtected).build()
@@ -202,19 +214,32 @@ public class ChatClientProviderServiceImpl implements ChatClientProviderService
@EventListener(AiProviderConfigChangeEvent.class)
public void onAiProviderConfigChange(AiProviderConfigChangeEvent event) {
GeneralConfig providerConfig = generalConfigDao.findByType("provider");
this.modelProviderConfig = JsonUtil.fromJson(providerConfig.getContent(), ModelProviderConfig.class);
refreshProviderConfiguration();
}
@Override
public boolean isConfigured() {
if (!isConfigured) {
GeneralConfig providerConfig = generalConfigDao.findByType("provider");
ModelProviderConfig modelProviderConfig = JsonUtil.fromJson(providerConfig.getContent(),
ModelProviderConfig.class);
isConfigured = modelProviderConfig != null && modelProviderConfig.getApiKey() != null;
this.modelProviderConfig = modelProviderConfig;
if (!configurationLoaded) {
synchronized (this) {
if (!configurationLoaded) {
refreshProviderConfiguration();
}
}
}
return isConfigured;
return configured;
}
/**
* Atomically refreshes the configuration snapshot after enabling, disabling, or switching providers.
*/
private synchronized void refreshProviderConfiguration() {
GeneralConfig providerConfig = generalConfigDao.findByType("provider");
ModelProviderConfig refreshedConfig = null;
if (providerConfig != null && StringUtils.hasText(providerConfig.getContent())) {
refreshedConfig = JsonUtil.fromJson(providerConfig.getContent(), ModelProviderConfig.class);
}
modelProviderConfig = refreshedConfig;
configured = refreshedConfig != null && StringUtils.hasText(refreshedConfig.getApiKey());
configurationLoaded = true;
}
}
@@ -17,6 +17,8 @@
package org.apache.hertzbeat.ai.service.impl;
import com.usthe.sureness.subject.SubjectSum;
import com.usthe.sureness.util.SurenessContextHolder;
import java.util.Optional;
import lombok.extern.slf4j.Slf4j;
import org.apache.hertzbeat.ai.dao.ChatConversationDao;
@@ -104,6 +106,8 @@ public class ConversationServiceImpl implements ConversationService {
// Stream response from AI service
StringBuilder fullResponse = new StringBuilder();
ChatMessage finalChatMessage = chatMessage;
SubjectSum subject = SurenessContextHolder.getBindSubject();
context.setSubject(subject);
return chatClientProviderService.streamChat(context)
.map(chunk -> {
fullResponse.append(chunk);
@@ -41,9 +41,6 @@ import reactor.core.publisher.Flux;
@Service
public class SopEngineImpl implements SopEngine {
// Thread-local context for each execution
private static final ThreadLocal<Map<String, Object>> CONTEXT_BUS = ThreadLocal.withInitial(HashMap::new);
private final List<SopExecutor> executors;
@Autowired
@@ -55,22 +52,12 @@ public class SopEngineImpl implements SopEngine {
public Flux<String> execute(SopDefinition definition, Map<String, Object> inputParams) {
return Flux.create(sink -> {
try {
OutputConfig outputConfig = resolveOutputConfig(definition);
Map<String, Object> context = prepareContext(definition, inputParams, outputConfig);
log.info("Starting execution of SOP: {}", definition.getName());
sink.next("Starting SOP: " + definition.getName() + " (v" + definition.getVersion() + ")");
// Initialize context with input parameters
Map<String, Object> context = CONTEXT_BUS.get();
context.clear();
context.putAll(inputParams);
// Add language configuration to context
OutputConfig outputConfig = definition.getOutput();
if (outputConfig != null) {
context.put("_language", outputConfig.getLanguageCode());
} else {
context.put("_language", "zh");
}
for (SopStep step : definition.getSteps()) {
sink.next("Executing step [" + step.getId() + "]: " + step.getType());
@@ -106,10 +93,9 @@ public class SopEngineImpl implements SopEngine {
sink.next("SOP " + definition.getName() + " completed successfully.");
sink.complete();
} catch (Exception e) {
log.error("Error executing SOP {}: {}", definition.getName(), e.getMessage(), e);
String sopName = definition == null ? "unknown" : definition.getName();
log.error("Error executing SOP {}: {}", sopName, e.getMessage(), e);
sink.error(e);
} finally {
CONTEXT_BUS.remove();
}
});
}
@@ -118,40 +104,20 @@ public class SopEngineImpl implements SopEngine {
public SopResult executeSync(SopDefinition definition, Map<String, Object> inputParams) {
long startTime = System.currentTimeMillis();
List<StepResult> stepResults = new ArrayList<>();
Map<String, Object> context = new HashMap<>(inputParams);
// Apply default values for parameters that are not provided
if (definition.getParameters() != null) {
for (SopParameter param : definition.getParameters()) {
if (!context.containsKey(param.getName()) && param.getDefaultValue() != null) {
context.put(param.getName(), param.getDefaultValue());
}
}
}
// Get output configuration first
OutputConfig outputConfig = definition.getOutput();
if (outputConfig == null) {
outputConfig = OutputConfig.builder()
.type("simple")
.format("text")
.language("zh")
.build();
}
// Add language configuration to context
context.put("_language", outputConfig.getLanguageCode());
SopResult.SopResultBuilder resultBuilder = SopResult.builder()
.sopName(definition.getName())
.sopVersion(definition.getVersion())
.sopName(definition == null ? null : definition.getName())
.sopVersion(definition == null ? null : definition.getVersion())
.startTime(startTime);
resultBuilder.outputType(outputConfig.getOutputType());
resultBuilder.outputFormat(outputConfig.getFormat() != null ? outputConfig.getFormat() : "text");
resultBuilder.language(outputConfig.getLanguageCode());
try {
OutputConfig outputConfig = resolveOutputConfig(definition);
Map<String, Object> context = prepareContext(definition, inputParams, outputConfig);
resultBuilder.outputType(outputConfig.getOutputType());
resultBuilder.outputFormat(outputConfig.getFormat() != null ? outputConfig.getFormat() : "text");
resultBuilder.language(outputConfig.getLanguageCode());
log.info("Starting sync execution of SOP: {}", definition.getName());
for (SopStep step : definition.getSteps()) {
@@ -213,10 +179,55 @@ public class SopEngineImpl implements SopEngine {
return resultBuilder.build();
} catch (Exception e) {
log.error("Error executing SOP {}: {}", definition.getName(), e.getMessage(), e);
String sopName = definition == null ? "unknown" : definition.getName();
log.error("Error executing SOP {}: {}", sopName, e.getMessage(), e);
return buildFailedResult(resultBuilder, stepResults, e.getMessage(), startTime);
}
}
/**
* Applies defaults and validates required parameters consistently for every execution entry point.
*/
private Map<String, Object> prepareContext(SopDefinition definition, Map<String, Object> inputParams,
OutputConfig outputConfig) {
if (definition.getSteps() == null || definition.getSteps().isEmpty()) {
throw new IllegalArgumentException("SOP must contain at least one step");
}
Map<String, Object> context = inputParams == null ? new HashMap<>() : new HashMap<>(inputParams);
if (definition.getParameters() != null) {
for (SopParameter parameter : definition.getParameters()) {
Object value = context.get(parameter.getName());
if (isMissing(value) && parameter.getDefaultValue() != null) {
context.put(parameter.getName(), parameter.getDefaultValue());
value = parameter.getDefaultValue();
}
if (parameter.isRequired() && isMissing(value)) {
throw new IllegalArgumentException("Required SOP parameter is missing: " + parameter.getName());
}
}
}
context.put("_language", outputConfig.getLanguageCode());
return context;
}
private boolean isMissing(Object value) {
return value == null || value instanceof String text && text.isBlank();
}
private OutputConfig resolveOutputConfig(SopDefinition definition) {
if (definition == null) {
throw new IllegalArgumentException("SOP definition must not be null");
}
if (definition.getOutput() != null) {
return definition.getOutput();
}
return OutputConfig.builder()
.type("simple")
.format("text")
.language("zh")
.build();
}
private SopResult buildFailedResult(SopResult.SopResultBuilder builder,
List<StepResult> stepResults, String error, long startTime) {
@@ -17,8 +17,16 @@
package org.apache.hertzbeat.ai.sop.registry;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.apache.hertzbeat.ai.sop.engine.SopEngine;
import org.apache.hertzbeat.ai.sop.model.SopDefinition;
import org.apache.hertzbeat.ai.sop.model.SopParameter;
import org.apache.hertzbeat.ai.sop.model.SopResult;
import org.apache.hertzbeat.common.util.JsonUtil;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.ai.tool.definition.ToolDefinition;
@@ -49,33 +57,66 @@ public class SopToolCallback implements ToolCallback {
@Override
public String call(String arguments) {
// TODO: Parse arguments and execute SOP
return "SOP " + definition.getName() + " execution started.";
Map<String, Object> inputParams = parseArguments(arguments);
SopResult result = sopEngine.executeSync(definition, inputParams);
return result.toAiResponse();
}
private String buildInputSchema() {
// Build JSON Schema for SOP parameters
StringBuilder schema = new StringBuilder();
schema.append("{\"type\":\"object\",\"properties\":{");
if (definition.getParameters() != null && !definition.getParameters().isEmpty()) {
boolean first = true;
for (var param : definition.getParameters()) {
if (!first) {
schema.append(",");
Map<String, Object> schema = new LinkedHashMap<>();
Map<String, Object> properties = new LinkedHashMap<>();
List<String> required = new ArrayList<>();
if (definition.getParameters() != null) {
for (SopParameter parameter : definition.getParameters()) {
Map<String, Object> property = new LinkedHashMap<>();
property.put("type", mapType(parameter.getType()));
if (parameter.getDescription() != null) {
property.put("description", parameter.getDescription());
}
schema.append("\"").append(param.getName()).append("\":");
schema.append("{\"type\":\"").append(mapType(param.getType())).append("\"");
if (param.getDescription() != null) {
schema.append(",\"description\":\"").append(param.getDescription()).append("\"");
if (parameter.getDefaultValue() != null) {
property.put("default", convertDefaultValue(parameter));
}
properties.put(parameter.getName(), property);
if (parameter.isRequired()) {
required.add(parameter.getName());
}
schema.append("}");
first = false;
}
}
schema.append("}}");
return schema.toString();
schema.put("type", "object");
schema.put("properties", properties);
if (!required.isEmpty()) {
schema.put("required", required);
}
schema.put("additionalProperties", false);
return JsonUtil.toJson(schema);
}
@SuppressWarnings("unchecked")
private Map<String, Object> parseArguments(String arguments) {
if (arguments == null || arguments.isBlank()) {
return new HashMap<>();
}
Map<String, Object> inputParams = JsonUtil.fromJson(arguments, Map.class);
if (inputParams == null) {
throw new IllegalArgumentException("SOP tool arguments must be a valid JSON object");
}
return inputParams;
}
private Object convertDefaultValue(SopParameter parameter) {
String defaultValue = parameter.getDefaultValue();
try {
return switch (mapType(parameter.getType())) {
case "boolean" -> Boolean.valueOf(defaultValue);
case "integer" -> Long.valueOf(defaultValue);
case "number" -> Double.valueOf(defaultValue);
default -> defaultValue;
};
} catch (NumberFormatException e) {
throw new IllegalArgumentException("Invalid default value for SOP parameter: " + parameter.getName(), e);
}
}
private String mapType(String type) {
@@ -17,31 +17,38 @@
package org.apache.hertzbeat.ai.sop.registry;
import java.lang.reflect.Method;
import java.lang.reflect.Parameter;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Arrays;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import lombok.extern.slf4j.Slf4j;
import org.apache.hertzbeat.common.util.JsonUtil;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.ai.tool.method.MethodToolCallbackProvider;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationContext;
import org.springframework.core.annotation.AnnotationUtils;
import org.springframework.stereotype.Component;
import org.springframework.util.ClassUtils;
import org.springframework.util.ReflectionUtils;
/**
* Registry for all @Tool annotated methods.
* Automatically discovers and registers tool methods on first use.
* Registry for Spring AI tools.
*
* <p>Scans beans containing {@link Tool} methods on first use and delegates argument deserialization,
* proxy method resolution, and result conversion to Spring AI's {@link MethodToolCallbackProvider}.
*/
@Slf4j
@Component
public class ToolRegistry {
private final ApplicationContext applicationContext;
private final Map<String, ToolMethod> tools = new HashMap<>();
private volatile Map<String, RegisteredTool> tools = Map.of();
private volatile boolean initialized = false;
@Autowired
@@ -49,16 +56,11 @@ public class ToolRegistry {
this.applicationContext = applicationContext;
}
/**
* Initialize the registry by scanning all beans for @Tool methods.
* Uses double-checked locking for thread safety.
*/
private void ensureInitialized() {
if (!initialized) {
synchronized (this) {
if (!initialized) {
log.info("Scanning for @Tool annotated methods...");
scanAllBeans();
tools = scanAllBeans();
initialized = true;
log.info("Registered {} tool methods: {}", tools.size(), tools.keySet());
}
@@ -66,58 +68,79 @@ public class ToolRegistry {
}
}
private void scanAllBeans() {
String[] beanNames = applicationContext.getBeanDefinitionNames();
for (String beanName : beanNames) {
try {
Object bean = applicationContext.getBean(beanName);
scanBeanForTools(bean);
} catch (Exception e) {
// Skip beans that cannot be instantiated
log.trace("Skipping bean {}: {}", beanName, e.getMessage());
}
private Map<String, RegisteredTool> scanAllBeans() {
log.info("Scanning for @Tool annotated methods...");
List<Object> toolBeans = Arrays.stream(applicationContext.getBeanDefinitionNames())
.filter(this::containsToolMethod)
.map(applicationContext::getBean)
.toList();
if (toolBeans.isEmpty()) {
return Map.of();
}
ToolCallback[] callbacks = MethodToolCallbackProvider.builder().toolObjects(toolBeans.toArray())
.build().getToolCallbacks();
Map<String, RegisteredTool> discoveredTools = new LinkedHashMap<>();
for (ToolCallback callback : callbacks) {
String toolName = callback.getToolDefinition().name();
discoveredTools.put(toolName, new RegisteredTool(callback, getRequiredParameters(callback)));
}
return Collections.unmodifiableMap(discoveredTools);
}
private void scanBeanForTools(Object bean) {
Class<?> clazz = bean.getClass();
// Handle Spring proxies
if (clazz.getName().contains("$$")) {
clazz = clazz.getSuperclass();
private boolean containsToolMethod(String beanName) {
Class<?> beanType = applicationContext.getType(beanName, false);
if (beanType == null) {
return false;
}
for (Method method : clazz.getMethods()) {
Tool toolAnnotation = method.getAnnotation(Tool.class);
if (toolAnnotation != null) {
String toolName = toolAnnotation.name();
if (toolName.isEmpty()) {
toolName = method.getName();
}
ToolMethod toolMethod = new ToolMethod(bean, method, toolAnnotation);
tools.put(toolName, toolMethod);
log.debug("Registered tool: {} -> {}.{}",
toolName, clazz.getSimpleName(), method.getName());
}
return Arrays.stream(ReflectionUtils.getDeclaredMethods(ClassUtils.getUserClass(beanType)))
.anyMatch(this::isToolMethod);
}
private boolean isToolMethod(Method method) {
return AnnotationUtils.findAnnotation(method, Tool.class) != null;
}
@SuppressWarnings("unchecked")
private Set<String> getRequiredParameters(ToolCallback callback) {
Map<String, Object> schema = JsonUtil.fromJson(callback.getToolDefinition().inputSchema(), Map.class);
if (schema == null || !(schema.get("required") instanceof List<?> required)) {
return Set.of();
}
return required.stream()
.map(String::valueOf)
.collect(Collectors.toUnmodifiableSet());
}
/**
* Invoke a tool by name with the given arguments.
* Invokes a tool with the provided arguments.
*/
public String invoke(String toolName, Map<String, Object> args) {
ensureInitialized();
ToolMethod toolMethod = tools.get(toolName);
if (toolMethod == null) {
throw new IllegalArgumentException("Unknown tool: " + toolName
RegisteredTool tool = tools.get(toolName);
if (tool == null) {
throw new IllegalArgumentException("Unknown tool: " + toolName
+ ". Available tools: " + tools.keySet());
}
return toolMethod.invoke(args);
Map<String, Object> safeArgs = args == null ? Map.of() : args;
validateRequiredParameters(tool.requiredParameters(), safeArgs);
String result = tool.callback().call(JsonUtil.toJson(safeArgs));
Object convertedResult = JsonUtil.fromJson(result, Object.class);
return convertedResult instanceof String text ? text : result;
}
private void validateRequiredParameters(Set<String> requiredParameters, Map<String, Object> args) {
for (String parameter : requiredParameters) {
Object value = args.get(parameter);
if (value == null || value instanceof String text && text.isBlank()) {
throw new IllegalArgumentException("Required tool parameter is missing: " + parameter);
}
}
}
/**
* Check if a tool exists.
* Checks whether a tool exists.
*/
public boolean hasMethod(String toolName) {
ensureInitialized();
@@ -125,158 +148,13 @@ public class ToolRegistry {
}
/**
* Get all registered tool names.
* Returns an immutable snapshot of all registered tool names.
*/
public Set<String> getToolNames() {
ensureInitialized();
return tools.keySet();
return Set.copyOf(tools.keySet());
}
/**
* Get tool method info.
*/
public ToolMethod getToolMethod(String toolName) {
ensureInitialized();
return tools.get(toolName);
}
/**
* Represents a registered tool method.
*/
public static class ToolMethod {
private final Object bean;
private final Method method;
private final Tool annotation;
private final List<ParamInfo> paramInfos;
public ToolMethod(Object bean, Method method, Tool annotation) {
this.bean = bean;
this.method = method;
this.annotation = annotation;
this.paramInfos = extractParamInfos(method);
}
private List<ParamInfo> extractParamInfos(Method method) {
List<ParamInfo> infos = new ArrayList<>();
Parameter[] parameters = method.getParameters();
for (Parameter param : parameters) {
ToolParam toolParam = param.getAnnotation(ToolParam.class);
String name = (toolParam != null && !toolParam.description().isEmpty())
? param.getName() : param.getName();
boolean required = toolParam != null && toolParam.required();
infos.add(new ParamInfo(name, param.getType(), required));
}
return infos;
}
/**
* Invoke this tool method with the given arguments.
*/
public String invoke(Map<String, Object> args) {
try {
Object[] methodArgs = new Object[paramInfos.size()];
for (int i = 0; i < paramInfos.size(); i++) {
ParamInfo paramInfo = paramInfos.get(i);
Object value = args.get(paramInfo.name);
methodArgs[i] = convertValue(value, paramInfo.type);
}
Object result = method.invoke(bean, methodArgs);
return result != null ? result.toString() : "";
} catch (Exception e) {
log.error("Failed to invoke tool {}: {}", annotation.name(), e.getMessage(), e);
throw new RuntimeException("Tool invocation failed: " + annotation.name(), e);
}
}
private Object convertValue(Object value, Class<?> targetType) {
if (value == null) {
return null;
}
if (targetType.isAssignableFrom(value.getClass())) {
return value;
}
String strValue = String.valueOf(value);
if (targetType == String.class) {
return strValue;
} else if (targetType == Integer.class || targetType == int.class) {
return strValue.isEmpty() ? null : Integer.valueOf(strValue);
} else if (targetType == Long.class || targetType == long.class) {
return strValue.isEmpty() ? null : Long.valueOf(strValue);
} else if (targetType == Boolean.class || targetType == boolean.class) {
return Boolean.valueOf(strValue);
} else if (targetType == Byte.class || targetType == byte.class) {
return strValue.isEmpty() ? null : Byte.valueOf(strValue);
} else if (targetType == Double.class || targetType == double.class) {
return strValue.isEmpty() ? null : Double.valueOf(strValue);
} else if (targetType == Float.class || targetType == float.class) {
return strValue.isEmpty() ? null : Float.valueOf(strValue);
} else if (targetType == List.class) {
return parseList(strValue);
}
return value;
}
@SuppressWarnings("unchecked")
private List<Long> parseList(String value) {
if (value == null || value.isEmpty()) {
return new ArrayList<>();
}
List<Long> result = new ArrayList<>();
for (String s : value.split(",")) {
if (!s.trim().isEmpty()) {
result.add(Long.valueOf(s.trim()));
}
}
return result;
}
public String getName() {
return annotation.name();
}
public String getDescription() {
return annotation.description();
}
public List<ParamInfo> getParamInfos() {
return paramInfos;
}
}
/**
* Parameter information for a tool method.
*/
public static class ParamInfo {
private final String name;
private final Class<?> type;
private final boolean required;
public ParamInfo(String name, Class<?> type, boolean required) {
this.name = name;
this.type = type;
this.required = required;
}
public String getName() {
return name;
}
public Class<?> getType() {
return type;
}
public boolean isRequired() {
return required;
}
private record RegisteredTool(ToolCallback callback, Set<String> requiredParameters) {
}
}
@@ -13,23 +13,6 @@
# See the License for the specific language governing permissions and
# limitations under the License.
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
# SOP Result Messages - Default (Chinese)
sop.result.title=SOP执行结果
sop.result.name=SOP名称
@@ -13,23 +13,6 @@
# See the License for the specific language governing permissions and
# limitations under the License.
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
# SOP Result Messages - English
sop.result.title=SOP Execution Result
sop.result.name=SOP Name
@@ -13,23 +13,6 @@
# See the License for the specific language governing permissions and
# limitations under the License.
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
# SOP Result Messages - Chinese
sop.result.title=SOP执行结果
sop.result.name=SOP名称
@@ -65,12 +65,16 @@ POST /api/ai/sop/execute/{skillName}/ai
```
YAML 定义 → SkillRegistry → SopEngine → Executors → SopResult
ToolExecutor / LlmExecutor
SopToolCallback ToolExecutor / LlmExecutor
ToolRegistry(自动发现 @Tool 方法)
```
所有执行入口都由 `SopEngine` 统一补齐默认参数并校验必填参数。技能注册为 Spring AI
工具时,`SopToolCallback` 会根据参数定义生成 JSON Schema、解析模型传入的 JSON 对象,
同步执行 SOP 并返回 `SopResult`;调用方不应在控制器或工具外层重复实现参数校验。
## 添加新工具
只需添加 `@Tool` 注解,无需修改其他代码:
@@ -13,23 +13,6 @@
# See the License for the specific language governing permissions and
# limitations under the License.
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
name: daily_inspection
description: "Execute daily health inspection on all monitors and generate a comprehensive report"
version: "1.0"
@@ -13,23 +13,6 @@
# See the License for the specific language governing permissions and
# limitations under the License.
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
name: mysql_slow_query_diagnosis
description: "Diagnose MySQL slow queries and provide optimization recommendations"
version: "1.0"
@@ -0,0 +1,146 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.hertzbeat.ai.config;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.mock;
import com.usthe.sureness.subject.SubjectSum;
import com.usthe.sureness.util.SurenessContextHolder;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.springframework.ai.chat.model.ToolContext;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.ai.tool.definition.ToolDefinition;
/**
* Verifies the security context scope used when model tools execute across threads.
*/
class McpContextHolderTest {
@AfterEach
void clearContext() {
McpContextHolder.clear();
SurenessContextHolder.clear();
}
@Test
void callWithSubjectShouldBindAndClearBothContexts() {
SubjectSum subject = mock(SubjectSum.class);
String result = McpContextHolder.callWithSubject(subject, () -> {
assertSame(subject, McpContextHolder.getSubject());
assertSame(subject, SurenessContextHolder.getBindSubject());
return "result";
});
assertEquals("result", result);
assertNull(McpContextHolder.getSubject());
assertNull(SurenessContextHolder.getBindSubject());
}
@Test
void callWithSubjectShouldRestoreIndependentPreviousContexts() {
SubjectSum previousMcpSubject = mock(SubjectSum.class);
SubjectSum previousSurenessSubject = mock(SubjectSum.class);
SubjectSum currentSubject = mock(SubjectSum.class);
McpContextHolder.setSubject(previousMcpSubject);
SurenessContextHolder.bindSubject(previousSurenessSubject);
McpContextHolder.callWithSubject(currentSubject, () -> {
assertSame(currentSubject, McpContextHolder.getSubject());
assertSame(currentSubject, SurenessContextHolder.getBindSubject());
return null;
});
assertSame(previousMcpSubject, McpContextHolder.getSubject());
assertSame(previousSurenessSubject, SurenessContextHolder.getBindSubject());
}
@Test
void callWithSubjectShouldRestoreContextsAfterFailure() {
SubjectSum previousSubject = mock(SubjectSum.class);
SubjectSum currentSubject = mock(SubjectSum.class);
McpContextHolder.setSubject(previousSubject);
SurenessContextHolder.bindSubject(previousSubject);
assertThrows(IllegalStateException.class, () ->
McpContextHolder.callWithSubject(currentSubject, () -> {
throw new IllegalStateException("tool failed");
}));
assertSame(previousSubject, McpContextHolder.getSubject());
assertSame(previousSubject, SurenessContextHolder.getBindSubject());
}
@Test
void toolContextShouldCarryOnlyValidSubject() {
SubjectSum subject = mock(SubjectSum.class);
ToolContext toolContext = new ToolContext(McpContextHolder.createToolContext(subject));
assertSame(subject, McpContextHolder.getSubject(toolContext));
assertNull(McpContextHolder.getSubject(null));
assertNull(McpContextHolder.getSubject(new ToolContext(
java.util.Map.of(McpContextHolder.SUBJECT_CONTEXT_KEY, "invalid"))));
assertEquals(java.util.Map.of(), McpContextHolder.createToolContext(null));
}
@Test
void callbackShouldExposeSubjectOnlyDuringDelegateCall() {
SubjectSum subject = mock(SubjectSum.class);
ToolContext toolContext = new ToolContext(McpContextHolder.createToolContext(subject));
ToolCallback delegate = new ToolCallback() {
@Override
public ToolDefinition getToolDefinition() {
return ToolDefinition.builder()
.name("test")
.description("test")
.inputSchema("{}")
.build();
}
@Override
public String call(String input) {
return input;
}
@Override
public String call(String input, ToolContext context) {
if (context == null) {
return input;
}
assertSame(subject, McpContextHolder.getSubject());
assertSame(subject, SurenessContextHolder.getBindSubject());
return "ok";
}
};
SecurityContextToolCallback callback = new SecurityContextToolCallback(delegate);
assertEquals("test", callback.getToolDefinition().name());
assertEquals(delegate.getToolMetadata().returnDirect(), callback.getToolMetadata().returnDirect());
assertEquals("plain", callback.call("plain"));
String result = callback.call("{}", toolContext);
assertEquals("ok", result);
assertNull(McpContextHolder.getSubject());
assertNull(SurenessContextHolder.getBindSubject());
}
}
@@ -0,0 +1,141 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.hertzbeat.ai.service.impl;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.ArgumentMatchers.anyMap;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import com.usthe.sureness.subject.SubjectSum;
import java.lang.reflect.Proxy;
import java.nio.charset.StandardCharsets;
import java.util.Map;
import java.util.concurrent.atomic.AtomicReference;
import org.apache.hertzbeat.ai.config.McpContextHolder;
import org.apache.hertzbeat.ai.config.SecurityContextToolCallback;
import org.apache.hertzbeat.ai.pojo.dto.ChatRequestContext;
import org.apache.hertzbeat.ai.sop.registry.SkillRegistry;
import org.apache.hertzbeat.base.dao.GeneralConfigDao;
import org.apache.hertzbeat.common.entity.dto.ModelProviderConfig;
import org.apache.hertzbeat.common.entity.manager.GeneralConfig;
import org.apache.hertzbeat.common.support.event.AiProviderConfigChangeEvent;
import org.apache.hertzbeat.common.util.JsonUtil;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.ai.tool.ToolCallbackProvider;
import org.springframework.context.ApplicationContext;
import org.springframework.context.support.StaticApplicationContext;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.test.util.ReflectionTestUtils;
import reactor.core.publisher.Flux;
/**
* Verifies that the provider configuration cache reacts to enable and disable events.
*/
class ChatClientProviderServiceImplTest {
@Test
void configurationChangeShouldRefreshConfiguredState() {
AtomicReference<GeneralConfig> currentConfig = new AtomicReference<>();
GeneralConfigDao configDao = configDao(currentConfig);
ChatClientProviderServiceImpl service = new ChatClientProviderServiceImpl(null, configDao, null);
assertFalse(service.isConfigured());
currentConfig.set(providerConfig("sk-test"));
service.onAiProviderConfigChange(changeEvent());
assertTrue(service.isConfigured());
currentConfig.set(providerConfig(" "));
service.onAiProviderConfigChange(changeEvent());
assertFalse(service.isConfigured());
}
@Test
@SuppressWarnings("unchecked")
void streamChatShouldAttachSubjectAndWrapEveryToolCallback() {
ApplicationContext applicationContext = mock(ApplicationContext.class);
SkillRegistry skillRegistry = mock(SkillRegistry.class);
ChatClient chatClient = mock(ChatClient.class);
ChatClient.ChatClientRequestSpec requestSpec = mock(ChatClient.ChatClientRequestSpec.class);
ChatClient.StreamResponseSpec streamSpec = mock(ChatClient.StreamResponseSpec.class);
ToolCallback delegate = mock(ToolCallback.class);
SubjectSum subject = mock(SubjectSum.class);
ChatClientProviderServiceImpl service = new ChatClientProviderServiceImpl(
applicationContext, configDao(new AtomicReference<>()), skillRegistry);
when(applicationContext.getBean("openAiChatClient", ChatClient.class)).thenReturn(chatClient);
when(chatClient.prompt()).thenReturn(requestSpec);
when(requestSpec.messages(anyList())).thenReturn(requestSpec);
when(requestSpec.system(anyString())).thenReturn(requestSpec);
when(requestSpec.tools(any(Object[].class))).thenReturn(requestSpec);
when(requestSpec.toolContext(anyMap())).thenReturn(requestSpec);
when(requestSpec.stream()).thenReturn(streamSpec);
when(streamSpec.content()).thenReturn(Flux.just("answer"));
when(skillRegistry.getAllSkills()).thenReturn(java.util.List.of());
ReflectionTestUtils.setField(service, "systemResource", new ByteArrayResource(
"skills={dynamically_injected_skills_list}; conversation={current_conversation_id}"
.getBytes(StandardCharsets.UTF_8)));
ReflectionTestUtils.setField(service, "toolCallbackProvider", ToolCallbackProvider.from(delegate));
ChatRequestContext context = ChatRequestContext.builder()
.message("question")
.conversationId(42L)
.subject(subject)
.build();
service.streamChat(context).collectList().block();
ArgumentCaptor<Object[]> callbacksCaptor = ArgumentCaptor.forClass(Object[].class);
ArgumentCaptor<Map<String, Object>> contextCaptor = ArgumentCaptor.forClass(Map.class);
verify(requestSpec).tools(callbacksCaptor.capture());
verify(requestSpec).toolContext(contextCaptor.capture());
assertInstanceOf(SecurityContextToolCallback.class, callbacksCaptor.getValue()[0]);
assertSame(subject, McpContextHolder.getSubject(new org.springframework.ai.chat.model.ToolContext(
contextCaptor.getValue())));
}
private GeneralConfigDao configDao(AtomicReference<GeneralConfig> currentConfig) {
return (GeneralConfigDao) Proxy.newProxyInstance(
GeneralConfigDao.class.getClassLoader(),
new Class<?>[]{GeneralConfigDao.class},
(proxy, method, args) -> "findByType".equals(method.getName()) ? currentConfig.get() : null);
}
private GeneralConfig providerConfig(String apiKey) {
ModelProviderConfig modelConfig = new ModelProviderConfig();
modelConfig.setApiKey(apiKey);
return GeneralConfig.builder()
.type("provider")
.content(JsonUtil.toJson(modelConfig))
.build();
}
private AiProviderConfigChangeEvent changeEvent() {
return new AiProviderConfigChangeEvent(new StaticApplicationContext());
}
}
@@ -23,6 +23,8 @@ import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import com.usthe.sureness.subject.SubjectSum;
import com.usthe.sureness.util.SurenessContextHolder;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicLong;
@@ -34,6 +36,7 @@ import org.apache.hertzbeat.ai.service.ChatClientProviderService;
import org.apache.hertzbeat.common.entity.ai.ChatConversation;
import org.apache.hertzbeat.common.entity.ai.ChatMessage;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
@@ -62,8 +65,15 @@ class ConversationServiceImplTest {
@InjectMocks
private ConversationServiceImpl conversationService;
@AfterEach
void clearSecurityContext() {
SurenessContextHolder.clear();
}
@Test
void streamChatShouldKeepCompleteConversationHistory() {
SubjectSum subject = org.mockito.Mockito.mock(SubjectSum.class);
SurenessContextHolder.bindSubject(subject);
ChatConversation conversation = ChatConversation.builder()
.id(CONVERSATION_ID)
.title("已命名会话")
@@ -104,5 +114,6 @@ class ConversationServiceImplTest {
ArgumentCaptor<ChatRequestContext> contextCaptor = ArgumentCaptor.forClass(ChatRequestContext.class);
verify(chatClientProviderService).streamChat(contextCaptor.capture());
assertEquals(history, contextCaptor.getValue().getConversationHistory());
assertEquals(subject, contextCaptor.getValue().getSubject());
}
}
@@ -0,0 +1,125 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.hertzbeat.ai.sop.engine;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicReference;
import org.apache.hertzbeat.ai.sop.executor.SopExecutor;
import org.apache.hertzbeat.ai.sop.model.SopDefinition;
import org.apache.hertzbeat.ai.sop.model.SopParameter;
import org.apache.hertzbeat.ai.sop.model.SopResult;
import org.apache.hertzbeat.ai.sop.model.SopStep;
import org.junit.jupiter.api.Test;
/**
* Verifies that all SOP execution entry points follow the same parameter contract.
*/
class SopEngineImplTest {
@Test
void executeSyncShouldReturnFailureForNullDefinition() {
SopEngineImpl engine = new SopEngineImpl(List.of());
SopResult result = engine.executeSync(null, Map.of());
assertEquals("FAILED", result.getStatus());
assertTrue(result.getError().contains("must not be null"));
}
@Test
void executeSyncShouldRejectMissingRequiredParameter() {
SopEngineImpl engine = new SopEngineImpl(List.of(new RecordingExecutor()));
SopResult result = engine.executeSync(definition(requiredParameter()), Map.of());
assertEquals("FAILED", result.getStatus());
assertTrue(result.getError().contains("monitorId"));
assertTrue(result.getSteps().isEmpty());
}
@Test
void executeSyncShouldApplyDefaultParameter() {
AtomicReference<Map<String, Object>> capturedContext = new AtomicReference<>();
SopEngineImpl engine = new SopEngineImpl(List.of(new RecordingExecutor(capturedContext)));
SopResult result = engine.executeSync(definition(defaultParameter()), Map.of());
assertEquals("SUCCESS", result.getStatus());
assertEquals("10", capturedContext.get().get("limit"));
assertEquals("zh", capturedContext.get().get("_language"));
assertEquals("10", result.getData().get("limit"));
}
@Test
void executeStreamShouldApplyTheSameParameterRules() {
AtomicReference<Map<String, Object>> capturedContext = new AtomicReference<>();
SopEngineImpl engine = new SopEngineImpl(List.of(new RecordingExecutor(capturedContext)));
List<String> events = engine.execute(definition(defaultParameter()), null).collectList().block();
assertNotNull(events);
assertTrue(events.getLast().contains("completed successfully"));
assertEquals("10", capturedContext.get().get("limit"));
}
private SopDefinition definition(SopParameter parameter) {
return SopDefinition.builder()
.name("test-sop")
.version("1.0")
.parameters(List.of(parameter))
.steps(List.of(SopStep.builder().id("query").type("tool").build()))
.build();
}
private SopParameter requiredParameter() {
return SopParameter.builder().name("monitorId").type("long").required(true).build();
}
private SopParameter defaultParameter() {
return SopParameter.builder().name("limit").type("integer").defaultValue("10").build();
}
private static final class RecordingExecutor implements SopExecutor {
private final AtomicReference<Map<String, Object>> capturedContext;
private RecordingExecutor() {
this(new AtomicReference<>());
}
private RecordingExecutor(AtomicReference<Map<String, Object>> capturedContext) {
this.capturedContext = capturedContext;
}
@Override
public boolean support(String type) {
return "tool".equals(type);
}
@Override
public Object execute(SopStep step, Map<String, Object> context) {
capturedContext.set(Map.copyOf(context));
return "ok";
}
}
}
@@ -0,0 +1,119 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.hertzbeat.ai.sop.registry;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicReference;
import org.apache.hertzbeat.ai.sop.engine.SopEngine;
import org.apache.hertzbeat.ai.sop.model.OutputType;
import org.apache.hertzbeat.ai.sop.model.SopDefinition;
import org.apache.hertzbeat.ai.sop.model.SopParameter;
import org.apache.hertzbeat.ai.sop.model.SopResult;
import org.apache.hertzbeat.common.util.JsonUtil;
import org.junit.jupiter.api.Test;
import reactor.core.publisher.Flux;
/**
* Verifies the input contract and execution behavior of a SOP exposed as a Spring AI tool.
*/
class SopToolCallbackTest {
@Test
@SuppressWarnings("unchecked")
void schemaShouldDeclareRequiredParametersAndEscapeDescriptions() {
SopDefinition definition = definition();
SopToolCallback callback = new SopToolCallback(definition, new RecordingEngine());
Map<String, Object> schema = JsonUtil.fromJson(callback.getToolDefinition().inputSchema(), Map.class);
Map<String, Object> properties = (Map<String, Object>) schema.get("properties");
Map<String, Object> monitorId = (Map<String, Object>) properties.get("monitorId");
Map<String, Object> limit = (Map<String, Object>) properties.get("limit");
assertEquals(List.of("monitorId"), schema.get("required"));
assertEquals(false, schema.get("additionalProperties"));
assertEquals("integer", monitorId.get("type"));
assertEquals("包含 \"引号\" 的说明", monitorId.get("description"));
assertEquals(10, limit.get("default"));
}
@Test
void callShouldParseArgumentsAndExecuteSop() {
RecordingEngine engine = new RecordingEngine();
SopToolCallback callback = new SopToolCallback(definition(), engine);
String response = callback.call("{\"monitorId\":42}");
assertEquals(42, engine.inputParams.get().get("monitorId"));
assertTrue(response.contains("SUCCESS"));
assertFalse(response.contains("execution started"));
}
@Test
void callShouldRejectInvalidJson() {
SopToolCallback callback = new SopToolCallback(definition(), new RecordingEngine());
assertThrows(IllegalArgumentException.class, () -> callback.call("not-json"));
}
private SopDefinition definition() {
SopParameter monitorId = SopParameter.builder()
.name("monitorId")
.type("long")
.description("包含 \"引号\" 的说明")
.required(true)
.build();
SopParameter limit = SopParameter.builder()
.name("limit")
.type("integer")
.defaultValue("10")
.build();
return SopDefinition.builder()
.name("diagnose")
.description("诊断工具")
.version("1.0")
.parameters(List.of(monitorId, limit))
.build();
}
private static final class RecordingEngine implements SopEngine {
private final AtomicReference<Map<String, Object>> inputParams = new AtomicReference<>();
@Override
public Flux<String> execute(SopDefinition definition, Map<String, Object> inputParams) {
return Flux.empty();
}
@Override
public SopResult executeSync(SopDefinition definition, Map<String, Object> inputParams) {
this.inputParams.set(inputParams);
return SopResult.builder()
.sopName(definition.getName())
.sopVersion(definition.getVersion())
.status("SUCCESS")
.outputType(OutputType.SIMPLE)
.build();
}
}
}
@@ -0,0 +1,136 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.hertzbeat.ai.sop.registry;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.junit.jupiter.api.Test;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.ai.tool.execution.ToolExecutionException;
import org.springframework.context.support.GenericApplicationContext;
/**
* Verifies tool discovery, argument validation, and type conversion by Spring AI callbacks.
*/
class ToolRegistryTest {
@Test
void invokeShouldConvertStructuredArguments() {
try (GenericApplicationContext context = contextWith(ToolFixture.class)) {
ToolRegistry registry = new ToolRegistry(context);
String result = registry.invoke("inspect", Map.of(
"monitorId", 42,
"ids", List.of(1, 2),
"enabled", true));
assertEquals("Long|Long|Boolean", result);
assertTrue(registry.hasMethod("inspect"));
assertEquals(Set.of("inspect", "optional"), registry.getToolNames());
}
}
@Test
void invokeShouldRejectMissingRequiredParameter() {
try (GenericApplicationContext context = contextWith(ToolFixture.class)) {
ToolRegistry registry = new ToolRegistry(context);
IllegalArgumentException error = assertThrows(IllegalArgumentException.class,
() -> registry.invoke("inspect", Map.of("ids", List.of(1), "enabled", true)));
assertEquals("Required tool parameter is missing: monitorId", error.getMessage());
}
}
@Test
void invokeShouldRejectInvalidBooleanInsteadOfSilentlyUsingFalse() {
try (GenericApplicationContext context = contextWith(ToolFixture.class)) {
ToolRegistry registry = new ToolRegistry(context);
assertThrows(ToolExecutionException.class, () -> registry.invoke("inspect", Map.of(
"monitorId", 42,
"ids", List.of(1),
"enabled", "not-a-boolean")));
}
}
@Test
void initializationShouldRejectDuplicateToolNames() {
try (GenericApplicationContext context = contextWith(ToolFixture.class, DuplicateToolFixture.class)) {
ToolRegistry registry = new ToolRegistry(context);
IllegalArgumentException error = assertThrows(
IllegalArgumentException.class, registry::getToolNames);
assertTrue(error.getMessage().contains("inspect"));
}
}
@Test
void emptyRegistryShouldReportUnknownTool() {
try (GenericApplicationContext context = contextWith()) {
ToolRegistry registry = new ToolRegistry(context);
assertTrue(registry.getToolNames().isEmpty());
assertThrows(IllegalArgumentException.class, () -> registry.invoke("missing", null));
}
}
private GenericApplicationContext contextWith(Class<?>... beanTypes) {
GenericApplicationContext context = new GenericApplicationContext();
for (Class<?> beanType : beanTypes) {
context.registerBean(beanType);
}
context.refresh();
return context;
}
public static final class ToolFixture {
@Tool(name = "inspect", description = "Inspect argument types")
public String inspect(
@ToolParam(description = "Monitor ID", required = true) Long monitorId,
@ToolParam(description = "Monitor ID list", required = false) List<Long> ids,
@ToolParam(description = "Whether enabled", required = true) Boolean enabled) {
return "%s|%s|%s".formatted(
monitorId.getClass().getSimpleName(),
ids.getFirst().getClass().getSimpleName(),
enabled.getClass().getSimpleName());
}
@Tool(name = "optional", description = "Tool without arguments")
public String optional() {
return "optional";
}
}
public static final class DuplicateToolFixture {
@Tool(name = "inspect", description = "Duplicate-name test tool")
public String inspect() {
return "duplicate";
}
}
}
@@ -30,6 +30,7 @@ import org.apache.hertzbeat.common.entity.alerter.NoticeReceiver;
import org.apache.hertzbeat.common.entity.alerter.NoticeRule;
import org.apache.hertzbeat.common.entity.alerter.NoticeTemplate;
import org.apache.hertzbeat.alert.service.NoticeConfigService;
import org.apache.hertzbeat.alert.util.NoticeReceiverMaskUtil;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Page;
import org.springframework.http.ResponseEntity;
@@ -87,14 +88,16 @@ public class NoticeConfigController {
@Parameter(description = "en: Recipient name,support fuzzy query", example = "tom") @RequestParam(required = false) final String name,
@Parameter(description = "en: List current page", example = "0") @RequestParam(defaultValue = "0") final int pageIndex,
@Parameter(description = "en: Number of list pages", example = "8") @RequestParam(defaultValue = "8") final int pageSize) {
return ResponseEntity.ok(Message.success(noticeConfigService.getNoticeReceivers(name, pageIndex, pageSize)));
return ResponseEntity.ok(Message.success(noticeConfigService.getNoticeReceivers(name, pageIndex, pageSize)
.map(NoticeReceiverMaskUtil::mask)));
}
@GetMapping(path = "/receivers/all")
@Operation(summary = "Get a list of all message notification recipients",
description = "Get a list of all message notification recipients")
public ResponseEntity<Message<List<NoticeReceiver>>> getAllReceivers() {
return ResponseEntity.ok(Message.success(noticeConfigService.getAllNoticeReceivers()));
return ResponseEntity.ok(Message.success(noticeConfigService.getAllNoticeReceivers().stream()
.map(NoticeReceiverMaskUtil::mask).toList()));
}
@GetMapping(path = "/receiver/{id}")
@@ -106,7 +109,7 @@ public class NoticeConfigController {
if (noticeReceiver == null) {
return ResponseEntity.ok(Message.fail(FAIL_CODE, "The relevant information of the recipient could not be found, please check whether the parameters are correct or refresh the page"));
}
return ResponseEntity.ok(Message.success(noticeReceiver));
return ResponseEntity.ok(Message.success(NoticeReceiverMaskUtil.mask(noticeReceiver)));
}
@PostMapping(path = "/rule")
@@ -31,6 +31,7 @@ import org.apache.hertzbeat.alert.dao.NoticeReceiverDao;
import org.apache.hertzbeat.alert.dao.NoticeRuleDao;
import org.apache.hertzbeat.alert.dao.NoticeTemplateDao;
import org.apache.hertzbeat.alert.service.NoticeConfigService;
import org.apache.hertzbeat.alert.util.NoticeReceiverMaskUtil;
import org.apache.hertzbeat.common.entity.alerter.SingleAlert;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.CommandLineRunner;
@@ -176,9 +177,28 @@ public class NoticeConfigServiceImpl implements NoticeConfigService, CommandLine
@Override
public void editReceiver(NoticeReceiver noticeReceiver) {
resolveMaskedSecrets(noticeReceiver);
noticeReceiverDao.save(noticeReceiver);
}
/**
* The rest api returns receivers with masked secret fields, so a receiver submitted
* from the ui may carry the mask placeholder instead of the real secret.
* Restore such fields from the stored entity before using the receiver.
* @throws IllegalArgumentException if the receiver carries an id but no stored receiver
* exists for it: without the stored entity the mask cannot
* be resolved, and saving would persist the placeholder
*/
private void resolveMaskedSecrets(NoticeReceiver noticeReceiver) {
if (noticeReceiver == null || noticeReceiver.getId() == null) {
return;
}
NoticeReceiver existing = noticeReceiverDao.findById(noticeReceiver.getId())
.orElseThrow(() -> new IllegalArgumentException(
"The receiver with id " + noticeReceiver.getId() + " does not exist."));
NoticeReceiverMaskUtil.resolveMask(noticeReceiver, existing);
}
@Override
public void deleteReceiver(Long receiverId) {
noticeReceiverDao.deleteById(receiverId);
@@ -319,6 +339,7 @@ public class NoticeConfigServiceImpl implements NoticeConfigService, CommandLine
@Override
public boolean sendTestMsg(NoticeReceiver noticeReceiver) {
resolveMaskedSecrets(noticeReceiver);
Map<String, String> labels = new HashMap<>(8);
labels.put(CommonConstants.LABEL_INSTANCE, "127.0.0.1");
labels.put(CommonConstants.LABEL_ALERT_NAME, "CPU Usage Alert");
@@ -0,0 +1,125 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.hertzbeat.alert.util;
import java.util.List;
import java.util.function.BiConsumer;
import java.util.function.Function;
import org.apache.commons.lang3.StringUtils;
import org.apache.hertzbeat.common.entity.alerter.NoticeReceiver;
/**
* Masks the secret fields of {@link NoticeReceiver} before it is exposed through the rest api,
* and resolves masked values back to the stored secrets when an edited receiver is submitted.
* A secret long enough keeps its last characters visible so different tokens stay
* distinguishable in the ui, while an unchanged secret round-trips through the ui
* without ever leaving the server.
*/
public final class NoticeReceiverMaskUtil {
/**
* Fixed-length placeholder replacing the hidden part of a secret. Its length is
* constant on purpose so the mask never reveals how long the real secret is.
*/
public static final String SECRET_MASK = "******";
/**
* A secret shorter than this is masked entirely: revealing a suffix of a short
* secret would give away too large a fraction of it.
*/
private static final int MIN_LENGTH_TO_SHOW_SUFFIX = 12;
/**
* Number of trailing characters kept visible for a long secret,
* enough to tell configured tokens apart.
*/
private static final int VISIBLE_SUFFIX_LENGTH = 4;
private record SecretField(Function<NoticeReceiver, String> getter, BiConsumer<NoticeReceiver, String> setter) {
}
private static final List<SecretField> SECRET_FIELDS = List.of(
new SecretField(NoticeReceiver::getHookAuthToken, NoticeReceiver::setHookAuthToken),
new SecretField(NoticeReceiver::getAccessToken, NoticeReceiver::setAccessToken),
new SecretField(NoticeReceiver::getTgBotToken, NoticeReceiver::setTgBotToken),
new SecretField(NoticeReceiver::getSlackWebHookUrl, NoticeReceiver::setSlackWebHookUrl),
new SecretField(NoticeReceiver::getAppSecret, NoticeReceiver::setAppSecret),
new SecretField(NoticeReceiver::getDiscordBotToken, NoticeReceiver::setDiscordBotToken),
new SecretField(NoticeReceiver::getSmnAk, NoticeReceiver::setSmnAk),
new SecretField(NoticeReceiver::getSmnSk, NoticeReceiver::setSmnSk),
new SecretField(NoticeReceiver::getServerChanToken, NoticeReceiver::setServerChanToken),
new SecretField(NoticeReceiver::getGotifyToken, NoticeReceiver::setGotifyToken),
new SecretField(NoticeReceiver::getNtfyToken, NoticeReceiver::setNtfyToken));
private NoticeReceiverMaskUtil() {
}
/**
* Return a copy of the receiver with all secret fields masked.
* The given entity is not modified as it may still be attached to the persistence context.
* @param receiver receiver to mask, may be null
* @return masked copy, or null if receiver is null
*/
public static NoticeReceiver mask(NoticeReceiver receiver) {
if (receiver == null) {
return null;
}
NoticeReceiver masked = receiver.toBuilder().build();
for (SecretField field : SECRET_FIELDS) {
field.setter().accept(masked, maskValue(field.getter().apply(masked)));
}
return masked;
}
/**
* Replace every secret field of the incoming receiver that still holds the masked form
* of the stored secret with the stored value, so an edit that did not touch a secret keeps it.
* A re-entered secret or a cleared field is left untouched.
* @param incoming receiver submitted by the ui, modified in place
* @param existing receiver currently stored in the database
*/
public static void resolveMask(NoticeReceiver incoming, NoticeReceiver existing) {
if (incoming == null || existing == null) {
return;
}
for (SecretField field : SECRET_FIELDS) {
String submitted = field.getter().apply(incoming);
String stored = field.getter().apply(existing);
if (isMaskOf(submitted, stored)) {
field.setter().accept(incoming, stored);
}
}
}
private static boolean isMaskOf(String submitted, String stored) {
if (submitted == null || StringUtils.isBlank(stored)) {
return false;
}
return submitted.equals(SECRET_MASK) || submitted.equals(maskValue(stored));
}
private static String maskValue(String value) {
if (StringUtils.isBlank(value)) {
return value;
}
if (value.length() < MIN_LENGTH_TO_SHOW_SUFFIX) {
return SECRET_MASK;
}
return SECRET_MASK + value.substring(value.length() - VISIBLE_SUFFIX_LENGTH);
}
}
@@ -31,6 +31,7 @@ import java.util.Arrays;
import java.util.List;
import java.util.Optional;
import org.apache.hertzbeat.alert.service.impl.NoticeConfigServiceImpl;
import org.apache.hertzbeat.alert.util.NoticeReceiverMaskUtil;
import org.apache.hertzbeat.common.constants.CommonConstants;
import org.apache.hertzbeat.common.entity.alerter.NoticeReceiver;
import org.apache.hertzbeat.common.entity.alerter.NoticeRule;
@@ -174,6 +175,7 @@ class NoticeConfigControllerTest {
NoticeReceiver receiver1 = new NoticeReceiver();
receiver1.setId(1L);
receiver1.setName("Receiver1");
receiver1.setTgBotToken("1499012345:AAEOB_wEYS-DZyPM3h5NzI8voJM");
NoticeReceiver receiver2 = new NoticeReceiver();
receiver2.setId(2L);
@@ -197,6 +199,7 @@ class NoticeConfigControllerTest {
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.content[0].id").value(1))
.andExpect(jsonPath("$.data.content[0].name").value("Receiver1"))
.andExpect(jsonPath("$.data.content[0].tgBotToken").value(NoticeReceiverMaskUtil.SECRET_MASK + "voJM"))
.andExpect(jsonPath("$.data.content[1].id").value(2))
.andExpect(jsonPath("$.data.content[1].name").value("Receiver2"))
.andExpect(jsonPath("$.data.totalElements").value(2))
@@ -222,6 +225,8 @@ class NoticeConfigControllerTest {
this.mockMvc.perform(MockMvcRequestBuilders.get("/api/notice/receiver/{id}", 7565463543L))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value((int) CommonConstants.SUCCESS_CODE))
.andExpect(jsonPath("$.data.accessToken").value(NoticeReceiverMaskUtil.SECRET_MASK + "739d"))
.andExpect(jsonPath("$.data.email").value("2762242004@qq.com"))
.andReturn();
}
@@ -17,25 +17,6 @@
* under the License.
*/
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.hertzbeat.alert.reduce;
import static org.junit.jupiter.api.Assertions.assertEquals;
@@ -17,25 +17,6 @@
* under the License.
*/
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.hertzbeat.alert.reduce;
import static org.junit.jupiter.api.Assertions.assertEquals;
@@ -22,6 +22,7 @@ import org.apache.hertzbeat.alert.dao.NoticeRuleDao;
import org.apache.hertzbeat.alert.dao.NoticeTemplateDao;
import org.apache.hertzbeat.alert.notice.AlertNoticeDispatch;
import org.apache.hertzbeat.alert.service.impl.NoticeConfigServiceImpl;
import org.apache.hertzbeat.alert.util.NoticeReceiverMaskUtil;
import org.apache.hertzbeat.common.cache.CacheFactory;
import org.apache.hertzbeat.common.entity.alerter.GroupAlert;
import org.apache.hertzbeat.common.entity.alerter.NoticeReceiver;
@@ -48,12 +49,15 @@ import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -211,11 +215,70 @@ class NoticeConfigServiceTest {
@Test
void editReceiver() {
final NoticeReceiver noticeReceiver = mock(NoticeReceiver.class);
final NoticeReceiver noticeReceiver = new NoticeReceiver();
noticeReceiver.setId(5L);
when(noticeReceiverDao.findById(5L)).thenReturn(Optional.of(noticeReceiver));
noticeConfigService.editReceiver(noticeReceiver);
verify(noticeReceiverDao, times(1)).save(noticeReceiver);
}
@Test
void editReceiverKeepsStoredSecretWhenMasked() {
final NoticeReceiver stored = new NoticeReceiver();
stored.setId(5L);
stored.setTgBotToken("1499012345:AAEOB_wEYS-DZyPM3h5NzI8voJM");
when(noticeReceiverDao.findById(5L)).thenReturn(Optional.of(stored));
final NoticeReceiver incoming = new NoticeReceiver();
incoming.setId(5L);
incoming.setTgBotToken(NoticeReceiverMaskUtil.SECRET_MASK + "voJM");
noticeConfigService.editReceiver(incoming);
assertEquals("1499012345:AAEOB_wEYS-DZyPM3h5NzI8voJM", incoming.getTgBotToken());
verify(noticeReceiverDao, times(1)).save(incoming);
}
@Test
void editReceiverRejectsUnknownId() {
when(noticeReceiverDao.findById(5L)).thenReturn(Optional.empty());
final NoticeReceiver incoming = new NoticeReceiver();
incoming.setId(5L);
incoming.setTgBotToken(NoticeReceiverMaskUtil.SECRET_MASK + "voJM");
assertThrows(IllegalArgumentException.class, () -> noticeConfigService.editReceiver(incoming));
verify(noticeReceiverDao, never()).save(any());
}
@Test
void sendTestMsgResolvesMaskedSecret() {
final NoticeReceiver stored = new NoticeReceiver();
stored.setId(5L);
stored.setTgBotToken("1499012345:AAEOB_wEYS-DZyPM3h5NzI8voJM");
when(noticeReceiverDao.findById(5L)).thenReturn(Optional.of(stored));
final NoticeReceiver incoming = new NoticeReceiver();
incoming.setId(5L);
incoming.setTgBotToken(NoticeReceiverMaskUtil.SECRET_MASK + "voJM");
noticeConfigService.sendTestMsg(incoming);
assertEquals("1499012345:AAEOB_wEYS-DZyPM3h5NzI8voJM", incoming.getTgBotToken());
}
@Test
void sendTestMsgRejectsUnknownId() {
when(noticeReceiverDao.findById(5L)).thenReturn(Optional.empty());
final NoticeReceiver incoming = new NoticeReceiver();
incoming.setId(5L);
incoming.setTgBotToken(NoticeReceiverMaskUtil.SECRET_MASK + "voJM");
assertThrows(IllegalArgumentException.class, () -> noticeConfigService.sendTestMsg(incoming));
verify(dispatcherAlarm, never()).sendNoticeMsg(any(), any(), any());
}
@Test
void deleteReceiver() {
final Long receiverId = 23342525L;
@@ -288,7 +351,7 @@ class NoticeConfigServiceTest {
@Test
void sendTestMsg() {
final NoticeReceiver noticeReceiver = mock(NoticeReceiver.class);
final NoticeReceiver noticeReceiver = new NoticeReceiver();
final NoticeTemplate noticeTemplate = null;
noticeConfigService.sendTestMsg(noticeReceiver);
verify(dispatcherAlarm, times(1)).sendNoticeMsg(eq(noticeReceiver), eq(noticeTemplate), any(GroupAlert.class));
@@ -0,0 +1,134 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.hertzbeat.alert.util;
import org.apache.hertzbeat.common.entity.alerter.NoticeReceiver;
import org.junit.jupiter.api.Test;
import static org.apache.hertzbeat.alert.util.NoticeReceiverMaskUtil.SECRET_MASK;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
/**
* Test case for {@link NoticeReceiverMaskUtil}
*/
class NoticeReceiverMaskUtilTest {
private NoticeReceiver buildReceiverWithSecrets() {
NoticeReceiver receiver = new NoticeReceiver();
receiver.setId(1L);
receiver.setName("tom");
receiver.setEmail("tom@usthe.com");
receiver.setHookUrl("https://example.com/hook");
receiver.setHookAuthToken("hook-auth-token-abcd");
receiver.setAccessToken("c03a568a306f8fd84dab51ff03cf6af6ba676a3be940c904e1df2de34853739d");
receiver.setTgBotToken("1499012345:AAEOB_wEYS-DZyPM3h5NzI8voJM");
receiver.setSlackWebHookUrl("https://hooks.slack.com/services/X/Y/Zt0k3n");
receiver.setAppSecret("oUydwn92ey0lnuY02MixNa57eNK-20dJn5NEOG-u2uE");
receiver.setDiscordBotToken("MTA2NTMwMzU0ODY4Mzg4MjUzNw.discord.t0kn");
receiver.setSmnAk("NCVBODJOEYHSW3VNSMAK");
receiver.setSmnSk("nmSNhUJN9MlpPl8lfCsgdA0KvHCL9JSMSK");
receiver.setServerChanToken("SCT193569TSNm6xIabdjqeZPtOGOWcvU1e");
receiver.setGotifyToken("A845h__ZMqDxZlO");
receiver.setNtfyToken("tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2");
return receiver;
}
@Test
void maskKeepsSuffixOfLongSecrets() {
NoticeReceiver receiver = buildReceiverWithSecrets();
NoticeReceiver masked = NoticeReceiverMaskUtil.mask(receiver);
assertEquals(SECRET_MASK + "abcd", masked.getHookAuthToken());
assertEquals(SECRET_MASK + "739d", masked.getAccessToken());
assertEquals(SECRET_MASK + "voJM", masked.getTgBotToken());
assertEquals(SECRET_MASK + "0k3n", masked.getSlackWebHookUrl());
assertEquals(SECRET_MASK + "u2uE", masked.getAppSecret());
assertEquals(SECRET_MASK + "t0kn", masked.getDiscordBotToken());
assertEquals(SECRET_MASK + "SMAK", masked.getSmnAk());
assertEquals(SECRET_MASK + "SMSK", masked.getSmnSk());
assertEquals(SECRET_MASK + "vU1e", masked.getServerChanToken());
assertEquals(SECRET_MASK + "xZlO", masked.getGotifyToken());
assertEquals(SECRET_MASK + "NIz2", masked.getNtfyToken());
assertEquals(receiver.getId(), masked.getId());
assertEquals(receiver.getName(), masked.getName());
assertEquals(receiver.getEmail(), masked.getEmail());
assertEquals(receiver.getHookUrl(), masked.getHookUrl());
}
@Test
void maskHidesShortSecretsEntirely() {
NoticeReceiver receiver = new NoticeReceiver();
receiver.setAccessToken("short-token");
receiver.setGotifyToken("tiny");
NoticeReceiver masked = NoticeReceiverMaskUtil.mask(receiver);
assertEquals(SECRET_MASK, masked.getAccessToken());
assertEquals(SECRET_MASK, masked.getGotifyToken());
}
@Test
void maskDoesNotModifyOriginalAndKeepsEmptySecrets() {
NoticeReceiver receiver = buildReceiverWithSecrets();
NoticeReceiverMaskUtil.mask(receiver);
assertEquals("1499012345:AAEOB_wEYS-DZyPM3h5NzI8voJM", receiver.getTgBotToken());
assertEquals("nmSNhUJN9MlpPl8lfCsgdA0KvHCL9JSMSK", receiver.getSmnSk());
NoticeReceiver empty = new NoticeReceiver();
NoticeReceiver maskedEmpty = NoticeReceiverMaskUtil.mask(empty);
assertNull(maskedEmpty.getAccessToken());
assertNull(maskedEmpty.getNtfyToken());
assertNull(NoticeReceiverMaskUtil.mask(null));
}
@Test
void resolveMaskRestoresOnlyMaskedFields() {
NoticeReceiver existing = buildReceiverWithSecrets();
NoticeReceiver incoming = NoticeReceiverMaskUtil.mask(existing);
incoming.setAccessToken("new-access-token-1234");
incoming.setGotifyToken(null);
incoming.setNtfyToken(SECRET_MASK);
NoticeReceiverMaskUtil.resolveMask(incoming, existing);
assertEquals("new-access-token-1234", incoming.getAccessToken());
assertNull(incoming.getGotifyToken());
assertEquals("tk_AgQdq7mVBoFD37zQVN29RhuMzNIz2", incoming.getNtfyToken());
assertEquals("hook-auth-token-abcd", incoming.getHookAuthToken());
assertEquals("1499012345:AAEOB_wEYS-DZyPM3h5NzI8voJM", incoming.getTgBotToken());
assertEquals("https://hooks.slack.com/services/X/Y/Zt0k3n", incoming.getSlackWebHookUrl());
assertEquals("oUydwn92ey0lnuY02MixNa57eNK-20dJn5NEOG-u2uE", incoming.getAppSecret());
assertEquals("MTA2NTMwMzU0ODY4Mzg4MjUzNw.discord.t0kn", incoming.getDiscordBotToken());
assertEquals("NCVBODJOEYHSW3VNSMAK", incoming.getSmnAk());
assertEquals("nmSNhUJN9MlpPl8lfCsgdA0KvHCL9JSMSK", incoming.getSmnSk());
assertEquals("SCT193569TSNm6xIabdjqeZPtOGOWcvU1e", incoming.getServerChanToken());
}
@Test
void resolveMaskIgnoresMaskWhenNothingIsStored() {
NoticeReceiver existing = new NoticeReceiver();
NoticeReceiver incoming = new NoticeReceiver();
incoming.setAccessToken(SECRET_MASK);
NoticeReceiverMaskUtil.resolveMask(incoming, existing);
assertEquals(SECRET_MASK, incoming.getAccessToken());
}
}
@@ -222,6 +222,8 @@ public class CommonDispatcher implements MetricsTaskDispatch, CollectDataDispatc
String monitorKey;
if (metrics.isHasSubTask()) {
monitorKey = job.getId() + "-" + metrics.getName() + "-sub-" + metrics.getSubTaskId();
} else if (metrics.getPrometheus() != null) {
monitorKey = String.valueOf(job.getId());
} else {
monitorKey = job.getId() + "-" + metrics.getName();
}
@@ -1,22 +1,3 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
@@ -0,0 +1,28 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.hertzbeat.common.entity.dto.observability;
/**
* Which observability query consoles the current storage backend supports.
*
* @param log log query console supported
* @param trace trace query console supported
* @param metric OTLP metric query console supported
*/
public record ObservabilityCapability(boolean log, boolean trace, boolean metric) {
}
@@ -1,22 +1,3 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
@@ -1,22 +1,3 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
@@ -17,8 +17,11 @@
package org.apache.hertzbeat.common.util;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.util.Arrays;
import java.util.Base64;
import javax.crypto.BadPaddingException;
import javax.crypto.Cipher;
@@ -46,6 +49,12 @@ public final class AesUtil {
private static final String AES = "AES";
private static final byte[] PAYLOAD_HEADER = {'H', 'B', 'A', '2'};
private static final int IV_LENGTH = 16;
private static final SecureRandom SECURE_RANDOM = new SecureRandom();
/**
* Encryption key The AES encryption key is 16 bits.
* If the AES encryption key is larger than 16 bits, an error message is displayed
@@ -87,14 +96,21 @@ public final class AesUtil {
SecretKeySpec keySpec = new SecretKeySpec(encryptKey.getBytes(StandardCharsets.UTF_8), AES);
// cipher based on the algorithm AES
Cipher cipher = Cipher.getInstance(ALGORITHM_STR);
byte[] initializationVector = new byte[IV_LENGTH];
SECURE_RANDOM.nextBytes(initializationVector);
// init cipher Encrypt_mode or Decrypt_mode operation, the second parameter is the KEY used
cipher.init(Cipher.ENCRYPT_MODE, keySpec, new IvParameterSpec(encryptKey.getBytes(StandardCharsets.UTF_8)));
cipher.init(Cipher.ENCRYPT_MODE, keySpec, new IvParameterSpec(initializationVector));
// get content bytes, must utf-8
byte[] byteEncode = content.getBytes(StandardCharsets.UTF_8);
// encode content to byte array
byte[] byteAes = cipher.doFinal(byteEncode);
byte[] payload = ByteBuffer.allocate(PAYLOAD_HEADER.length + initializationVector.length + byteAes.length)
.put(PAYLOAD_HEADER)
.put(initializationVector)
.put(byteAes)
.array();
// base64 encode content
return Base64.getEncoder().encodeToString(byteAes);
return Base64.getEncoder().encodeToString(payload);
} catch (Exception e) {
log.error("aes encode content error: {}", e.getMessage(), e);
return content;
@@ -135,13 +151,36 @@ public final class AesUtil {
SecretKeySpec keySpec = new SecretKeySpec(decryptKey.getBytes(StandardCharsets.UTF_8), AES);
// cipher based on the algorithm AES
Cipher cipher = Cipher.getInstance(ALGORITHM_STR);
// init cipher Encrypt_mode or Decrypt_mode operation, the second parameter is the KEY used
cipher.init(Cipher.DECRYPT_MODE, keySpec, new IvParameterSpec(decryptKey.getBytes(StandardCharsets.UTF_8)));
// base64 decode content
byte[] bytesContent = Base64.getDecoder().decode(content);
byte[] initializationVector;
if (hasPayloadHeader(bytesContent)) {
if (bytesContent.length <= PAYLOAD_HEADER.length + IV_LENGTH) {
throw new IllegalArgumentException("Invalid encrypted payload");
}
initializationVector = Arrays.copyOfRange(
bytesContent, PAYLOAD_HEADER.length, PAYLOAD_HEADER.length + IV_LENGTH);
bytesContent = Arrays.copyOfRange(
bytesContent, PAYLOAD_HEADER.length + IV_LENGTH, bytesContent.length);
} else {
initializationVector = decryptKey.getBytes(StandardCharsets.UTF_8);
}
// init cipher Encrypt_mode or Decrypt_mode operation, the second parameter is the KEY used
cipher.init(Cipher.DECRYPT_MODE, keySpec, new IvParameterSpec(initializationVector));
// decode content to byte array
return cipher.doFinal(bytesContent);
}
private static boolean hasPayloadHeader(byte[] payload) {
if (payload.length < PAYLOAD_HEADER.length) {
return false;
}
for (int index = 0; index < PAYLOAD_HEADER.length; index++) {
if (payload[index] != PAYLOAD_HEADER[index]) {
return false;
}
}
return true;
}
/**
* Determine whether it is encrypted
@@ -20,10 +20,14 @@ package org.apache.hertzbeat.common.util;
import static org.apache.hertzbeat.common.util.AesUtil.aesDecode;
import static org.apache.hertzbeat.common.util.AesUtil.aesEncode;
import static org.apache.hertzbeat.common.util.AesUtil.isCiphertext;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.Base64;
import org.junit.jupiter.api.Test;
/**
@@ -43,12 +47,31 @@ class AesUtilTest {
void testAesEncode() {
String originalText = "This is a secret message";
String encryptedText = aesEncode(originalText, VALID_KEY);
String secondEncryptedText = aesEncode(originalText, VALID_KEY);
assertNotEquals(originalText, encryptedText);
assertNotEquals(encryptedText, secondEncryptedText);
byte[] encryptedPayload = Base64.getDecoder().decode(encryptedText);
byte[] secondEncryptedPayload = Base64.getDecoder().decode(secondEncryptedText);
assertArrayEquals(new byte[] {'H', 'B', 'A', '2'}, Arrays.copyOfRange(encryptedPayload, 0, 4));
assertFalse(Arrays.equals(
VALID_KEY.getBytes(StandardCharsets.UTF_8), Arrays.copyOfRange(encryptedPayload, 4, 20)));
assertFalse(Arrays.equals(
Arrays.copyOfRange(encryptedPayload, 4, 20),
Arrays.copyOfRange(secondEncryptedPayload, 4, 20)));
String decryptedText = aesDecode(encryptedText, VALID_KEY);
assertEquals(originalText, decryptedText);
}
@Test
void testLegacyCiphertextCanBeDecoded() {
String legacyCiphertext = "muJNZwhxg173v6EZAXb5TK8L5XlmDE5xmc9XTryH6Qk=";
assertEquals("This is a secret message", aesDecode(legacyCiphertext, VALID_KEY));
assertTrue(isCiphertext(legacyCiphertext, VALID_KEY));
}
@Test
void testAesDecode() {
// Test with invalid key
@@ -17,25 +17,6 @@
* under the License.
*/
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.hertzbeat.common.entity.alerter;
import io.swagger.v3.oas.annotations.media.Schema;
@@ -48,7 +48,7 @@ import org.springframework.data.jpa.domain.support.AuditingEntityListener;
@Entity
@Table(name = "hzb_notice_receiver")
@Data
@Builder
@Builder(toBuilder = true)
@AllArgsConstructor
@NoArgsConstructor
@Schema(description = "Message notification recipient entity")
@@ -70,6 +70,7 @@ resourceRole:
- /api/sse/**===post===[admin,user]
- /api/chat/**===get===[admin,user]
- /api/chat/**===post===[admin,user]
- /api/logs/sse/**===get===[admin,user,guest]
- /api/logs/ingest/**===post===[admin,user]
- /api/otlp/**===post===[admin,user]
- /api/ingestion/otlp/**===get===[admin,user,guest]
@@ -81,10 +82,10 @@ resourceRole:
# eg: /api/v1/source3===get means /api/v1/source3===get can be access by anyone, no need auth.
excludedResource:
- /api/alert/sse/**===*
- /api/logs/sse/**===*
- /api/account/auth/**===*
- /api/i18n/**===get
- /api/apps/hierarchy===get
- /api/observability/capability===get
- /api/push/**===*
- /api/status/page/public/**===*
- /api/manager/sse/**===*
+1 -5
View File
@@ -30,7 +30,6 @@
<properties>
<awaitility.version>4.2.0</awaitility.version>
<grpc.version>1.56.1</grpc.version>
</properties>
<dependencies>
@@ -72,21 +71,18 @@
<groupId>com.google.protobuf</groupId>
<artifactId>protobuf-java-util</artifactId>
</dependency>
<!-- OTLP gRPC server -->
<!-- OTLP gRPC server, version managed by grpc-bom in the root pom -->
<dependency>
<groupId>io.grpc</groupId>
<artifactId>grpc-netty-shaded</artifactId>
<version>${grpc.version}</version>
</dependency>
<dependency>
<groupId>io.grpc</groupId>
<artifactId>grpc-protobuf</artifactId>
<version>${grpc.version}</version>
</dependency>
<dependency>
<groupId>io.grpc</groupId>
<artifactId>grpc-stub</artifactId>
<version>${grpc.version}</version>
</dependency>
<!-- awaitility for async testing -->
<dependency>
@@ -44,6 +44,7 @@ import org.springframework.web.client.RestTemplate;
public class GreptimeSignalInitializer {
private static final String TRACE_SCHEMA = "greptime/tables/hzb_traces.sql";
private static final String LOG_SCHEMA = "greptime/tables/hertzbeat_logs.sql";
private static final String LOG_PIPELINE = "greptime/pipelines/hertzbeat_otlp_log_v1.yaml";
private final GreptimeProperties greptimeProperties;
private final GreptimeSqlQueryExecutor sqlQueryExecutor;
@@ -68,6 +69,9 @@ public class GreptimeSignalInitializer {
+ "\"resource_attributes.service.namespace\" STRING NULL");
sqlQueryExecutor.execute("ALTER TABLE hzb_traces ADD COLUMN IF NOT EXISTS "
+ "\"resource_attributes.deployment.environment.name\" STRING NULL");
String logSchema = new ClassPathResource(LOG_SCHEMA)
.getContentAsString(StandardCharsets.UTF_8).strip();
sqlQueryExecutor.execute(StringUtils.trimTrailingCharacter(logSchema, ';'));
uploadLogPipeline(new ClassPathResource(LOG_PIPELINE)
.getContentAsString(StandardCharsets.UTF_8));
if (sqlQueryExecutor.execute("SELECT 1 AS ready").isEmpty()) {
@@ -24,6 +24,7 @@ import io.swagger.v3.oas.annotations.tags.Tag;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Supplier;
import org.apache.hertzbeat.warehouse.store.history.tsdb.HistoryDataReader;
import org.springframework.data.domain.Page;
@@ -32,6 +33,7 @@ import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import lombok.extern.slf4j.Slf4j;
import org.apache.hertzbeat.common.constants.CommonConstants;
import org.apache.hertzbeat.common.entity.dto.Message;
import org.apache.hertzbeat.common.entity.dto.observability.LogQueryFilter;
import org.apache.hertzbeat.common.entity.log.LogEntry;
@@ -54,6 +56,9 @@ public class LogQueryController {
private static final int MAX_PAGE_SIZE = 200;
private static final String LOG_STORAGE_UNAVAILABLE_MSG =
"Log query is not supported by the current storage, please enable a log capable storage such as GreptimeDB";
private final HistoryDataReader historyDataReader;
private final SignalWorkloadGuard workloadGuard;
@@ -88,7 +93,7 @@ public class LogQueryController {
@RequestParam(value = "pageIndex", required = false, defaultValue = "0") Integer pageIndex,
@Parameter(description = "Number of items per page", example = "20")
@RequestParam(value = "pageSize", required = false, defaultValue = "20") Integer pageSize) {
return workloadGuard.execute(Workload.LOG_LIST, () -> {
return executeIfLogSupported(Workload.LOG_LIST, () -> {
LogQueryFilter filter = filter(start, end, traceId, spanId, severityNumber, severityText, search,
serviceName, serviceNamespace, environment, resource);
Page<LogEntry> result = getPagedLogs(filter, pageIndex, pageSize);
@@ -118,7 +123,7 @@ public class LogQueryController {
@RequestParam(value = "serviceNamespace", required = false) String serviceNamespace,
@RequestParam(value = "environment", required = false) String environment,
@RequestParam(value = "resource", required = false) String resource) {
return workloadGuard.execute(Workload.LOG_AGGREGATE,
return executeIfLogSupported(Workload.LOG_AGGREGATE,
() -> doOverviewStats(filter(start, end, traceId, spanId, severityNumber, severityText, search,
serviceName, serviceNamespace, environment, resource)));
}
@@ -149,7 +154,7 @@ public class LogQueryController {
@RequestParam(value = "serviceNamespace", required = false) String serviceNamespace,
@RequestParam(value = "environment", required = false) String environment,
@RequestParam(value = "resource", required = false) String resource) {
return workloadGuard.execute(Workload.LOG_AGGREGATE,
return executeIfLogSupported(Workload.LOG_AGGREGATE,
() -> doTraceCoverageStats(filter(start, end, traceId, spanId, severityNumber, severityText, search,
serviceName, serviceNamespace, environment, resource)));
}
@@ -182,7 +187,7 @@ public class LogQueryController {
@RequestParam(value = "serviceNamespace", required = false) String serviceNamespace,
@RequestParam(value = "environment", required = false) String environment,
@RequestParam(value = "resource", required = false) String resource) {
return workloadGuard.execute(Workload.LOG_AGGREGATE,
return executeIfLogSupported(Workload.LOG_AGGREGATE,
() -> doTrendStats(filter(start, end, traceId, spanId, severityNumber, severityText, search,
serviceName, serviceNamespace, environment, resource)));
}
@@ -193,6 +198,14 @@ public class LogQueryController {
return ResponseEntity.ok(Message.success(result));
}
private <T> ResponseEntity<Message<T>> executeIfLogSupported(Workload workload,
Supplier<ResponseEntity<Message<T>>> operation) {
if (!historyDataReader.supportsLogQuery()) {
return ResponseEntity.ok(Message.fail(CommonConstants.FAIL_CODE, LOG_STORAGE_UNAVAILABLE_MSG));
}
return workloadGuard.execute(workload, operation);
}
private LogQueryFilter filter(Long start, Long end, String traceId, String spanId, Integer severityNumber,
String severityText, String search, String serviceName, String serviceNamespace,
String environment, String resource) {
@@ -0,0 +1,65 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.hertzbeat.log.controller;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.apache.hertzbeat.common.entity.dto.Message;
import org.apache.hertzbeat.common.entity.dto.observability.ObservabilityCapability;
import org.apache.hertzbeat.log.service.ThreeSignalQueryService;
import org.apache.hertzbeat.warehouse.store.history.tsdb.HistoryDataReader;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* Report which observability query features the current storage backend supports,
* so the UI can render guidance instead of firing queries that cannot be served.
*
* <p>The three capabilities are probed through two different mechanisms on purpose: log query
* is dispatched per storage via {@link HistoryDataReader#supportsLogQuery()} (multiple storages
* implement log read/write), while trace/metric consoles are served only by the conditionally
* registered {@link ThreeSignalQueryService} (GreptimeDB only for now).
* TODO: once trace/metric query grows storage-dispatched implementations like logs (or log query
* is absorbed into the three-signal contract), unify the probes on a single abstraction.
*/
@RestController
@RequestMapping(path = "/api/observability", produces = "application/json")
@Tag(name = "Observability Capability Controller")
public class ObservabilityCapabilityController {
private final HistoryDataReader historyDataReader;
private final ObjectProvider<ThreeSignalQueryService> threeSignalQueryService;
public ObservabilityCapabilityController(HistoryDataReader historyDataReader,
ObjectProvider<ThreeSignalQueryService> threeSignalQueryService) {
this.historyDataReader = historyDataReader;
this.threeSignalQueryService = threeSignalQueryService;
}
@GetMapping("/capability")
@Operation(summary = "Query observability capability",
description = "Return whether log, trace and OTLP metric query consoles are supported by the current storage backend.")
public ResponseEntity<Message<ObservabilityCapability>> capability() {
boolean traceAndMetricSupported = threeSignalQueryService.getIfAvailable() != null;
return ResponseEntity.ok(Message.success(new ObservabilityCapability(
historyDataReader.supportsLogQuery(), traceAndMetricSupported, traceAndMetricSupported)));
}
}
@@ -38,6 +38,7 @@ import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicLong;
@@ -51,6 +52,8 @@ import java.util.concurrent.atomic.AtomicLong;
public class LogSseManager {
private static final long BATCH_INTERVAL_MS = 200;
private static final long HEARTBEAT_INTERVAL_SECONDS = 15;
private static final long RECONNECT_TIME_MS = 5_000;
private static final int MAX_BATCH_SIZE = 1000;
private static final int MAX_QUEUE_SIZE = 10000;
@@ -70,6 +73,8 @@ public class LogSseManager {
public LogSseManager() {
scheduler.scheduleAtFixedRate(this::flushBatch, BATCH_INTERVAL_MS, BATCH_INTERVAL_MS, TimeUnit.MILLISECONDS);
scheduler.scheduleAtFixedRate(this::sendHeartbeat, HEARTBEAT_INTERVAL_SECONDS,
HEARTBEAT_INTERVAL_SECONDS, TimeUnit.SECONDS);
}
@PreDestroy
@@ -96,6 +101,13 @@ public class LogSseManager {
emitter.onError((ex) -> removeEmitter(clientId));
emitters.put(clientId, new SseSubscriber(emitter, filters));
try {
emitter.send(SseEmitter.event()
.comment("connected")
.reconnectTime(RECONNECT_TIME_MS));
} catch (IOException | IllegalStateException e) {
safeComplete(clientId, emitter);
}
return emitter;
}
@@ -167,6 +179,31 @@ public class LogSseManager {
}
}
void sendHeartbeat() {
for (Map.Entry<Long, SseSubscriber> entry : emitters.entrySet()) {
Long clientId = entry.getKey();
SseEmitter emitter = entry.getValue().emitter;
try {
senderPool.submit(() -> sendHeartbeat(clientId, emitter));
} catch (RejectedExecutionException e) {
if (!senderPool.isShutdown()) {
log.warn("Failed to schedule SSE heartbeat for client {}", clientId, e);
}
}
}
}
private void sendHeartbeat(Long clientId, SseEmitter emitter) {
try {
emitter.send(SseEmitter.event().comment("heartbeat"));
} catch (IOException | IllegalStateException e) {
safeComplete(clientId, emitter);
} catch (Exception e) {
log.error("Failed to send SSE heartbeat to client {}", clientId, e);
safeComplete(clientId, emitter);
}
}
private void safeComplete(Long clientId, SseEmitter emitter) {
try {
emitter.complete();
@@ -0,0 +1,32 @@
-- Licensed to the Apache Software Foundation (ASF) under one or more
-- contributor license agreements. See the NOTICE file distributed with
-- this work for additional information regarding copyright ownership.
-- The ASF licenses this file to You under the Apache License, Version 2.0
-- (the "License"); you may not use this file except in compliance with
-- the License. You may obtain a copy of the License at
--
-- http://www.apache.org/licenses/LICENSE-2.0
--
-- Unless required by applicable law or agreed to in writing, software
-- distributed under the License is distributed on an "AS IS" BASIS,
-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-- See the License for the specific language governing permissions and
-- limitations under the License.
-- Schema must stay in sync with the transform section of
-- greptime/pipelines/hertzbeat_otlp_log_v1.yaml, which GreptimeDB would
-- otherwise use to auto-create this table on first ingestion.
CREATE TABLE IF NOT EXISTS hertzbeat_logs (
"time_unix_nano" TIMESTAMP(9) TIME INDEX,
"observed_time_unix_nano" TIMESTAMP(9) NULL,
"trace_id" STRING NULL SKIPPING INDEX WITH(granularity = '10240', type = 'BLOOM'),
"span_id" STRING NULL SKIPPING INDEX WITH(granularity = '10240', type = 'BLOOM'),
"trace_flags" INT NULL,
"severity_text" STRING NULL,
"severity_number" INT NULL,
"body" STRING NULL,
"attributes" JSON NULL,
"resource" JSON NULL,
"instrumentation_scope" JSON NULL,
"dropped_attributes_count" INT NULL
) WITH ('append_mode' = 'true');
@@ -35,4 +35,4 @@ CREATE TABLE IF NOT EXISTS hzb_traces (
"span_events" JSON NULL,
"span_links" JSON NULL,
PRIMARY KEY("service_name")
) WITH (append_mode = true, table_data_model = 'greptime_trace_v1');
) WITH ('append_mode' = 'true', 'table_data_model' = 'greptime_trace_v1');
@@ -76,6 +76,8 @@ class GreptimeSignalInitializerTest {
"ALTER TABLE hzb_traces ADD COLUMN IF NOT EXISTS \"resource_attributes.service.namespace\""));
sqlOrder.verify(sqlQueryExecutor).execute(org.mockito.ArgumentMatchers.startsWith(
"ALTER TABLE hzb_traces ADD COLUMN IF NOT EXISTS \"resource_attributes.deployment.environment.name\""));
sqlOrder.verify(sqlQueryExecutor).execute(org.mockito.ArgumentMatchers.contains(
"CREATE TABLE IF NOT EXISTS hertzbeat_logs"));
sqlOrder.verify(sqlQueryExecutor).execute("SELECT 1 AS ready");
verify(restTemplate).exchange(eq("http://127.0.0.1:4000/v1/pipelines/hertzbeat_otlp_log_v1"),
eq(HttpMethod.POST), any(HttpEntity.class), eq(String.class));
@@ -19,6 +19,7 @@ package org.apache.hertzbeat.log.controller;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
@@ -57,12 +58,26 @@ class LogQueryControllerTest {
@BeforeEach
void setUp() {
lenient().when(historyDataReader.supportsLogQuery()).thenReturn(true);
this.logQueryController = new LogQueryController(historyDataReader, new SignalWorkloadGuard());
this.mockMvc = MockMvcBuilders.standaloneSetup(logQueryController)
.setControllerAdvice(new SignalWorkloadExceptionHandler())
.build();
}
@Test
void shouldReturnFriendlyFailureWhenLogQueryUnsupported() throws Exception {
when(historyDataReader.supportsLogQuery()).thenReturn(false);
for (String path : List.of("/api/logs/list", "/api/logs/stats/overview",
"/api/logs/stats/trace-coverage", "/api/logs/stats/trend")) {
mockMvc.perform(MockMvcRequestBuilders.get(path))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value((int) CommonConstants.FAIL_CODE))
.andExpect(jsonPath("$.msg").isNotEmpty());
}
}
@Test
void testListLogsWithAllFilters() throws Exception {
// Mock data
@@ -17,9 +17,11 @@
package org.apache.hertzbeat.log.controller;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.Mockito.verify;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.request;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import org.apache.hertzbeat.log.notice.LogSseFilterCriteria;
@@ -35,6 +37,7 @@ import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
/**
@@ -75,6 +78,28 @@ class LogSseControllerTest {
Assertions.assertNull(capturedCriteria.getSpanId());
}
@Test
void testSubscribeCommitsInitialEvent() throws Exception {
LogSseManager realEmitterManager = new LogSseManager();
try {
MockMvc realMockMvc = MockMvcBuilders
.standaloneSetup(new LogSseController(realEmitterManager))
.build();
MvcResult result = realMockMvc.perform(get("/api/logs/sse/subscribe")
.accept(MediaType.TEXT_EVENT_STREAM_VALUE))
.andExpect(status().isOk())
.andExpect(request().asyncStarted())
.andReturn();
assertTrue(result.getResponse().isCommitted());
assertTrue(result.getResponse().getContentAsString().contains(":connected"));
assertTrue(result.getResponse().getContentAsString().contains("retry:5000"));
} finally {
realEmitterManager.shutdown();
}
}
@Test
void testSubscribeWithMultipleFilters() throws Exception {
// Given: Multiple filter parameters
@@ -110,4 +135,4 @@ class LogSseControllerTest {
.accept(MediaType.TEXT_EVENT_STREAM_VALUE))
.andExpect(status().is4xxClientError());
}
}
}
@@ -0,0 +1,85 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.hertzbeat.log.controller;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import org.apache.hertzbeat.common.constants.CommonConstants;
import org.apache.hertzbeat.log.service.ThreeSignalQueryService;
import org.apache.hertzbeat.warehouse.store.history.tsdb.HistoryDataReader;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
/**
* Unit test for {@link ObservabilityCapabilityController}
*/
@ExtendWith(MockitoExtension.class)
class ObservabilityCapabilityControllerTest {
private MockMvc mockMvc;
@Mock
private HistoryDataReader historyDataReader;
@Mock
private ObjectProvider<ThreeSignalQueryService> threeSignalQueryService;
@Mock
private ThreeSignalQueryService queryService;
@BeforeEach
void setUp() {
this.mockMvc = MockMvcBuilders.standaloneSetup(
new ObservabilityCapabilityController(historyDataReader, threeSignalQueryService)).build();
}
@Test
void shouldReportAllUnsupportedWithoutCapableStorage() throws Exception {
when(historyDataReader.supportsLogQuery()).thenReturn(false);
when(threeSignalQueryService.getIfAvailable()).thenReturn(null);
mockMvc.perform(MockMvcRequestBuilders.get("/api/observability/capability"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value((int) CommonConstants.SUCCESS_CODE))
.andExpect(jsonPath("$.data.log").value(false))
.andExpect(jsonPath("$.data.trace").value(false))
.andExpect(jsonPath("$.data.metric").value(false));
}
@Test
void shouldReportAllSupportedWithCapableStorage() throws Exception {
when(historyDataReader.supportsLogQuery()).thenReturn(true);
when(threeSignalQueryService.getIfAvailable()).thenReturn(queryService);
mockMvc.perform(MockMvcRequestBuilders.get("/api/observability/capability"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value((int) CommonConstants.SUCCESS_CODE))
.andExpect(jsonPath("$.data.log").value(true))
.andExpect(jsonPath("$.data.trace").value(true))
.andExpect(jsonPath("$.data.metric").value(true));
}
}
@@ -72,6 +72,33 @@ class LogSseManagerTest {
assertTrue(logSseManager.getEmitters().containsKey(CLIENT_ID));
}
@Test
void shouldSendHeartbeatToSubscribers() throws IOException {
SseEmitter mockEmitter = mock(SseEmitter.class);
subscribeClient(CLIENT_ID, null, mockEmitter);
logSseManager.sendHeartbeat();
await().atMost(500, TimeUnit.MILLISECONDS).untilAsserted(() ->
verify(mockEmitter).send(any(SseEmitter.SseEventBuilder.class))
);
}
@Test
void shouldRemoveEmitterWhenHeartbeatFails() throws IOException {
SseEmitter mockEmitter = mock(SseEmitter.class);
doThrow(new IOException("Connection closed"))
.when(mockEmitter).send(any(SseEmitter.SseEventBuilder.class));
subscribeClient(CLIENT_ID, null, mockEmitter);
logSseManager.sendHeartbeat();
await().atMost(500, TimeUnit.MILLISECONDS).untilAsserted(() -> {
verify(mockEmitter).complete();
assertFalse(logSseManager.getEmitters().containsKey(CLIENT_ID));
});
}
@Test
void shouldBroadcastLogWhenFilterMatches() throws IOException {
// Given: A client with a filter for "INFO" logs
@@ -62,15 +62,20 @@ public class StatusPagePublicController {
@GetMapping("/component")
@Operation(summary = "Query Status Page Components")
public ResponseEntity<Message<List<ComponentStatus>>> queryStatusPageComponent() {
List<ComponentStatus> componentStatusList = statusPageService.queryComponentsStatus();
public ResponseEntity<Message<List<ComponentStatus>>> queryStatusPageComponent(
@Parameter(description = "Number of days of history", example = "30")
@RequestParam(defaultValue = "30") int days) {
List<ComponentStatus> componentStatusList = statusPageService.queryComponentsStatus(days);
return ResponseEntity.ok(Message.success(componentStatusList));
}
@GetMapping("/component/{id}")
@Operation(summary = "Query Status Page Component")
public ResponseEntity<Message<ComponentStatus>> queryStatusPageComponent(@PathVariable("id") final long id) {
ComponentStatus componentStatus = statusPageService.queryComponentStatus(id);
public ResponseEntity<Message<ComponentStatus>> queryStatusPageComponent(
@PathVariable("id") final long id,
@Parameter(description = "Number of days of history", example = "30")
@RequestParam(defaultValue = "30") int days) {
ComponentStatus componentStatus = statusPageService.queryComponentStatus(id, days);
return ResponseEntity.ok(Message.success(componentStatus));
}
@@ -83,17 +83,19 @@ public interface StatusPageService {
/**
* query status page components status.
*
* @param days number of days of history to return
* @return status page components status
*/
List<ComponentStatus> queryComponentsStatus();
List<ComponentStatus> queryComponentsStatus(int days);
/**
* query status page component status.
*
* @param id status page component id
* @param id status page component id
* @param days number of days of history to return
* @return status page component status
*/
ComponentStatus queryComponentStatus(long id);
ComponentStatus queryComponentStatus(long id, int days);
/**
* query status page incidents.
@@ -58,8 +58,6 @@ import org.springframework.util.StringUtils;
@RequiredArgsConstructor
public class StatusPageServiceImpl implements StatusPageService {
private static final int HISTORY_SPAN_DAYS = 29;
@Autowired
private StatusPageOrgDao statusPageOrgDao;
@@ -126,7 +124,12 @@ public class StatusPageServiceImpl implements StatusPageService {
}
@Override
public List<ComponentStatus> queryComponentsStatus() {
public List<ComponentStatus> queryComponentsStatus(int days) {
if (days < 1 || days > 90) {
throw new IllegalArgumentException("days must be between 1 and 90");
}
int historySpanDays = days - 1;
List<StatusPageComponent> components = statusPageComponentDao.findAll();
List<ComponentStatus> componentStatusList = new LinkedList<>();
for (StatusPageComponent component : components) {
@@ -150,11 +153,11 @@ public class StatusPageServiceImpl implements StatusPageService {
.findStatusPageHistoriesByComponentIdAndTimestampBetween(component.getId(), todayStartTimestamp, nowTimestamp);
StatusPageHistory todayStatus = combineOneDayStatusPageHistory(todayStatusPageHistoryList, component, nowTimestamp);
histories.add(todayStatus);
// query 30d component status history
// query component status history for the configured number of days
long preTimestamp = now
.atZone(zoneId)
.toLocalDate()
.minusDays(HISTORY_SPAN_DAYS)
.minusDays(historySpanDays)
.atStartOfDay(zoneId)
.toInstant()
.toEpochMilli();
@@ -167,7 +170,7 @@ public class StatusPageServiceImpl implements StatusPageService {
.atZone(zoneId)
.minusSeconds(1); // yesterday 23:59:59 local time
for (int i = 0; i < HISTORY_SPAN_DAYS; i++) {
for (int i = 0; i < historySpanDays; i++) {
long endTimestamp = end.toInstant().toEpochMilli();
long startTimestamp = end.toLocalDate()
@@ -242,7 +245,12 @@ public class StatusPageServiceImpl implements StatusPageService {
}
@Override
public ComponentStatus queryComponentStatus(long id) {
public ComponentStatus queryComponentStatus(long id, int days) {
if (days < 1 || days > 90) {
throw new IllegalArgumentException("days must be between 1 and 90");
}
int historySpanDays = days - 1;
StatusPageComponent component = statusPageComponentDao.findById(id)
.orElseThrow(() -> new IllegalArgumentException("component not found"));
@@ -272,11 +280,11 @@ public class StatusPageServiceImpl implements StatusPageService {
histories.add(todayStatus);
// Previous HISTORY_SPAN_DAYS days (excluding today)
// Previous historySpanDays days (excluding today)
long preTimestamp = now
.atZone(zoneId)
.toLocalDate()
.minusDays(HISTORY_SPAN_DAYS)
.minusDays(historySpanDays)
.atStartOfDay(zoneId)
.toInstant()
.toEpochMilli();
@@ -292,7 +300,7 @@ public class StatusPageServiceImpl implements StatusPageService {
.atZone(zoneId)
.minusSeconds(1); // yesterday 23:59:59 local time
for (int i = 0; i < HISTORY_SPAN_DAYS; i++) {
for (int i = 0; i < historySpanDays; i++) {
long endTimestamp = end.toInstant().toEpochMilli();
long startTimestamp = end.toLocalDate()
@@ -88,7 +88,7 @@ class StatusPagePublicControllerTest {
public void testQueryStatusPageComponent() throws Exception {
List<ComponentStatus> componentStatusList = Collections.singletonList(new ComponentStatus());
when(statusPageService.queryComponentsStatus()).thenReturn(componentStatusList);
when(statusPageService.queryComponentsStatus(30)).thenReturn(componentStatusList);
mockMvc.perform(get("/api/status/page/public/component")
.accept(MediaType.APPLICATION_JSON))
@@ -96,11 +96,24 @@ class StatusPagePublicControllerTest {
.andExpect(jsonPath("$.code").value((int) CommonConstants.SUCCESS_CODE));
}
@Test
public void testQueryStatusPageComponentWithDays() throws Exception {
List<ComponentStatus> componentStatusList = Collections.singletonList(new ComponentStatus());
when(statusPageService.queryComponentsStatus(7)).thenReturn(componentStatusList);
mockMvc.perform(get("/api/status/page/public/component")
.param("days", "7")
.accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value((int) CommonConstants.SUCCESS_CODE));
}
@Test
public void testQueryStatusPageComponentById() throws Exception {
ComponentStatus componentStatus = new ComponentStatus();
when(statusPageService.queryComponentStatus(1L)).thenReturn(componentStatus);
when(statusPageService.queryComponentStatus(1L, 30)).thenReturn(componentStatus);
mockMvc.perform(get("/api/status/page/public/component/1")
.accept(MediaType.APPLICATION_JSON))
@@ -108,6 +121,19 @@ class StatusPagePublicControllerTest {
.andExpect(jsonPath("$.code").value((int) CommonConstants.SUCCESS_CODE));
}
@Test
public void testQueryStatusPageComponentByIdWithDays() throws Exception {
ComponentStatus componentStatus = new ComponentStatus();
when(statusPageService.queryComponentStatus(1L, 7)).thenReturn(componentStatus);
mockMvc.perform(get("/api/status/page/public/component/1")
.param("days", "7")
.accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value((int) CommonConstants.SUCCESS_CODE));
}
@Test
public void testQueryStatusPageIncident() throws Exception {
@@ -99,8 +99,9 @@ class StatusPageServiceImplTimeTest {
when(historyDao.findStatusPageHistoriesByComponentIdAndTimestampBetween(anyLong(), anyLong(), anyLong()))
.thenReturn(List.of(before, after));
List<ComponentStatus> result = service.queryComponentsStatus();
assertEquals(30, result.get(0).getHistory().size());
assertEquals(30, service.queryComponentsStatus(30).get(0).getHistory().size());
assertEquals(1, service.queryComponentsStatus(1).get(0).getHistory().size());
assertEquals(90, service.queryComponentsStatus(90).get(0).getHistory().size());
}
@Test
@@ -114,7 +115,7 @@ class StatusPageServiceImplTimeTest {
when(historyDao.findStatusPageHistoriesByComponentIdAndTimestampBetween(anyLong(), anyLong(), anyLong()))
.thenReturn(List.of(history));
List<ComponentStatus> result = service.queryComponentsStatus();
List<ComponentStatus> result = service.queryComponentsStatus(30);
assertEquals(30, result.get(0).getHistory().size());
}
@@ -128,7 +129,7 @@ class StatusPageServiceImplTimeTest {
when(historyDao.findStatusPageHistoriesByComponentIdAndTimestampBetween(anyLong(), anyLong(), anyLong()))
.thenReturn(List.of(history));
List<ComponentStatus> result = service.queryComponentsStatus();
List<ComponentStatus> result = service.queryComponentsStatus(30);
assertEquals(30, result.get(0).getHistory().size());
}
@@ -70,6 +70,7 @@ resourceRole:
- /api/sse/**===post===[admin,user]
- /api/chat/**===get===[admin,user]
- /api/chat/**===post===[admin,user]
- /api/logs/sse/**===get===[admin,user,guest]
- /api/logs/ingest/**===post===[admin,user]
- /api/otlp/**===post===[admin,user]
- /api/ingestion/otlp/**===get===[admin,user,guest]
@@ -81,10 +82,10 @@ resourceRole:
# eg: /api/v1/source3===get means /api/v1/source3===get can be access by anyone, no need auth.
excludedResource:
- /api/alert/sse/**===*
- /api/logs/sse/**===*
- /api/account/auth/**===*
- /api/i18n/**===get
- /api/apps/hierarchy===get
- /api/observability/capability===get
- /api/push/**===*
- /api/status/page/public/**===*
- /api/manager/sse/**===*
@@ -70,6 +70,7 @@ resourceRole:
- /api/mcp/**===post===[admin]
- /api/chat/**===get===[admin,user]
- /api/chat/**===post===[admin]
- /api/logs/sse/**===get===[admin,user,guest]
- /api/logs/ingest/**===post===[admin,user]
- /api/otlp/**===post===[admin,user]
- /api/ingestion/otlp/**===get===[admin,user,guest]
@@ -84,10 +85,10 @@ resourceRole:
# eg: /api/v1/source3===get means /api/v1/source3===get can be access by anyone, no need auth.
excludedResource:
- /api/alert/sse/**===*
- /api/logs/sse/**===*
- /api/account/auth/**===*
- /api/i18n/**===get
- /api/apps/hierarchy===get
- /api/observability/capability===get
- /api/push/**===*
- /api/status/page/public/**===*
- /api/manager/sse/**===*
@@ -38,6 +38,8 @@ import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.client.RestTemplate;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.LinkedList;
import java.util.List;
@@ -79,7 +81,7 @@ public class GreptimeSqlQueryExecutor extends SqlQueryExecutor {
headers.add(HttpHeaders.AUTHORIZATION, NetworkConstants.BASIC + SignConstants.BLANK + encodedAuth);
}
String requestBody = "sql=" + queryString;
String requestBody = "sql=" + URLEncoder.encode(queryString, StandardCharsets.UTF_8);
HttpEntity<String> httpEntity = new HttpEntity<>(requestBody, headers);
String url = greptimeProperties.httpEndpoint() + QUERY_PATH;
@@ -33,6 +33,13 @@ public interface HistoryDataReader {
*/
boolean isServerAvailable();
/**
* @return whether this storage supports observability log query
*/
default boolean supportsLogQuery() {
return false;
}
/**
* query history range metrics data from tsdb
*
@@ -662,6 +662,11 @@ public class GreptimeDbDataStorage extends AbstractHistoryDataStorage {
}
}
@Override
public boolean supportsLogQuery() {
return true;
}
@Override
public Map<String, Object> queryLogOverviewAggregate(LogQueryFilter filter) {
try {
@@ -15,25 +15,6 @@
* limitations under the License.
*/
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.hertzbeat.warehouse.store.history.tsdb.vm;
import java.util.List;
@@ -20,12 +20,16 @@
package org.apache.hertzbeat.warehouse.db;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
@@ -34,6 +38,7 @@ import org.apache.hertzbeat.warehouse.store.history.tsdb.greptime.GreptimeSqlQue
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.HttpEntity;
@@ -90,6 +95,37 @@ class GreptimeSqlQueryExecutorTest {
assertEquals(85.5, result.get(0).get("value"));
}
@Test
@SuppressWarnings("unchecked")
void testRequestBodyIsFormEncoded() {
when(restTemplate.exchange(
any(String.class),
eq(HttpMethod.POST),
any(HttpEntity.class),
eq(GreptimeSqlQueryContent.class)
)).thenReturn(new ResponseEntity<>(new GreptimeSqlQueryContent(), HttpStatus.OK));
// SQL carrying form-urlencoded metacharacters that would pollute the request body
// if concatenated raw: '&' would start a second sql= parameter and override the query.
String sql = "SELECT * FROM t WHERE name = 'x&sql=DROP TABLE t'";
greptimeSqlQueryExecutor.execute(sql);
ArgumentCaptor<HttpEntity<String>> captor = ArgumentCaptor.forClass(HttpEntity.class);
verify(restTemplate).exchange(
any(String.class),
eq(HttpMethod.POST),
captor.capture(),
eq(GreptimeSqlQueryContent.class));
String body = captor.getValue().getBody();
assertNotNull(body);
// The body must be a single sql= parameter; the '&' inside the SQL must be encoded.
String encodedValue = body.substring("sql=".length());
assertFalse(encodedValue.contains("&"), "raw '&' in body causes HTTP parameter pollution");
// Decoding the value must yield exactly the original SQL, lossless round-trip.
assertEquals(sql, URLDecoder.decode(encodedValue, StandardCharsets.UTF_8));
}
@Test
void testExecuteError() {
// Mock error response
+1 -1
View File
@@ -24,7 +24,7 @@ module.exports = {
},
i18n: {
defaultLocale: 'en',
locales: ['zh-cn', 'en'],
locales: ['zh-cn', 'en', 'ko'],
},
themeConfig: {
matomo: {
+504
View File
@@ -0,0 +1,504 @@
{
"theme.NotFound.title": {
"message": "페이지를 찾을 수 없습니다",
"description": "The title of the 404 page"
},
"theme.NotFound.p1": {
"message": "찾으시는 페이지를 찾을 수 없습니다.",
"description": "The first paragraph of the 404 page"
},
"theme.NotFound.p2": {
"message": "이 URL로 연결한 사이트의 관리자에게 링크가 깨졌다고 알려주세요.",
"description": "The 2nd paragraph of the 404 page"
},
"theme.AnnouncementBar.closeButtonAriaLabel": {
"message": "닫기",
"description": "The ARIA label for close button of announcement bar"
},
"theme.blog.paginator.navAriaLabel": {
"message": "블로그 목록 페이지 내비게이션",
"description": "The ARIA label for the blog pagination"
},
"theme.blog.paginator.newerEntries": {
"message": "최신 글",
"description": "The label used to navigate to the newer blog posts page (previous page)"
},
"theme.blog.paginator.olderEntries": {
"message": "이전 글",
"description": "The label used to navigate to the older blog posts page (next page)"
},
"theme.blog.post.readingTime.plurals": {
"message": "{readingTime}분 소요",
"description": "Pluralized label for \"{readingTime} min read\". Use as much plural forms (separated by \"|\") as your language support (see https://www.unicode.org/cldr/cldr-aux/charts/34/supplemental/language_plural_rules.html)"
},
"theme.tags.tagsListLabel": {
"message": "태그:",
"description": "The label alongside a tag list"
},
"theme.blog.post.readMore": {
"message": "더 읽기",
"description": "The label used in blog post item excerpts to link to full blog posts"
},
"theme.blog.post.paginator.navAriaLabel": {
"message": "블로그 게시글 페이지 내비게이션",
"description": "The ARIA label for the blog posts pagination"
},
"theme.blog.post.paginator.newerPost": {
"message": "다음 글",
"description": "The blog post button label to navigate to the newer/previous post"
},
"theme.blog.post.paginator.olderPost": {
"message": "이전 글",
"description": "The blog post button label to navigate to the older/next post"
},
"theme.blog.post.plurals": {
"message": "게시글 {count}개",
"description": "Pluralized label for \"{count} posts\". Use as much plural forms (separated by \"|\") as your language support (see https://www.unicode.org/cldr/cldr-aux/charts/34/supplemental/language_plural_rules.html)"
},
"theme.blog.tagTitle": {
"message": "\"{tagName}\" 태그가 지정된 {nPosts}",
"description": "The title of the page for a blog tag"
},
"theme.tags.tagsPageLink": {
"message": "모든 태그 보기",
"description": "The label of the link targeting the tag list page"
},
"theme.CodeBlock.copyButtonAriaLabel": {
"message": "코드를 클립보드에 복사",
"description": "The ARIA label for copy code blocks button"
},
"theme.CodeBlock.copied": {
"message": "복사됨",
"description": "The copied button label on code blocks"
},
"theme.CodeBlock.copy": {
"message": "복사",
"description": "The copy button label on code blocks"
},
"theme.docs.sidebar.expandButtonTitle": {
"message": "사이드바 펼치기",
"description": "The ARIA label and title attribute for expand button of doc sidebar"
},
"theme.docs.sidebar.expandButtonAriaLabel": {
"message": "사이드바 펼치기",
"description": "The ARIA label and title attribute for expand button of doc sidebar"
},
"theme.docs.paginator.navAriaLabel": {
"message": "문서 페이지 내비게이션",
"description": "The ARIA label for the docs pagination"
},
"theme.docs.paginator.previous": {
"message": "이전",
"description": "The label used to navigate to the previous doc"
},
"theme.docs.paginator.next": {
"message": "다음",
"description": "The label used to navigate to the next doc"
},
"theme.docs.sidebar.collapseButtonTitle": {
"message": "사이드바 접기",
"description": "The title attribute for collapse button of doc sidebar"
},
"theme.docs.sidebar.collapseButtonAriaLabel": {
"message": "사이드바 접기",
"description": "The title attribute for collapse button of doc sidebar"
},
"theme.docs.versions.unreleasedVersionLabel": {
"message": "이 문서는 아직 출시되지 않은 {siteTitle} {versionLabel} 버전에 대한 것입니다.",
"description": "The label used to tell the user that he's browsing an unreleased doc version"
},
"theme.docs.versions.unmaintainedVersionLabel": {
"message": "이 문서는 더 이상 관리되지 않는 {siteTitle} {versionLabel} 버전에 대한 것입니다.",
"description": "The label used to tell the user that he's browsing an unmaintained doc version"
},
"theme.docs.versions.latestVersionSuggestionLabel": {
"message": "최신 문서는 {latestVersionLink}({versionLabel})을 참고하세요.",
"description": "The label used to tell the user to check the latest version"
},
"theme.docs.versions.latestVersionLinkLabel": {
"message": "최신 버전",
"description": "The label used for the latest version suggestion link label"
},
"theme.common.editThisPage": {
"message": "이 페이지 수정하기",
"description": "The link label to edit the current page"
},
"theme.common.headingLinkTitle": {
"message": "제목으로 바로가기 링크",
"description": "Title for link to heading"
},
"theme.lastUpdated.atDate": {
"message": " {date}에",
"description": "The words used to describe on which date a page has been last updated"
},
"theme.lastUpdated.byUser": {
"message": " {user}에 의해",
"description": "The words used to describe by who the page has been last updated"
},
"theme.lastUpdated.lastUpdatedAtBy": {
"message": "마지막 업데이트{atDate}{byUser}",
"description": "The sentence used to display when a page has been last updated, and by who"
},
"theme.common.skipToMainContent": {
"message": "본문으로 건너뛰기",
"description": "The skip to content label used for accessibility, allowing to rapidly navigate to main content with keyboard tab/enter navigation"
},
"theme.SearchPage.documentsFound.plurals": {
"message": "문서 {count}개 검색됨",
"description": "Pluralized label for \"{count} documents found\". Use as much plural forms (separated by \"|\") as your language support (see https://www.unicode.org/cldr/cldr-aux/charts/34/supplemental/language_plural_rules.html)"
},
"theme.SearchPage.existingResultsTitle": {
"message": "\"{query}\" 검색 결과",
"description": "The search page title for non-empty query"
},
"theme.SearchPage.emptyResultsTitle": {
"message": "문서 검색",
"description": "The search page title for empty query"
},
"theme.SearchPage.inputPlaceholder": {
"message": "검색어를 입력하세요",
"description": "The placeholder for search page input"
},
"theme.SearchPage.inputLabel": {
"message": "검색",
"description": "The ARIA label for search page input"
},
"theme.SearchPage.algoliaLabel": {
"message": "Algolia 검색",
"description": "The ARIA label for Algolia mention"
},
"theme.SearchPage.noResultsText": {
"message": "검색 결과가 없습니다",
"description": "The paragraph for empty search result"
},
"theme.SearchPage.fetchingNewResults": {
"message": "새 검색 결과를 가져오는 중...",
"description": "The paragraph for fetching new search results"
},
"theme.SearchBar.label": {
"message": "검색",
"description": "The ARIA label and placeholder for search button"
},
"theme.PwaReloadPopup.info": {
"message": "새 버전이 있습니다",
"description": "The text for PWA reload popup"
},
"theme.PwaReloadPopup.refreshButtonText": {
"message": "새로고침",
"description": "The text for PWA reload button"
},
"theme.PwaReloadPopup.closeButtonAriaLabel": {
"message": "닫기",
"description": "The ARIA label for close button of PWA reload popup"
},
"theme.Playground.result": {
"message": "결과",
"description": "The result label of the live codeblocks"
},
"theme.Playground.liveEditor": {
"message": "라이브 에디터",
"description": "The live editor label of the live codeblocks"
},
"convenient": {
"message": "쉬운 사용성"
},
"custom-multi-support": {
"message": "고성능 & 높은 확장성"
},
"opensource": {
"message": "오픈소스 & 커뮤니티 주도"
},
"theme.ErrorPageContent.title": {
"message": "이 페이지에서 오류가 발생했습니다.",
"description": "The title of the fallback page when the page crashed"
},
"theme.ErrorPageContent.tryAgain": {
"message": "다시 시도",
"description": "The label of the button to try again when the page crashed"
},
"theme.BackToTopButton.buttonAriaLabel": {
"message": "맨 위로 스크롤",
"description": "The ARIA label for the back to top button"
},
"theme.blog.archive.title": {
"message": "아카이브",
"description": "The page & hero title of the blog archive page"
},
"theme.blog.archive.description": {
"message": "아카이브",
"description": "The page & hero description of the blog archive page"
},
"theme.blog.sidebar.navAriaLabel": {
"message": "블로그 최근 게시글 내비게이션",
"description": "The ARIA label for recent posts in the blog sidebar"
},
"theme.DocSidebarItem.toggleCollapsedCategoryAriaLabel": {
"message": "'{label}' 사이드바 카테고리 접기/펼치기",
"description": "The ARIA label to toggle the collapsible sidebar category"
},
"theme.docs.tagDocListPageTitle.nDocsTagged": {
"message": "문서 {count}개",
"description": "Pluralized label for \"{count} docs tagged\". Use as much plural forms (separated by \"|\") as your language support (see https://www.unicode.org/cldr/cldr-aux/charts/34/supplemental/language_plural_rules.html)"
},
"theme.docs.tagDocListPageTitle": {
"message": "\"{tagName}\" 태그가 지정된 {nDocsTagged}",
"description": "The title of the page for a docs tag"
},
"theme.navbar.mobileSidebarSecondaryMenu.backButtonLabel": {
"message": "← 메인 메뉴로 돌아가기",
"description": "The label of the back button to return to main menu, inside the mobile navbar sidebar secondary menu (notably used to display the docs sidebar)"
},
"theme.navbar.mobileVersionsDropdown.label": {
"message": "버전",
"description": "The label for the navbar versions dropdown on mobile view"
},
"theme.TOCCollapsible.toggleButtonLabel": {
"message": "이 페이지에서",
"description": "The label used by the button on the collapsible TOC component"
},
"convenient-content": {
"message": "{docker} {br} 올인원 모니터링, 알람, 알림 시스템. 웹 서비스, 데이터베이스, 캐시, OS, 미들웨어, 빅데이터, 클라우드 네이티브, 네트워크 모니터링을 지원합니다. {br} 에이전트리스 방식으로 웹 화면에서 클릭 몇 번만으로 시작할 수 있습니다. {br} 모든 민감 데이터에 대해 종단 간 암호화를 적용한 엔터프라이즈급 보안을 제공합니다."
},
"custom-multi-support-content": {
"message": "HTTP, JMX, SSH, SNMP, JDBC 등 설정 가능한 프로토콜. 간단한 온라인 YAML 설정으로 커스텀 메트릭을 정의할 수 있습니다. {br} 수집기 클러스터의 수평 확장, 다중 네트워크 격리, 클라우드-엣지 협업을 지원하는 고성능 아키텍처입니다. {br} Discord, Slack, 이메일, Webhook 등을 통한 유연하고 신속한 알람 규칙 및 알림을 제공합니다."
},
"opensource-content": {
"message": "Apache HertzBeat™는 포용적인 커뮤니티를 가진 오픈소스 프로젝트입니다. 코드, 문서, 버그 리포트 등 규모에 상관없이 모든 기여를 환영합니다. {br} 모든 기여자를 존중하며 함께 성장합니다. {br} 전 세계 수천 대의 서버에 배포되어 있습니다."
},
"slogan": {
"message": "AI 기반 오픈소스 옵저버빌리티 시스템"
},
"Who uses HertzBeat?": {
"message": "Apache HertzBeat는 누가 사용하나요?"
},
"theme.admonition.note": {
"message": "참고",
"description": "The default label used for the Note admonition (:::note)"
},
"theme.admonition.tip": {
"message": "팁",
"description": "The default label used for the Tip admonition (:::tip)"
},
"theme.admonition.danger": {
"message": "위험",
"description": "The default label used for the Danger admonition (:::danger)"
},
"theme.admonition.info": {
"message": "정보",
"description": "The default label used for the Info admonition (:::info)"
},
"theme.admonition.caution": {
"message": "주의",
"description": "The default label used for the Caution admonition (:::caution)"
},
"theme.colorToggle.ariaLabel": {
"message": "다크·라이트 모드 전환(현재 {mode})",
"description": "The ARIA label for the navbar color mode toggle"
},
"theme.colorToggle.ariaLabel.mode.dark": {
"message": "다크 모드",
"description": "The name for the dark color mode"
},
"theme.colorToggle.ariaLabel.mode.light": {
"message": "라이트 모드",
"description": "The name for the light color mode"
},
"theme.docs.breadcrumbs.home": {
"message": "홈페이지",
"description": "The ARIA label for the home page in the breadcrumbs"
},
"theme.docs.breadcrumbs.navAriaLabel": {
"message": "이동 경로",
"description": "The ARIA label for the breadcrumbs"
},
"theme.docs.DocCard.categoryDescription": {
"message": "{count}개 항목",
"description": "The default description for a category card in the generated index about how many items this category includes"
},
"theme.docs.versionBadge.label": {
"message": "버전: {versionLabel}"
},
"theme.CodeBlock.wordWrapToggle": {
"message": "줄바꿈 전환",
"description": "The title attribute for toggle word wrapping button of code block lines"
},
"theme.navbar.mobileLanguageDropdown.label": {
"message": "언어",
"description": "The label for the mobile language switcher dropdown"
},
"theme.blog.post.readMoreLabel": {
"message": "{title}에 대해 더 읽기",
"description": "The ARIA label for the link to full blog posts from excerpts"
},
"theme.SearchBar.seeAll": {
"message": "{count}개 결과 모두 보기"
},
"theme.SearchModal.searchBox.resetButtonTitle": {
"message": "검색어 지우기",
"description": "The label and ARIA label for search box reset button"
},
"theme.SearchModal.searchBox.cancelButtonText": {
"message": "취소",
"description": "The label and ARIA label for search box cancel button"
},
"theme.SearchModal.startScreen.recentSearchesTitle": {
"message": "최근 검색",
"description": "The title for recent searches"
},
"theme.SearchModal.startScreen.noRecentSearchesText": {
"message": "최근 검색 기록이 없습니다",
"description": "The text when no recent searches"
},
"theme.SearchModal.startScreen.saveRecentSearchButtonTitle": {
"message": "이 검색 저장",
"description": "The label for save recent search button"
},
"theme.SearchModal.startScreen.removeRecentSearchButtonTitle": {
"message": "이 검색을 기록에서 삭제",
"description": "The label for remove recent search button"
},
"theme.SearchModal.startScreen.favoriteSearchesTitle": {
"message": "즐겨찾기",
"description": "The title for favorite searches"
},
"theme.SearchModal.startScreen.removeFavoriteSearchButtonTitle": {
"message": "이 검색을 즐겨찾기에서 삭제",
"description": "The label for remove favorite search button"
},
"theme.SearchModal.errorScreen.titleText": {
"message": "결과를 가져올 수 없습니다",
"description": "The title for error screen of search modal"
},
"theme.SearchModal.errorScreen.helpText": {
"message": "네트워크 연결 상태를 확인해 보세요.",
"description": "The help text for error screen of search modal"
},
"theme.SearchModal.footer.selectText": {
"message": "선택",
"description": "The explanatory text of the action for the enter key"
},
"theme.SearchModal.footer.selectKeyAriaLabel": {
"message": "Enter 키",
"description": "The ARIA label for the Enter key button that makes the selection"
},
"theme.SearchModal.footer.navigateText": {
"message": "이동",
"description": "The explanatory text of the action for the Arrow up and Arrow down key"
},
"theme.SearchModal.footer.navigateUpKeyAriaLabel": {
"message": "위쪽 화살표",
"description": "The ARIA label for the Arrow up key button that makes the navigation"
},
"theme.SearchModal.footer.navigateDownKeyAriaLabel": {
"message": "아래쪽 화살표",
"description": "The ARIA label for the Arrow down key button that makes the navigation"
},
"theme.SearchModal.footer.closeText": {
"message": "닫기",
"description": "The explanatory text of the action for Escape key"
},
"theme.SearchModal.footer.closeKeyAriaLabel": {
"message": "Esc 키",
"description": "The ARIA label for the Escape key button that close the modal"
},
"theme.SearchModal.footer.searchByText": {
"message": "검색 제공",
"description": "The text explain that the search is making by Algolia"
},
"theme.SearchModal.noResultsScreen.noResultsText": {
"message": "검색 결과 없음:",
"description": "The text explains that there are no results for the following search"
},
"theme.SearchModal.noResultsScreen.suggestedQueryText": {
"message": "다음 검색어를 시도해 보세요:",
"description": "The text for the suggested query when no results are found for the following search"
},
"theme.SearchModal.noResultsScreen.reportMissingResultsText": {
"message": "이 검색어에 결과가 있어야 한다고 생각하시나요?",
"description": "The text for the question where the user thinks there are missing results"
},
"theme.SearchModal.noResultsScreen.reportMissingResultsLinkText": {
"message": "알려주세요.",
"description": "The text for the link to report missing results"
},
"theme.SearchModal.placeholder": {
"message": "문서 검색",
"description": "The placeholder of the input of the DocSearch pop-up modal"
},
"theme.IdealImageMessage.loading": {
"message": "로딩 중...",
"description": "When the full-scale image is loading"
},
"theme.IdealImageMessage.load": {
"message": "클릭하여 로드{sizeMessage}",
"description": "To prompt users to load the full image. sizeMessage is a parenthesized size figure."
},
"theme.IdealImageMessage.offline": {
"message": "오프라인 상태입니다. 이미지를 불러올 수 없습니다",
"description": "When the user is viewing an offline document"
},
"theme.IdealImageMessage.404error": {
"message": "404. 이미지를 찾을 수 없습니다",
"description": "When the image is not found"
},
"theme.IdealImageMessage.error": {
"message": "오류가 발생했습니다. 클릭하여 다시 로드",
"description": "When the image fails to load for unknown error"
},
"support hertzbeat": {
"message": "HertzBeat 후원하기"
},
"click": {
"message": "클릭"
},
"go github": {
"message": "Github로 이동"
},
"If you like HertzBeat, star us on GitHub": {
"message": "HertzBeat가 마음에 드셨다면 GitHub에서 Star를 눌러주세요."
},
"We will grow better with your support.": {
"message": "여러분의 응원과 함께 더 크게 성장하겠습니다. 가장 큰 힘이 됩니다."
},
"quickstart": {
"message": "빠른 시작"
},
"Support HertzBeat": {
"message": "HertzBeat 후원하기"
},
"Go to Github": {
"message": "Github로 이동"
},
"theme.NavBar.navAriaLabel": {
"message": "메인",
"description": "The ARIA label for the main navigation"
},
"theme.docs.sidebar.navAriaLabel": {
"message": "문서 사이드바",
"description": "The ARIA label for the sidebar navigation"
},
"theme.docs.sidebar.closeSidebarButtonAriaLabel": {
"message": "내비게이션 바 닫기",
"description": "The ARIA label for close button of mobile sidebar"
},
"theme.docs.sidebar.toggleSidebarButtonAriaLabel": {
"message": "내비게이션 바 전환",
"description": "The ARIA label for hamburger menu button of mobile navigation"
},
"theme.tags.tagsPageTitle": {
"message": "태그",
"description": "The title of the tag list page"
},
"team.name": {
"message": "Apache HertzBeat™ 팀"
},
"team.desc": {
"message": "HertzBeat 팀은 전 세계 다양한 분야의 기여자들로 구성되어 있습니다. 우리는 개방과 협업이라는 커뮤니티 철학을 존중하며, 더 많은 분들이 함께 참여하고👏 커뮤니티와 함께 성장하기를 환영합니다."
},
"team.tip": {
"message": "(특정 순서 없음)"
},
"team.thanks": {
"message": "HertzBeat에 기여해주신 멋진 분들께 감사드립니다!"
}
}
@@ -0,0 +1,14 @@
{
"title": {
"message": "블로그",
"description": "The title for the blog used in SEO"
},
"description": {
"message": "블로그",
"description": "The description for the blog used in SEO"
},
"sidebar.title": {
"message": "최근 게시글",
"description": "The label for the left sidebar"
}
}
@@ -0,0 +1,166 @@
{
"version.label": {
"message": "dev",
"description": "The label for version current"
},
"sidebar.docs.category.quickstart": {
"message": "빠른 시작",
"description": "The label for category quickstart in sidebar docs"
},
"sidebar.docs.category.custom": {
"message": "커스텀 모니터링",
"description": "The label for category custom in sidebar docs"
},
"sidebar.docs.category.http": {
"message": "HTTP 프로토콜",
"description": "The label for category http in sidebar docs"
},
"sidebar.docs.category.jdbc": {
"message": "JDBC 프로토콜",
"description": "The label for category jdbc in sidebar docs"
},
"sidebar.docs.category.ssh": {
"message": "SSH 프로토콜",
"description": "The label for category ssh in sidebar docs"
},
"sidebar.docs.category.telnet": {
"message": "TELNET 프로토콜",
"description": "The label for category telnet in sidebar docs"
},
"sidebar.docs.category.guides": {
"message": "사용 가이드",
"description": "The label for category guides in sidebar docs"
},
"sidebar.docs.category.service": {
"message": "서비스 모니터링",
"description": "The label for category service in sidebar docs"
},
"sidebar.docs.category.database": {
"message": "데이터베이스 모니터링",
"description": "The label for category database in sidebar docs"
},
"sidebar.docs.category.os": {
"message": "OS 모니터링",
"description": "The label for category os in sidebar docs"
},
"sidebar.docs.category.server": {
"message": "서버 모니터링",
"description": "The label for category server in sidebar docs"
},
"sidebar.docs.category.alert": {
"message": "알람",
"description": "The label for category alert in sidebar docs"
},
"sidebar.docs.category.notice": {
"message": "알림",
"description": "The label for category notice in sidebar docs"
},
"sidebar.docs.category.service-discovery": {
"message": "서비스 디스커버리",
"description": "The label for category service-discovery in sidebar docs"
},
"sidebar.docs.category.mid": {
"message": "미들웨어 모니터링",
"description": "The label for category mid in sidebar docs"
},
"sidebar.docs.category.Others": {
"message": "기타",
"description": "The label for category Others in sidebar docs"
},
"sidebar.docs.category.cloud-native": {
"message": "클라우드 네이티브 모니터링",
"description": "The label for category cloud-native in sidebar docs"
},
"sidebar.docs.category.jmx": {
"message": "JMX 프로토콜",
"description": "The label for category jmx in sidebar docs"
},
"sidebar.docs.category.snmp": {
"message": "SNMP 프로토콜",
"description": "The label for category snmp in sidebar docs"
},
"sidebar.docs.link.Install via Helm": {
"message": "Helm으로 설치",
"description": "The label for link Install via Helm in sidebar docs, linking to https://artifacthub.io/packages/helm/hertzbeat/hertzbeat"
},
"sidebar.docs.category.push": {
"message": "Push",
"description": "The label for category push in sidebar docs"
},
"sidebar.docs.category.program": {
"message": "애플리케이션 모니터링",
"description": "The label for category program in sidebar docs"
},
"sidebar.docs.category.cache": {
"message": "캐시 모니터링",
"description": "The label for category cache in sidebar docs"
},
"sidebar.docs.category.bigdata": {
"message": "빅데이터 모니터링",
"description": "The label for category bigdata in sidebar docs"
},
"sidebar.docs.category.webserver": {
"message": "웹서버 모니터링",
"description": "The label for category webserver in sidebar docs"
},
"sidebar.docs.category.contribution": {
"message": "기여 가이드",
"description": "The label for category contribution in sidebar docs"
},
"sidebar.docs.category.submit": {
"message": "제출 가이드",
"description": "The label for category submit in sidebar docs"
},
"sidebar.docs.category.release": {
"message": "릴리스 가이드",
"description": "The label for category release in sidebar docs"
},
"sidebar.docs.category.Community": {
"message": "커뮤니티",
"description": "The label for category Community in sidebar docs"
},
"sidebar.docs.category.network": {
"message": "네트워크 모니터링",
"description": "The label for category network in sidebar docs"
},
"sidebar.docs.category.NGQL": {
"message": "NGQL",
"description": "The label for category NGQL in sidebar docs"
},
"sidebar.docs.category.llm": {
"message": "AI LLM 모니터링",
"description": "The label for category llm in sidebar docs"
},
"sidebar.docs.category.aiConfig": {
"message": "AI LLM 설정",
"description": "The label for category aiConfig in sidebar docs"
},
"sidebar.docs.category.install": {
"message": "설치",
"description": "The label for category install in sidebar docs"
},
"sidebar.docs.category.upgrade": {
"message": "업그레이드",
"description": "The label for category upgrade in sidebar docs"
},
"sidebar.docs.category.setting": {
"message": "설정",
"description": "The label for category setting in sidebar docs"
},
"sidebar.docs.category.change-db": {
"message": "DB 변경",
"description": "The label for category change-db in sidebar docs"
},
"sidebar.docs.category.use-case": {
"message": "사용 사례",
"description": "The label for category use-case in sidebar docs"
},
"sidebar.docs.category.extern-deploy": {
"message": "외부 설치",
"description": "The label for category extern-deploy in sidebar docs"
},
"sidebar.docs.category.log": {
"message": "로그",
"description": "The label for category log in sidebar docs"
}
}
@@ -0,0 +1,166 @@
{
"version.label": {
"message": "1.8.0",
"description": "The label for version current"
},
"sidebar.docs.category.quickstart": {
"message": "빠른 시작",
"description": "The label for category quickstart in sidebar docs"
},
"sidebar.docs.category.custom": {
"message": "커스텀 모니터링",
"description": "The label for category custom in sidebar docs"
},
"sidebar.docs.category.http": {
"message": "HTTP 프로토콜",
"description": "The label for category http in sidebar docs"
},
"sidebar.docs.category.jdbc": {
"message": "JDBC 프로토콜",
"description": "The label for category jdbc in sidebar docs"
},
"sidebar.docs.category.ssh": {
"message": "SSH 프로토콜",
"description": "The label for category ssh in sidebar docs"
},
"sidebar.docs.category.telnet": {
"message": "TELNET 프로토콜",
"description": "The label for category telnet in sidebar docs"
},
"sidebar.docs.category.guides": {
"message": "사용 가이드",
"description": "The label for category guides in sidebar docs"
},
"sidebar.docs.category.service": {
"message": "서비스 모니터링",
"description": "The label for category service in sidebar docs"
},
"sidebar.docs.category.database": {
"message": "데이터베이스 모니터링",
"description": "The label for category database in sidebar docs"
},
"sidebar.docs.category.os": {
"message": "OS 모니터링",
"description": "The label for category os in sidebar docs"
},
"sidebar.docs.category.server": {
"message": "서버 모니터링",
"description": "The label for category server in sidebar docs"
},
"sidebar.docs.category.alert": {
"message": "알람",
"description": "The label for category alert in sidebar docs"
},
"sidebar.docs.category.notice": {
"message": "알림",
"description": "The label for category notice in sidebar docs"
},
"sidebar.docs.category.service-discovery": {
"message": "서비스 디스커버리",
"description": "The label for category service-discovery in sidebar docs"
},
"sidebar.docs.category.mid": {
"message": "미들웨어 모니터링",
"description": "The label for category mid in sidebar docs"
},
"sidebar.docs.category.Others": {
"message": "기타",
"description": "The label for category Others in sidebar docs"
},
"sidebar.docs.category.cloud-native": {
"message": "클라우드 네이티브 모니터링",
"description": "The label for category cloud-native in sidebar docs"
},
"sidebar.docs.category.jmx": {
"message": "JMX 프로토콜",
"description": "The label for category jmx in sidebar docs"
},
"sidebar.docs.category.snmp": {
"message": "SNMP 프로토콜",
"description": "The label for category snmp in sidebar docs"
},
"sidebar.docs.link.Install via Helm": {
"message": "Helm으로 설치",
"description": "The label for link Install via Helm in sidebar docs, linking to https://artifacthub.io/packages/helm/hertzbeat/hertzbeat"
},
"sidebar.docs.category.push": {
"message": "Push",
"description": "The label for category push in sidebar docs"
},
"sidebar.docs.category.program": {
"message": "애플리케이션 모니터링",
"description": "The label for category program in sidebar docs"
},
"sidebar.docs.category.cache": {
"message": "캐시 모니터링",
"description": "The label for category cache in sidebar docs"
},
"sidebar.docs.category.bigdata": {
"message": "빅데이터 모니터링",
"description": "The label for category bigdata in sidebar docs"
},
"sidebar.docs.category.webserver": {
"message": "웹서버 모니터링",
"description": "The label for category webserver in sidebar docs"
},
"sidebar.docs.category.contribution": {
"message": "기여 가이드",
"description": "The label for category contribution in sidebar docs"
},
"sidebar.docs.category.submit": {
"message": "제출 가이드",
"description": "The label for category submit in sidebar docs"
},
"sidebar.docs.category.release": {
"message": "릴리스 가이드",
"description": "The label for category release in sidebar docs"
},
"sidebar.docs.category.Community": {
"message": "커뮤니티",
"description": "The label for category Community in sidebar docs"
},
"sidebar.docs.category.network": {
"message": "네트워크 모니터링",
"description": "The label for category network in sidebar docs"
},
"sidebar.docs.category.NGQL": {
"message": "NGQL",
"description": "The label for category NGQL in sidebar docs"
},
"sidebar.docs.category.llm": {
"message": "AI LLM 모니터링",
"description": "The label for category llm in sidebar docs"
},
"sidebar.docs.category.aiConfig": {
"message": "AI LLM 설정",
"description": "The label for category aiConfig in sidebar docs"
},
"sidebar.docs.category.install": {
"message": "설치",
"description": "The label for category install in sidebar docs"
},
"sidebar.docs.category.upgrade": {
"message": "업그레이드",
"description": "The label for category upgrade in sidebar docs"
},
"sidebar.docs.category.setting": {
"message": "설정",
"description": "The label for category setting in sidebar docs"
},
"sidebar.docs.category.change-db": {
"message": "DB 변경",
"description": "The label for category change-db in sidebar docs"
},
"sidebar.docs.category.use-case": {
"message": "사용 사례",
"description": "The label for category use-case in sidebar docs"
},
"sidebar.docs.category.extern-deploy": {
"message": "외부 설치",
"description": "The label for category extern-deploy in sidebar docs"
},
"sidebar.docs.category.log": {
"message": "로그",
"description": "The label for category log in sidebar docs"
}
}
@@ -0,0 +1,50 @@
{
"link.title.intro": {
"message": "소개",
"description": "The title of the footer links column with title=intro in the footer"
},
"link.title.contact": {
"message": "커뮤니티",
"description": "The title of the footer links column with title=contact in the footer"
},
"link.item.label.use": {
"message": "소개",
"description": "The label of footer link with label=use linking to /docs/"
},
"link.item.label.quickstart": {
"message": "빠른 시작",
"description": "The label of footer link with label=quickstart linking to /docs/start/quickstart"
},
"link.item.label.custom": {
"message": "커스텀 모니터링",
"description": "The label of footer link with label=custom linking to /docs/advanced/extend-point"
},
"link.item.label.help": {
"message": "도움말 문서",
"description": "The label of footer link with label=help linking to /docs/help/guide"
},
"link.item.label.discuss": {
"message": "문의",
"description": "The label of footer link with label=discuss linking to /docs/others/contact"
},
"link.item.label.github": {
"message": "Github",
"description": "The label of footer link with label=github linking to https://github.com/apache/hertzbeat"
},
"link.title.resource": {
"message": "자료",
"description": "The title of the footer links column with title=resource in the footer"
},
"link.item.label.relate": {
"message": "기타",
"description": "The label of footer link with label=relate linking to /docs/others/resource"
},
"link.item.label.discussion": {
"message": "토론",
"description": "The label of footer link with label=discussion linking to https://github.com/apache/hertzbeat/discussions/"
},
"link.item.label.contact": {
"message": "문의",
"description": "The label of footer link with label=contact linking to /docs/community/contact"
}
}
@@ -0,0 +1,162 @@
{
"title": {
"message": "HertzBeat",
"description": "The title in the navbar"
},
"item.label.main": {
"message": "홈",
"description": "Navbar item with label main"
},
"item.label.document": {
"message": "문서",
"description": "Navbar item with label document"
},
"item.label.blog": {
"message": "블로그",
"description": "Navbar item with label blog"
},
"item.label.discuss": {
"message": "커뮤니티 문의",
"description": "Navbar item with label discuss"
},
"item.label.kanban": {
"message": "칸반",
"description": "Navbar item with label kanban"
},
"item.label.about": {
"message": "소개",
"description": "Navbar item with label about"
},
"item.label.login": {
"message": "지금 로그인",
"description": "Navbar item with label login"
},
"item.label.design": {
"message": "설계",
"description": "Navbar item with label design"
},
"item.label.resource": {
"message": "자료",
"description": "Navbar item with label resource"
},
"item.label.contributing": {
"message": "기여자 가이드",
"description": "Navbar item with label contributing"
},
"item.label.developer": {
"message": "기여자",
"description": "Navbar item with label developer"
},
"item.label.enterprise": {
"message": "엔터프라이즈",
"description": "Navbar item with label enterprise"
},
"item.label.Docs": {
"message": "문서",
"description": "Navbar item with label Docs"
},
"item.label.Others": {
"message": "기타",
"description": "Navbar item with label Others"
},
"item.label.contributors": {
"message": "기여자",
"description": "Navbar item with label contributors"
},
"item.label.ASF": {
"message": "ASF",
"description": "Navbar item with label ASF"
},
"item.label.Community": {
"message": "커뮤니티",
"description": "Navbar item with label Community"
},
"item.label.code_of_conduct": {
"message": "행동 강령",
"description": "Navbar item with label code_of_conduct"
},
"item.label.join_the_mailing_lists": {
"message": "메일링 리스트 가입",
"description": "Navbar item with label join_the_mailing_lists"
},
"item.label.become_committer": {
"message": "커미터 되기",
"description": "Navbar item with label become_committer"
},
"item.label.become_pmc_member": {
"message": "PMC 멤버 되기",
"description": "Navbar item with label become_pmc_member"
},
"item.label.new_committer_process": {
"message": "신규 커미터 절차",
"description": "Navbar item with label new_committer_process"
},
"item.label.new_pmc_member_process": {
"message": "신규 PMC 멤버 절차",
"description": "Navbar item with label new_pmc_member_process"
},
"item.label.documentation_notice": {
"message": "문서 안내",
"description": "Navbar item with label documentation_notice"
},
"item.label.submit_code": {
"message": "코드 제출",
"description": "Navbar item with label submit_code"
},
"item.label.code_style_and_quality_guide": {
"message": "코드 스타일 및 품질 가이드",
"description": "Navbar item with label code_style_and_quality_guide"
},
"item.label.how_to_release": {
"message": "릴리스 방법",
"description": "Navbar item with label how_to_release"
},
"item.label.how_to_verify_release": {
"message": "릴리스 검증 방법",
"description": "Navbar item with label how_to_verify_release"
},
"item.label.Foundation": {
"message": "재단",
"description": "Navbar item with label Foundation"
},
"item.label.License": {
"message": "라이선스",
"description": "Navbar item with label License"
},
"item.label.Events": {
"message": "이벤트",
"description": "Navbar item with label Events"
},
"item.label.Security": {
"message": "보안",
"description": "Navbar item with label Security"
},
"item.label.Sponsorship": {
"message": "후원",
"description": "Navbar item with label Sponsorship"
},
"item.label.Privacy": {
"message": "개인정보처리방침",
"description": "Navbar item with label Privacy"
},
"item.label.team": {
"message": "팀",
"description": "Navbar item with label team"
},
"item.label.Thanks": {
"message": "감사의 말",
"description": "Navbar item with label Thanks"
},
"item.label.download": {
"message": "다운로드",
"description": "Navbar item with label download"
},
"item.label.contribution": {
"message": "기여 가이드",
"description": "Navbar item with label contribution"
},
"item.label.development": {
"message": "개발",
"description": "Navbar item with label development"
}
}
@@ -2983,5 +2983,231 @@
"code"
],
"githubId": "183058331"
},
{
"login": "orangeCatDeveloper",
"name": "NekoPunch",
"avatar_url": "https://avatars.githubusercontent.com/u/95899648?v=4",
"profile": "https://github.com/orangeCatDeveloper",
"contributions": [
"code",
"doc"
],
"githubId": "95899648"
},
{
"login": "wy471x",
"name": "wy471x",
"avatar_url": "https://avatars.githubusercontent.com/u/52033069?v=4",
"profile": "https://wy471x.github.io/",
"contributions": [
"code"
],
"githubId": "52033069"
},
{
"login": "hengyuss",
"name": "hengyuss",
"avatar_url": "https://avatars.githubusercontent.com/u/81064732?v=4",
"profile": "https://github.com/hengyuss",
"contributions": [
"code"
],
"githubId": "81064732"
},
{
"login": "moduvoice",
"name": "moduvoice",
"avatar_url": "https://avatars.githubusercontent.com/u/291867022?v=4",
"profile": "https://moduvoice.com/",
"contributions": [
"doc"
],
"githubId": "291867022"
},
{
"login": "hutiefang76",
"name": "hutiefang76",
"avatar_url": "https://avatars.githubusercontent.com/u/137664623?v=4",
"profile": "https://github.com/hutiefang76",
"contributions": [
"code"
],
"githubId": "137664623"
},
{
"login": "ZhouYinLong-lab",
"name": "柳含知 Liu Hanzhi",
"avatar_url": "https://avatars.githubusercontent.com/u/250777154?v=4",
"profile": "https://zylatent.com/",
"contributions": [
"doc"
],
"githubId": "250777154"
},
{
"login": "wilmerdooley",
"name": "wilmerdooley",
"avatar_url": "https://avatars.githubusercontent.com/u/259930736?v=4",
"profile": "https://github.com/wilmerdooley",
"contributions": [
"code"
],
"githubId": "259930736"
},
{
"login": "Zmjjeff7",
"name": "Zhenhong Guo",
"avatar_url": "https://avatars.githubusercontent.com/u/175370943?v=4",
"profile": "https://github.com/Zmjjeff7",
"contributions": [
"code"
],
"githubId": "175370943"
},
{
"login": "abhyudayareddy",
"name": "abhyudayareddy",
"avatar_url": "https://avatars.githubusercontent.com/u/54602866?v=4",
"profile": "https://github.com/abhyudayareddy",
"contributions": [
"code"
],
"githubId": "54602866"
},
{
"login": "neon-hippo",
"name": "neon-hippo",
"avatar_url": "https://avatars.githubusercontent.com/u/165560498?v=4",
"profile": "https://github.com/neon-hippo",
"contributions": [
"code"
],
"githubId": "165560498"
},
{
"login": "P-Peaceful",
"name": "P_Peaceful",
"avatar_url": "https://avatars.githubusercontent.com/u/52856161?v=4",
"profile": "https://github.com/P-Peaceful",
"contributions": [
"code",
"doc"
],
"githubId": "52856161"
},
{
"login": "zhusaidong",
"name": "zhusaidong",
"avatar_url": "https://avatars.githubusercontent.com/u/3039961?v=4",
"profile": "https://github.com/zhusaidong",
"contributions": [
"code"
],
"githubId": "3039961"
},
{
"login": "zhehenlu",
"name": "zhlu",
"avatar_url": "https://avatars.githubusercontent.com/u/31504542?v=4",
"profile": "https://github.com/zhehenlu",
"contributions": [
"code",
"doc"
],
"githubId": "31504542"
},
{
"login": "brettgervasoni",
"name": "brettgervasoni",
"avatar_url": "https://avatars.githubusercontent.com/u/34056000?v=4",
"profile": "https://github.com/brettgervasoni",
"contributions": [
"code"
],
"githubId": "34056000"
},
{
"login": "Darshan-paul",
"name": "Darshan-paul",
"avatar_url": "https://avatars.githubusercontent.com/u/211450705?v=4",
"profile": "https://github.com/Darshan-paul",
"contributions": [
"code"
],
"githubId": "211450705"
},
{
"login": "04cb",
"name": "layla",
"avatar_url": "https://avatars.githubusercontent.com/u/111667698?v=4",
"profile": "https://github.com/04cb",
"contributions": [
"doc"
],
"githubId": "111667698"
},
{
"login": "miantalha45",
"name": "Talha Amjad",
"avatar_url": "https://avatars.githubusercontent.com/u/155809113?v=4",
"profile": "https://github.com/miantalha45",
"contributions": [
"code"
],
"githubId": "155809113"
},
{
"login": "turanalmammadov",
"name": "Turan Almammadov",
"avatar_url": "https://avatars.githubusercontent.com/u/16321061?v=4",
"profile": "https://turanalmammadov.com/",
"contributions": [
"code",
"doc"
],
"githubId": "16321061"
},
{
"login": "zhaoyangplus",
"name": "zhaoyangplus",
"avatar_url": "https://avatars.githubusercontent.com/u/245090302?v=4",
"profile": "https://github.com/zhaoyangplus",
"contributions": [
"code"
],
"githubId": "245090302"
},
{
"login": "yexuanyang",
"name": "Yang Yexuan",
"avatar_url": "https://avatars.githubusercontent.com/u/73885401?v=4",
"profile": "https://github.com/yexuanyang",
"contributions": [
"code"
],
"githubId": "73885401"
},
{
"login": "markguo123",
"name": "markguo123",
"avatar_url": "https://avatars.githubusercontent.com/u/155072651?v=4",
"profile": "https://github.com/markguo123",
"contributions": [
"doc"
],
"githubId": "155072651"
},
{
"login": "leo-934",
"name": "leo",
"avatar_url": "https://avatars.githubusercontent.com/u/55838224?v=4",
"profile": "https://github.com/leo-934",
"contributions": [
"code",
"doc",
"blog"
],
"githubId": "55838224"
}
]
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -42,7 +42,7 @@ LIMIT <1-100>
服务支持通过 `greptime.username``greptime.password` 发送 HTTP Basic Authentication。用户名和密码必须同时配置;建议配合 HTTPS 或可信内网使用。
项目当前 Docker Compose 使用 GreptimeDB `v0.14.3`,该版本只提供身份认证,不能限制用户为只读权限。因此当前真正生效的安全边界是“删除原始 SQL参数并固定生成单条 `SELECT`”。使用 GreptimeDB 1.0 及以上版本时,应为该 MCP 配置独立的 `ro`/`readonly` 账号
项目当前 Docker Compose 使用 GreptimeDB `v1.1.3`。应为该 MCP 配置独立的 `ro`/`readonly` 账号;同时,服务仍通过删除原始 SQL 参数并固定生成单条 `SELECT`,限制可执行查询范围
## Claude Desktop 集成(stdio
+9
View File
@@ -174,6 +174,7 @@
<influxdb.version>2.23</influxdb.version>
<spring-cloud-starter-openfeign.version>3.0.5</spring-cloud-starter-openfeign.version>
<greptimedb.version>0.11.0</greptimedb.version>
<grpc.version>1.76.3</grpc.version>
<arrow.version>18.1.0</arrow.version>
<snappy-java.version>1.1.10.7</snappy-java.version>
<sshd-sftp.version>2.13.1</sshd-sftp.version>
@@ -579,6 +580,14 @@
</exclusion>
</exclusions>
</dependency>
<!-- grpc: managed -->
<dependency>
<groupId>io.grpc</groupId>
<artifactId>grpc-bom</artifactId>
<version>${grpc.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
@@ -70,6 +70,7 @@ resourceRole:
- /api/mcp/**===post===[admin]
- /api/chat/**===get===[admin,user]
- /api/chat/**===post===[admin]
- /api/logs/sse/**===get===[admin,user,guest]
- /api/logs/ingest/**===post===[admin,user]
# config the resource restful api that need bypass auth protection
@@ -77,10 +78,10 @@ resourceRole:
# eg: /api/v1/source3===get means /api/v1/source3===get can be access by anyone, no need auth.
excludedResource:
- /api/alert/sse/**===*
- /api/logs/sse/**===*
- /api/account/auth/**===*
- /api/i18n/**===get
- /api/apps/hierarchy===get
- /api/observability/capability===get
- /api/push/**===*
- /api/status/page/public/**===*
- /api/manager/sse/**===*
@@ -70,6 +70,7 @@ resourceRole:
- /api/mcp/**===post===[admin]
- /api/chat/**===get===[admin,user]
- /api/chat/**===post===[admin]
- /api/logs/sse/**===get===[admin,user,guest]
- /api/logs/ingest/**===post===[admin,user]
# config the resource restful api that need bypass auth protection
@@ -77,10 +78,10 @@ resourceRole:
# eg: /api/v1/source3===get means /api/v1/source3===get can be access by anyone, no need auth.
excludedResource:
- /api/alert/sse/**===*
- /api/logs/sse/**===*
- /api/account/auth/**===*
- /api/i18n/**===get
- /api/apps/hierarchy===get
- /api/observability/capability===get
- /api/push/**===*
- /api/status/page/public/**===*
- /api/manager/sse/**===*
@@ -70,6 +70,7 @@ resourceRole:
- /api/mcp/**===post===[admin]
- /api/chat/**===get===[admin,user]
- /api/chat/**===post===[admin]
- /api/logs/sse/**===get===[admin,user,guest]
- /api/logs/ingest/**===post===[admin,user]
# config the resource restful api that need bypass auth protection
@@ -77,10 +78,10 @@ resourceRole:
# eg: /api/v1/source3===get means /api/v1/source3===get can be access by anyone, no need auth.
excludedResource:
- /api/alert/sse/**===*
- /api/logs/sse/**===*
- /api/account/auth/**===*
- /api/i18n/**===get
- /api/apps/hierarchy===get
- /api/observability/capability===get
- /api/push/**===*
- /api/status/page/public/**===*
- /api/manager/sse/**===*
@@ -70,6 +70,7 @@ resourceRole:
- /api/mcp/**===post===[admin]
- /api/chat/**===get===[admin,user]
- /api/chat/**===post===[admin]
- /api/logs/sse/**===get===[admin,user,guest]
- /api/logs/ingest/**===post===[admin,user]
- /api/otlp/**===post===[admin,user]
- /api/ingestion/otlp/**===get===[admin,user,guest]
@@ -81,10 +82,10 @@ resourceRole:
# eg: /api/v1/source3===get means /api/v1/source3===get can be access by anyone, no need auth.
excludedResource:
- /api/alert/sse/**===*
- /api/logs/sse/**===*
- /api/account/auth/**===*
- /api/i18n/**===get
- /api/apps/hierarchy===get
- /api/observability/capability===get
- /api/push/**===*
- /api/status/page/public/**===*
- /api/manager/sse/**===*
@@ -46,7 +46,7 @@ services:
- hertzbeat
greptime:
image: greptime/greptimedb:v0.14.3
image: greptime/greptimedb:v1.1.3
container_name: compose-greptimedb
hostname: greptime
restart: always
@@ -70,6 +70,7 @@ resourceRole:
- /api/mcp/**===post===[admin]
- /api/chat/**===get===[admin,user]
- /api/chat/**===post===[admin]
- /api/logs/sse/**===get===[admin,user,guest]
- /api/logs/ingest/**===post===[admin,user]
# config the resource restful api that need bypass auth protection
@@ -77,10 +78,10 @@ resourceRole:
# eg: /api/v1/source3===get means /api/v1/source3===get can be access by anyone, no need auth.
excludedResource:
- /api/alert/sse/**===*
- /api/logs/sse/**===*
- /api/account/auth/**===*
- /api/i18n/**===get
- /api/apps/hierarchy===get
- /api/observability/capability===get
- /api/push/**===*
- /api/status/page/public/**===*
- /api/manager/sse/**===*
+2 -1
View File
@@ -70,6 +70,7 @@ resourceRole:
- /api/mcp/**===post===[admin]
- /api/chat/**===get===[admin,user]
- /api/chat/**===post===[admin]
- /api/logs/sse/**===get===[admin,user,guest]
- /api/logs/ingest/**===post===[admin,user]
- /api/otlp/**===post===[admin,user]
- /api/ingestion/otlp/**===get===[admin,user,guest]
@@ -81,10 +82,10 @@ resourceRole:
# eg: /api/v1/source3===get means /api/v1/source3===get can be access by anyone, no need auth.
excludedResource:
- /api/alert/sse/**===*
- /api/logs/sse/**===*
- /api/account/auth/**===*
- /api/i18n/**===get
- /api/apps/hierarchy===get
- /api/observability/capability===get
- /api/push/**===*
- /api/status/page/public/**===*
- /api/manager/sse/**===*
+1
View File
@@ -1,4 +1,5 @@
{
"customSyntax": "postcss-less",
"extends": [
"stylelint-config-standard",
"stylelint-config-rational-order",
+2 -1
View File
@@ -35,7 +35,7 @@
"lint": "npm run lint:fix && npm run lint:style",
"lint:ts": "ng lint",
"lint:fix": "ng lint --fix",
"lint:style": "stylelint \"src/**/*.less\" --syntax less --fix",
"lint:style": "stylelint \"src/**/*.less\" --fix",
"package": "ng build --configuration production"
},
"private": true,
@@ -124,6 +124,7 @@
"lint-staged": "13.3.0",
"ng-alain": "17.3.1",
"node-fetch": "2.7.0",
"postcss-less": "6.0.0",
"prettier": "2.8.8",
"protractor": "7.0.0",
"source-map-explorer": "2.5.3",
+73 -49
View File
@@ -255,6 +255,9 @@ importers:
node-fetch:
specifier: 2.7.0
version: 2.7.0(encoding@0.1.13)
postcss-less:
specifier: 6.0.0
version: 6.0.0(postcss@8.5.6)
prettier:
specifier: 2.8.8
version: 2.8.8
@@ -1674,24 +1677,28 @@ packages:
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@nx/nx-linux-arm64-musl@19.8.14':
resolution: {integrity: sha512-ltty/PDWqkYgu/6Ye65d7v5nh3D6e0n3SacoKRs2Vtfz5oHYRUkSKizKIhEVfRNuHn3d9j8ve1fdcCN4SDPUBQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@nx/nx-linux-x64-gnu@19.8.14':
resolution: {integrity: sha512-JzE3BuO9RCBVdgai18CCze6KUzG0AozE0TtYFxRokfSC05NU3nUhd/o62UsOl7s6Bqt/9nwrW7JC8pNDiCi9OQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@nx/nx-linux-x64-musl@19.8.14':
resolution: {integrity: sha512-2rPvDOQLb7Wd6YiU88FMBiLtYco0dVXF99IJBRGAWv+WTI7MNr47OyK2ze+JOsbYY1d8aOGUvckUvCCZvZKEfg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@nx/nx-win32-arm64-msvc@19.8.14':
resolution: {integrity: sha512-JxW+YPS+EjhUsLw9C6wtk9pQTG3psyFwxhab8y/dgk2s4AOTLyIm0XxgcCJVvB6i4uv+s1g0QXRwp6+q3IR6hg==}
@@ -1743,66 +1750,79 @@ packages:
resolution: {integrity: sha512-Rn3n+FUk2J5VWx+ywrG/HGPTD9jXNbicRtTM11e/uorplArnXZYsVifnPPqNNP5BsO3roI4n8332ukpY/zN7rQ==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.55.1':
resolution: {integrity: sha512-grPNWydeKtc1aEdrJDWk4opD7nFtQbMmV7769hiAaYyUKCT1faPRm2av8CX1YJsZ4TLAZcg9gTR1KvEzoLjXkg==}
cpu: [arm]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-arm64-gnu@4.55.1':
resolution: {integrity: sha512-a59mwd1k6x8tXKcUxSyISiquLwB5pX+fJW9TkWU46lCqD/GRDe9uDN31jrMmVP3feI3mhAdvcCClhV8V5MhJFQ==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.55.1':
resolution: {integrity: sha512-puS1MEgWX5GsHSoiAsF0TYrpomdvkaXm0CofIMG5uVkP6IBV+ZO9xhC5YEN49nsgYo1DuuMquF9+7EDBVYu4uA==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-loong64-gnu@4.55.1':
resolution: {integrity: sha512-r3Wv40in+lTsULSb6nnoudVbARdOwb2u5fpeoOAZjFLznp6tDU8kd+GTHmJoqZ9lt6/Sys33KdIHUaQihFcu7g==}
cpu: [loong64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-loong64-musl@4.55.1':
resolution: {integrity: sha512-MR8c0+UxAlB22Fq4R+aQSPBayvYa3+9DrwG/i1TKQXFYEaoW3B5b/rkSRIypcZDdWjWnpcvxbNaAJDcSbJU3Lw==}
cpu: [loong64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-ppc64-gnu@4.55.1':
resolution: {integrity: sha512-3KhoECe1BRlSYpMTeVrD4sh2Pw2xgt4jzNSZIIPLFEsnQn9gAnZagW9+VqDqAHgm1Xc77LzJOo2LdigS5qZ+gw==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-ppc64-musl@4.55.1':
resolution: {integrity: sha512-ziR1OuZx0vdYZZ30vueNZTg73alF59DicYrPViG0NEgDVN8/Jl87zkAPu4u6VjZST2llgEUjaiNl9JM6HH1Vdw==}
cpu: [ppc64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-riscv64-gnu@4.55.1':
resolution: {integrity: sha512-uW0Y12ih2XJRERZ4jAfKamTyIHVMPQnTZcQjme2HMVDAHY4amf5u414OqNYC+x+LzRdRcnIG1YodLrrtA8xsxw==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-musl@4.55.1':
resolution: {integrity: sha512-u9yZ0jUkOED1BFrqu3BwMQoixvGHGZ+JhJNkNKY/hyoEgOwlqKb62qu+7UjbPSHYjiVy8kKJHvXKv5coH4wDeg==}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-s390x-gnu@4.55.1':
resolution: {integrity: sha512-/0PenBCmqM4ZUd0190j7J0UsQ/1nsi735iPRakO8iPciE7BQ495Y6msPzaOmvx0/pn+eJVVlZrNrSh4WSYLxNg==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.55.1':
resolution: {integrity: sha512-a8G4wiQxQG2BAvo+gU6XrReRRqj+pLS2NGXKm8io19goR+K8lw269eTrPkSdDTALwMmJp4th2Uh0D8J9bEV1vg==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.55.1':
resolution: {integrity: sha512-bD+zjpFrMpP/hqkfEcnjXWHMw5BIghGisOKPj+2NaNDuVT+8Ds4mPf3XcPHuat1tz89WRL+1wbcxKY3WSbiT7w==}
cpu: [x64]
os: [linux]
libc: [musl]
'@rollup/rollup-openbsd-x64@4.55.1':
resolution: {integrity: sha512-eLXw0dOiqE4QmvikfQ6yjgkg/xDM+MdU9YJuP4ySTibXU0oAvnEWXt7UDJmD4UkYialMfOGFPJnIHSe/kdzPxg==}
@@ -2251,6 +2271,7 @@ packages:
'@ungap/structured-clone@1.3.0':
resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==}
deprecated: Potential CWE-502 - Update to 1.3.1 or higher
'@vitejs/plugin-basic-ssl@1.1.0':
resolution: {integrity: sha512-wO4Dk/rm8u7RNhOf95ZzcEmC9rYOncYgvq4z3duaJrCgjN8BxAnDVyndanfcJZ0O6XZzHz6Q0hTimxTg8Y9g/A==}
@@ -4125,11 +4146,12 @@ packages:
glob@10.5.0:
resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==}
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
hasBin: true
glob@7.2.3:
resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==}
deprecated: Glob versions prior to v9 are no longer supported
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
global-modules@2.0.0:
resolution: {integrity: sha512-NGbfmJBp9x8IxyJSd1P+otYK8vonoJactOogrVfFRIAEY1ukil8RSKDz2Yo7wh1oihl51l/r6W4epkeKJHqL8A==}
@@ -7081,6 +7103,7 @@ packages:
tar@6.2.1:
resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==}
engines: {node: '>=10'}
deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
temp@0.9.4:
resolution: {integrity: sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==}
@@ -7426,11 +7449,12 @@ packages:
uuid@3.4.0:
resolution: {integrity: sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==}
deprecated: Please upgrade to version 7 or higher. Older versions may use Math.random() in certain circumstances, which is known to be problematic. See https://v8.dev/blog/math-random for details.
deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).
hasBin: true
uuid@8.3.2:
resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==}
deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).
hasBin: true
v8-compile-cache-lib@3.0.1:
@@ -7813,7 +7837,7 @@ snapshots:
dependencies:
'@ampproject/remapping': 2.3.0
'@angular-devkit/architect': 0.1703.8(chokidar@3.6.0)
'@angular-devkit/build-webpack': 0.1703.8(chokidar@3.6.0)(webpack-dev-server@4.15.1(webpack@5.90.3))(webpack@5.90.3)
'@angular-devkit/build-webpack': 0.1703.8(chokidar@3.6.0)(webpack-dev-server@4.15.1(webpack@5.90.3(esbuild@0.20.1)))(webpack@5.90.3(esbuild@0.20.1))
'@angular-devkit/core': 17.3.8(chokidar@3.6.0)
'@angular/compiler-cli': 17.3.10(@angular/compiler@17.3.10(@angular/core@17.3.10(rxjs@7.8.1)(zone.js@0.14.5)))(typescript@5.3.3)
'@babel/core': 7.24.0
@@ -7826,16 +7850,16 @@ snapshots:
'@babel/preset-env': 7.24.0(@babel/core@7.24.0)
'@babel/runtime': 7.24.0
'@discoveryjs/json-ext': 0.5.7
'@ngtools/webpack': 17.3.8(@angular/compiler-cli@17.3.10(@angular/compiler@17.3.10(@angular/core@17.3.10(rxjs@7.8.1)(zone.js@0.14.5)))(typescript@5.3.3))(typescript@5.3.3)(webpack@5.90.3)
'@ngtools/webpack': 17.3.8(@angular/compiler-cli@17.3.10(@angular/compiler@17.3.10(@angular/core@17.3.10(rxjs@7.8.1)(zone.js@0.14.5)))(typescript@5.3.3))(typescript@5.3.3)(webpack@5.90.3(esbuild@0.20.1))
'@vitejs/plugin-basic-ssl': 1.1.0(vite@5.1.7(@types/node@20.12.11)(less@4.2.0)(sass@1.71.1)(sugarss@2.0.0)(terser@5.29.1))
ansi-colors: 4.1.3
autoprefixer: 10.4.18(postcss@8.4.35)
babel-loader: 9.1.3(@babel/core@7.24.0)(webpack@5.90.3)
babel-loader: 9.1.3(@babel/core@7.24.0)(webpack@5.90.3(esbuild@0.20.1))
babel-plugin-istanbul: 6.1.1
browserslist: 4.28.1
copy-webpack-plugin: 11.0.0(webpack@5.90.3)
copy-webpack-plugin: 11.0.0(webpack@5.90.3(esbuild@0.20.1))
critters: 0.0.22
css-loader: 6.10.0(webpack@5.90.3)
css-loader: 6.10.0(webpack@5.90.3(esbuild@0.20.1))
esbuild-wasm: 0.20.1
fast-glob: 3.3.2
http-proxy-middleware: 2.0.6(@types/express@4.17.25)
@@ -7844,11 +7868,11 @@ snapshots:
jsonc-parser: 3.2.1
karma-source-map-support: 1.4.0
less: 4.2.0
less-loader: 11.1.0(less@4.2.0)(webpack@5.90.3)
license-webpack-plugin: 4.0.2(webpack@5.90.3)
less-loader: 11.1.0(less@4.2.0)(webpack@5.90.3(esbuild@0.20.1))
license-webpack-plugin: 4.0.2(webpack@5.90.3(esbuild@0.20.1))
loader-utils: 3.2.1
magic-string: 0.30.8
mini-css-extract-plugin: 2.8.1(webpack@5.90.3)
mini-css-extract-plugin: 2.8.1(webpack@5.90.3(esbuild@0.20.1))
mrmime: 2.0.0
open: 8.4.2
ora: 5.4.1
@@ -7856,13 +7880,13 @@ snapshots:
picomatch: 4.0.1
piscina: 4.4.0
postcss: 8.4.35
postcss-loader: 8.1.1(postcss@8.4.35)(typescript@5.3.3)(webpack@5.90.3)
postcss-loader: 8.1.1(postcss@8.4.35)(typescript@5.3.3)(webpack@5.90.3(esbuild@0.20.1))
resolve-url-loader: 5.0.0
rxjs: 7.8.1
sass: 1.71.1
sass-loader: 14.1.1(sass@1.71.1)(webpack@5.90.3)
sass-loader: 14.1.1(sass@1.71.1)(webpack@5.90.3(esbuild@0.20.1))
semver: 7.6.0
source-map-loader: 5.0.0(webpack@5.90.3)
source-map-loader: 5.0.0(webpack@5.90.3(esbuild@0.20.1))
source-map-support: 0.5.21
terser: 5.29.1
tree-kill: 1.2.2
@@ -7872,10 +7896,10 @@ snapshots:
vite: 5.1.7(@types/node@20.12.11)(less@4.2.0)(sass@1.71.1)(sugarss@2.0.0)(terser@5.29.1)
watchpack: 2.4.0
webpack: 5.90.3(esbuild@0.20.1)
webpack-dev-middleware: 6.1.2(webpack@5.90.3)
webpack-dev-server: 4.15.1(webpack@5.90.3)
webpack-dev-middleware: 6.1.2(webpack@5.90.3(esbuild@0.20.1))
webpack-dev-server: 4.15.1(webpack@5.90.3(esbuild@0.20.1))
webpack-merge: 5.10.0
webpack-subresource-integrity: 5.1.0(webpack@5.90.3)
webpack-subresource-integrity: 5.1.0(webpack@5.90.3(esbuild@0.20.1))
optionalDependencies:
esbuild: 0.20.1
karma: 6.4.2
@@ -7899,12 +7923,12 @@ snapshots:
- utf-8-validate
- webpack-cli
'@angular-devkit/build-webpack@0.1703.8(chokidar@3.6.0)(webpack-dev-server@4.15.1(webpack@5.90.3))(webpack@5.90.3)':
'@angular-devkit/build-webpack@0.1703.8(chokidar@3.6.0)(webpack-dev-server@4.15.1(webpack@5.90.3(esbuild@0.20.1)))(webpack@5.90.3(esbuild@0.20.1))':
dependencies:
'@angular-devkit/architect': 0.1703.8(chokidar@3.6.0)
rxjs: 7.8.1
webpack: 5.90.3(esbuild@0.20.1)
webpack-dev-server: 4.15.1(webpack@5.90.3)
webpack-dev-server: 4.15.1(webpack@5.90.3(esbuild@0.20.1))
transitivePeerDependencies:
- chokidar
@@ -9393,7 +9417,7 @@ snapshots:
'@emnapi/runtime': 1.8.1
'@tybys/wasm-util': 0.9.0
'@ngtools/webpack@17.3.8(@angular/compiler-cli@17.3.10(@angular/compiler@17.3.10(@angular/core@17.3.10(rxjs@7.8.1)(zone.js@0.14.5)))(typescript@5.3.3))(typescript@5.3.3)(webpack@5.90.3)':
'@ngtools/webpack@17.3.8(@angular/compiler-cli@17.3.10(@angular/compiler@17.3.10(@angular/core@17.3.10(rxjs@7.8.1)(zone.js@0.14.5)))(typescript@5.3.3))(typescript@5.3.3)(webpack@5.90.3(esbuild@0.20.1))':
dependencies:
'@angular/compiler-cli': 17.3.10(@angular/compiler@17.3.10(@angular/core@17.3.10(rxjs@7.8.1)(zone.js@0.14.5)))(typescript@5.3.3)
typescript: 5.3.3
@@ -10503,7 +10527,7 @@ snapshots:
dependencies:
dequal: 2.0.3
babel-loader@9.1.3(@babel/core@7.24.0)(webpack@5.90.3):
babel-loader@9.1.3(@babel/core@7.24.0)(webpack@5.90.3(esbuild@0.20.1)):
dependencies:
'@babel/core': 7.24.0
find-cache-dir: 4.0.0
@@ -10986,7 +11010,7 @@ snapshots:
copy-descriptor@0.1.1: {}
copy-webpack-plugin@11.0.0(webpack@5.90.3):
copy-webpack-plugin@11.0.0(webpack@5.90.3(esbuild@0.20.1)):
dependencies:
fast-glob: 3.3.2
glob-parent: 6.0.2
@@ -11075,7 +11099,7 @@ snapshots:
css-functions-list@3.2.3: {}
css-loader@6.10.0(webpack@5.90.3):
css-loader@6.10.0(webpack@5.90.3(esbuild@0.20.1)):
dependencies:
icss-utils: 5.1.0(postcss@8.4.35)
postcss: 8.4.35
@@ -13277,7 +13301,7 @@ snapshots:
layout-base@2.0.1:
optional: true
less-loader@11.1.0(less@4.2.0)(webpack@5.90.3):
less-loader@11.1.0(less@4.2.0)(webpack@5.90.3(esbuild@0.20.1)):
dependencies:
klona: 2.0.6
less: 4.2.0
@@ -13326,7 +13350,7 @@ snapshots:
prelude-ls: 1.2.1
type-check: 0.4.0
license-webpack-plugin@4.0.2(webpack@5.90.3):
license-webpack-plugin@4.0.2(webpack@5.90.3(esbuild@0.20.1)):
dependencies:
webpack-sources: 3.3.3
optionalDependencies:
@@ -13639,7 +13663,7 @@ snapshots:
min-indent@1.0.1: {}
mini-css-extract-plugin@2.8.1(webpack@5.90.3):
mini-css-extract-plugin@2.8.1(webpack@5.90.3(esbuild@0.20.1)):
dependencies:
schema-utils: 4.3.3
tapable: 2.3.0
@@ -14443,17 +14467,17 @@ snapshots:
possible-typed-array-names@1.1.0: {}
postcss-html@0.36.0(postcss-syntax@0.36.2(postcss@8.5.6))(postcss@7.0.39):
postcss-html@0.36.0(postcss-syntax@0.36.2)(postcss@7.0.39):
dependencies:
htmlparser2: 3.10.1
postcss: 7.0.39
postcss-syntax: 0.36.2(postcss-html@0.36.0)(postcss-jsx@0.36.4(postcss-syntax@0.36.2(postcss@8.5.6))(postcss@7.0.39))(postcss-less@3.1.4)(postcss-markdown@0.36.0)(postcss-scss@2.1.1)(postcss@7.0.39)
postcss-syntax: 0.36.2(postcss-html@0.36.0)(postcss-jsx@0.36.4)(postcss-less@3.1.4)(postcss-markdown@0.36.0)(postcss-scss@2.1.1)(postcss@7.0.39)
postcss-jsx@0.36.4(postcss-syntax@0.36.2(postcss@8.5.6))(postcss@7.0.39):
postcss-jsx@0.36.4(postcss-syntax@0.36.2)(postcss@7.0.39):
dependencies:
'@babel/core': 7.28.6
postcss: 7.0.39
postcss-syntax: 0.36.2(postcss-html@0.36.0)(postcss-jsx@0.36.4(postcss-syntax@0.36.2(postcss@8.5.6))(postcss@7.0.39))(postcss-less@3.1.4)(postcss-markdown@0.36.0)(postcss-scss@2.1.1)(postcss@7.0.39)
postcss-syntax: 0.36.2(postcss-html@0.36.0)(postcss-jsx@0.36.4)(postcss-less@3.1.4)(postcss-markdown@0.36.0)(postcss-scss@2.1.1)(postcss@7.0.39)
transitivePeerDependencies:
- supports-color
@@ -14465,7 +14489,7 @@ snapshots:
dependencies:
postcss: 8.5.6
postcss-loader@8.1.1(postcss@8.4.35)(typescript@5.3.3)(webpack@5.90.3):
postcss-loader@8.1.1(postcss@8.4.35)(typescript@5.3.3)(webpack@5.90.3(esbuild@0.20.1)):
dependencies:
cosmiconfig: 9.0.0(typescript@5.3.3)
jiti: 1.21.7
@@ -14476,10 +14500,10 @@ snapshots:
transitivePeerDependencies:
- typescript
postcss-markdown@0.36.0(postcss-syntax@0.36.2(postcss@8.5.6))(postcss@7.0.39):
postcss-markdown@0.36.0(postcss-syntax@0.36.2)(postcss@7.0.39):
dependencies:
postcss: 7.0.39
postcss-syntax: 0.36.2(postcss-html@0.36.0)(postcss-jsx@0.36.4(postcss-syntax@0.36.2(postcss@8.5.6))(postcss@7.0.39))(postcss-less@3.1.4)(postcss-markdown@0.36.0)(postcss-scss@2.1.1)(postcss@7.0.39)
postcss-syntax: 0.36.2(postcss-html@0.36.0)(postcss-jsx@0.36.4)(postcss-less@3.1.4)(postcss-markdown@0.36.0)(postcss-scss@2.1.1)(postcss@7.0.39)
remark: 10.0.1
unist-util-find-all-after: 1.0.5
@@ -14557,14 +14581,14 @@ snapshots:
dependencies:
postcss: 8.5.6
postcss-syntax@0.36.2(postcss-html@0.36.0)(postcss-jsx@0.36.4(postcss-syntax@0.36.2(postcss@8.5.6))(postcss@7.0.39))(postcss-less@3.1.4)(postcss-markdown@0.36.0)(postcss-scss@2.1.1)(postcss@7.0.39):
postcss-syntax@0.36.2(postcss-html@0.36.0)(postcss-jsx@0.36.4)(postcss-less@3.1.4)(postcss-markdown@0.36.0)(postcss-scss@2.1.1)(postcss@7.0.39):
dependencies:
postcss: 7.0.39
optionalDependencies:
postcss-html: 0.36.0(postcss-syntax@0.36.2(postcss@8.5.6))(postcss@7.0.39)
postcss-jsx: 0.36.4(postcss-syntax@0.36.2(postcss@8.5.6))(postcss@7.0.39)
postcss-html: 0.36.0(postcss-syntax@0.36.2)(postcss@7.0.39)
postcss-jsx: 0.36.4(postcss-syntax@0.36.2)(postcss@7.0.39)
postcss-less: 3.1.4
postcss-markdown: 0.36.0(postcss-syntax@0.36.2(postcss@8.5.6))(postcss@7.0.39)
postcss-markdown: 0.36.0(postcss-syntax@0.36.2)(postcss@7.0.39)
postcss-scss: 2.1.1
postcss-value-parser@3.3.1: {}
@@ -15033,7 +15057,7 @@ snapshots:
safer-buffer@2.1.2: {}
sass-loader@14.1.1(sass@1.71.1)(webpack@5.90.3):
sass-loader@14.1.1(sass@1.71.1)(webpack@5.90.3(esbuild@0.20.1)):
dependencies:
neo-async: 2.6.2
optionalDependencies:
@@ -15375,7 +15399,7 @@ snapshots:
source-map-js@1.2.1: {}
source-map-loader@5.0.0(webpack@5.90.3):
source-map-loader@5.0.0(webpack@5.90.3(esbuild@0.20.1)):
dependencies:
iconv-lite: 0.6.3
source-map-js: 1.2.1
@@ -15705,10 +15729,10 @@ snapshots:
normalize-selector: 0.2.0
pify: 4.0.1
postcss: 7.0.39
postcss-html: 0.36.0(postcss-syntax@0.36.2(postcss@8.5.6))(postcss@7.0.39)
postcss-jsx: 0.36.4(postcss-syntax@0.36.2(postcss@8.5.6))(postcss@7.0.39)
postcss-html: 0.36.0(postcss-syntax@0.36.2)(postcss@7.0.39)
postcss-jsx: 0.36.4(postcss-syntax@0.36.2)(postcss@7.0.39)
postcss-less: 3.1.4
postcss-markdown: 0.36.0(postcss-syntax@0.36.2(postcss@8.5.6))(postcss@7.0.39)
postcss-markdown: 0.36.0(postcss-syntax@0.36.2)(postcss@7.0.39)
postcss-media-query-parser: 0.2.3
postcss-reporter: 6.0.1
postcss-resolve-nested-selector: 0.1.6
@@ -15716,7 +15740,7 @@ snapshots:
postcss-sass: 0.3.5
postcss-scss: 2.1.1
postcss-selector-parser: 3.1.2
postcss-syntax: 0.36.2(postcss-html@0.36.0)(postcss-jsx@0.36.4(postcss-syntax@0.36.2(postcss@8.5.6))(postcss@7.0.39))(postcss-less@3.1.4)(postcss-markdown@0.36.0)(postcss-scss@2.1.1)(postcss@7.0.39)
postcss-syntax: 0.36.2(postcss-html@0.36.0)(postcss-jsx@0.36.4)(postcss-less@3.1.4)(postcss-markdown@0.36.0)(postcss-scss@2.1.1)(postcss@7.0.39)
postcss-value-parser: 3.3.1
resolve-from: 4.0.0
signal-exit: 3.0.7
@@ -15838,7 +15862,7 @@ snapshots:
mkdirp: 0.5.6
rimraf: 2.6.3
terser-webpack-plugin@5.3.16(esbuild@0.20.1)(webpack@5.90.3):
terser-webpack-plugin@5.3.16(esbuild@0.20.1)(webpack@5.90.3(esbuild@0.20.1)):
dependencies:
'@jridgewell/trace-mapping': 0.3.31
jest-worker: 27.5.1
@@ -16281,7 +16305,7 @@ snapshots:
webidl-conversions@3.0.1: {}
webpack-dev-middleware@5.3.4(webpack@5.90.3):
webpack-dev-middleware@5.3.4(webpack@5.90.3(esbuild@0.20.1)):
dependencies:
colorette: 2.0.20
memfs: 3.5.3
@@ -16290,7 +16314,7 @@ snapshots:
schema-utils: 4.3.3
webpack: 5.90.3(esbuild@0.20.1)
webpack-dev-middleware@6.1.2(webpack@5.90.3):
webpack-dev-middleware@6.1.2(webpack@5.90.3(esbuild@0.20.1)):
dependencies:
colorette: 2.0.20
memfs: 3.5.3
@@ -16300,7 +16324,7 @@ snapshots:
optionalDependencies:
webpack: 5.90.3(esbuild@0.20.1)
webpack-dev-server@4.15.1(webpack@5.90.3):
webpack-dev-server@4.15.1(webpack@5.90.3(esbuild@0.20.1)):
dependencies:
'@types/bonjour': 3.5.13
'@types/connect-history-api-fallback': 1.5.4
@@ -16330,7 +16354,7 @@ snapshots:
serve-index: 1.9.1
sockjs: 0.3.24
spdy: 4.0.2
webpack-dev-middleware: 5.3.4(webpack@5.90.3)
webpack-dev-middleware: 5.3.4(webpack@5.90.3(esbuild@0.20.1))
ws: 8.19.0
optionalDependencies:
webpack: 5.90.3(esbuild@0.20.1)
@@ -16348,7 +16372,7 @@ snapshots:
webpack-sources@3.3.3: {}
webpack-subresource-integrity@5.1.0(webpack@5.90.3):
webpack-subresource-integrity@5.1.0(webpack@5.90.3(esbuild@0.20.1)):
dependencies:
typed-assert: 1.0.9
webpack: 5.90.3(esbuild@0.20.1)
@@ -16376,7 +16400,7 @@ snapshots:
neo-async: 2.6.2
schema-utils: 3.3.0
tapable: 2.3.0
terser-webpack-plugin: 5.3.16(esbuild@0.20.1)(webpack@5.90.3)
terser-webpack-plugin: 5.3.16(esbuild@0.20.1)(webpack@5.90.3(esbuild@0.20.1))
watchpack: 2.4.0
webpack-sources: 3.3.3
transitivePeerDependencies:
+13 -2
View File
@@ -3,6 +3,7 @@ import { registerLocaleData } from '@angular/common';
import { HttpHeaders } from '@angular/common/http';
import ngEn from '@angular/common/locales/en';
import ngJa from '@angular/common/locales/ja';
import ngKo from '@angular/common/locales/ko';
import ngPt from '@angular/common/locales/pt';
import ngZh from '@angular/common/locales/zh';
import ngZhTw from '@angular/common/locales/zh-Hant';
@@ -15,10 +16,11 @@ import {
SettingsService,
zh_CN as delonZhCn,
zh_TW as delonZhTw,
ja_JP as delonJaJP
ja_JP as delonJaJP,
ko_KR as delonKoKR
} from '@delon/theme';
import { AlainConfigService } from '@delon/util/config';
import { enUS as dfEn, zhCN as dfZhCn, zhTW as dfZhTw, ja as dfJa, ptBR as dfPtBR } from 'date-fns/locale';
import { enUS as dfEn, zhCN as dfZhCn, zhTW as dfZhTw, ja as dfJa, ko as dfKo, ptBR as dfPtBR } from 'date-fns/locale';
import { NzSafeAny } from 'ng-zorro-antd/core/types';
import {
en_US as zorroEnUS,
@@ -26,6 +28,7 @@ import {
zh_CN as zorroZhCN,
zh_TW as zorroZhTW,
ja_JP as zorroJaJP,
ko_KR as zorroKoKR,
pt_BR as zorroPtBR
} from 'ng-zorro-antd/i18n';
import { Observable, zip } from 'rxjs';
@@ -76,6 +79,14 @@ const LANGS: { [key: string]: LangConfigData } = {
delon: delonJaJP,
abbr: '🇯🇵'
},
'ko-KR': {
text: '한국어',
ng: ngKo,
zorro: zorroKoKR,
date: dfKo,
delon: delonKoKR,
abbr: '🇰🇷'
},
'pt-BR': {
text: 'Português (Brasil)',
ng: ngPt,
@@ -6,7 +6,7 @@ import { DA_SERVICE_TOKEN, ITokenService } from '@delon/auth';
import { ALAIN_I18N_TOKEN, Menu, MenuService, SettingsService, TitleService } from '@delon/theme';
import type { NzSafeAny } from 'ng-zorro-antd/core/types';
import { NzIconService } from 'ng-zorro-antd/icon';
import { Observable, zip } from 'rxjs';
import { Observable, of, zip } from 'rxjs';
import { catchError, map } from 'rxjs/operators';
import { ICONS } from '../../../style-icons';
@@ -41,14 +41,15 @@ export class StartupService {
return zip(
this.i18n.loadLangData(defaultLang),
this.httpClient.get('./assets/app-data.json', { headers: headers }),
this.httpClient.get(`/apps/hierarchy?lang=${defaultLang}`)
this.httpClient.get(`/apps/hierarchy?lang=${defaultLang}`),
this.httpClient.get('/observability/capability').pipe(catchError(() => of(null)))
).pipe(
catchError((res: NzSafeAny) => {
console.warn(`StartupService.load: Network request failed`, res);
setTimeout(() => this.router.navigateByUrl(`/exception/500`));
return [];
}),
map(([langData, appData, menuData]: [Record<string, string>, NzSafeAny, NzSafeAny]) => {
map(([langData, appData, menuData, capabilityData]: [Record<string, string>, NzSafeAny, NzSafeAny, NzSafeAny]) => {
// setting language data
this.i18n.use(defaultLang, langData);
// Application information: including site name, description, year
@@ -85,6 +86,7 @@ export class StartupService {
item.hide = true;
}
});
this.storageService.putData('observabilityCapability', capabilityData?.data ?? null);
this.storageService.putData('hierarchy', menuData.data);
this.menuService.resume();
this.titleService.suffix = appData.app.name;
@@ -74,6 +74,12 @@ describe('SettingDrawerI18nDirective', () => {
'setting.drawer.theme.color': '主題色',
'setting.drawer.settings': '設置',
'setting.drawer.info.message': '配置欄只在開發環境用於預覽,生產環境不會展現,請拷貝後手動修改參數配置文件 src/styles/theme.less'
},
'ko-KR': {
'setting.drawer.theme.color': '테마 색상',
'setting.drawer.settings': '설정',
'setting.drawer.info.message':
'이 설정 패널은 개발 환경에서 미리보기 용도로만 제공되며, 운영 환경에서는 표시되지 않습니다. src/styles/theme.less 파일을 복사하여 직접 수정해 주세요'
}
};
@@ -121,7 +127,7 @@ describe('SettingDrawerI18nDirective', () => {
it('should load mappings from i18n files', fakeAsync(() => {
fixture.detectChanges();
const languages = ['zh-CN', 'en-US', 'ja-JP', 'pt-BR', 'zh-TW'];
const languages = ['zh-CN', 'en-US', 'ja-JP', 'pt-BR', 'zh-TW', 'ko-KR'];
const requests = languages.map(lang => httpMock.expectOne(`./assets/i18n/${lang}.json`));
languages.forEach((lang, index) => {
@@ -137,7 +143,7 @@ describe('SettingDrawerI18nDirective', () => {
it('should replace Chinese text with translations', fakeAsync(() => {
fixture.detectChanges();
const languages = ['zh-CN', 'en-US', 'ja-JP', 'pt-BR', 'zh-TW'];
const languages = ['zh-CN', 'en-US', 'ja-JP', 'pt-BR', 'zh-TW', 'ko-KR'];
const requests = languages.map(lang => httpMock.expectOne(`./assets/i18n/${lang}.json`));
languages.forEach((lang, index) => {
@@ -54,7 +54,7 @@ export class SettingDrawerI18nDirective implements OnInit, AfterViewInit, OnDest
'setting.drawer.info.message'
];
private readonly languages = ['zh-CN', 'en-US', 'ja-JP', 'pt-BR', 'zh-TW'];
private readonly languages = ['zh-CN', 'en-US', 'ja-JP', 'pt-BR', 'zh-TW', 'ko-KR'];
constructor(
private el: ElementRef<HTMLElement>,
@@ -19,7 +19,7 @@
<app-help-message-show
[help_message_content]="'alert.help.setting' | i18n"
[guild_link]="'alert.help.center.setting' | i18n"
[guild_link]="'alert.help.setting.link' | i18n"
[module_name]="'menu.alert.setting'"
[icon_name]="'calculator'"
></app-help-message-show>
@@ -713,7 +713,7 @@
[nzData]="previewData"
[nzSize]="'small'"
[nzLoading]="previewTableLoading"
[nzScroll]="previewData.length > 3 ? { y: '180px' } : {}"
[nzScroll]="previewData.length > 3 ? { x: 'max-content', y: '180px' } : { x: 'max-content' }"
[nzShowPagination]="false"
>
<thead>
@@ -788,7 +788,7 @@
[nzData]="previewData"
[nzSize]="'small'"
[nzLoading]="previewTableLoading"
[nzScroll]="previewData.length > 3 ? { y: '180px' } : {}"
[nzScroll]="previewData.length > 3 ? { x: 'max-content', y: '180px' } : { x: 'max-content' }"
[nzShowPagination]="false"
>
<thead>
@@ -14,7 +14,8 @@
See the License for the specific language governing permissions and
limitations under the License.
-->
<section class="signal-page">
<app-signal-storage-guide *ngIf="!storageSupported"></app-signal-storage-guide>
<section class="signal-page" *ngIf="storageSupported">
<header class="page-heading"
><div
><h1>{{ 'observability.logs.title' | i18n }}</h1
@@ -20,6 +20,7 @@
import { ActivatedRoute, Router } from '@angular/router';
import { LogService } from '../../../service/log.service';
import { MemoryStorageService } from '../../../service/memory-storage.service';
import { LogManageComponent } from './log-manage.component';
describe('LogManageComponent', () => {
@@ -27,7 +28,7 @@ describe('LogManageComponent', () => {
const logs = jasmine.createSpyObj<LogService>('LogService', ['list', 'overviewStats', 'trendStats']);
const route = { snapshot: { queryParamMap: { get: () => null }, data: {} } } as unknown as ActivatedRoute;
const router = jasmine.createSpyObj<Router>('Router', ['navigate']);
const component = new LogManageComponent(logs, route, router);
const component = new LogManageComponent(logs, route, router, new MemoryStorageService());
component.timeRange = [new Date(1_000), new Date(2_000)];
component.setMode('stream');

Some files were not shown because too many files have changed in this diff Show More