mirror of
https://github.com/komari-monitor/komari.git
synced 2026-11-11 00:30:54 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
96cf0f79d8 | ||
|
|
9e5f9a3a71 | ||
|
|
70bbceaf09 | ||
|
|
e30b9fdbd4 | ||
|
|
d5689efeea | ||
|
|
c24c4b9c7c | ||
|
|
d50cc0a9fd | ||
|
|
528cea23be | ||
|
|
b7042672db | ||
|
|
7468dcb12b | ||
|
|
dc7f4cb541 | ||
|
|
48af63ca7b | ||
|
|
3cf8445ad4 | ||
|
|
b38b0b2347 | ||
|
|
fa5a04d8e2 | ||
|
|
4b24f1e196 | ||
|
|
5006f55ca0 | ||
|
|
1b76b7ad5b | ||
|
|
13cbdca545 | ||
|
|
e48aa1e769 | ||
|
|
a68704a425 | ||
|
|
39cb3f59b2 | ||
|
|
9812acfd11 | ||
|
|
70df32096f | ||
|
|
29d309f4a1 | ||
|
|
7d692d2faf | ||
|
|
68c284d043 | ||
|
|
14bc87d1e9 | ||
|
|
154d2b8af2 | ||
|
|
2c3f2281fa | ||
|
|
7e91794f24 | ||
|
|
f57461f87f | ||
|
|
d15f0512c7 | ||
|
|
4ec22edf1f | ||
|
|
0ca87aafd1 | ||
|
|
f18ad72c17 | ||
|
|
8e4a3f8709 | ||
|
|
80add06a12 | ||
|
|
1333d63ce0 | ||
|
|
44ed3035f1 | ||
|
|
7ec36157d5 | ||
|
|
360fd5f50b | ||
|
|
b11ffd3aa7 | ||
|
|
d7d6135c49 | ||
|
|
ac037f2177 | ||
|
|
5405bdf377 | ||
|
|
f754f01f53 | ||
|
|
434ba108e1 | ||
|
|
24254749b5 | ||
|
|
c6b02af000 | ||
|
|
b28ac9d42c | ||
|
|
2ff7c11db0 | ||
|
|
46fd2af44c | ||
|
|
4d2eb70c62 | ||
|
|
7a6692b79a | ||
|
|
63d78b6ed4 | ||
|
|
2319ea77f6 | ||
|
|
0c39a52e0b | ||
|
|
e31a032d89 | ||
|
|
da4d5187c1 | ||
|
|
bf6b45ec3a | ||
|
|
4a3400bee6 | ||
|
|
4caa067556 | ||
|
|
2f3959adde | ||
|
|
63a7ffddcf | ||
|
|
92d679ffdb | ||
|
|
7c30fc4dc9 | ||
|
|
773a3715dc | ||
|
|
75d7b6c7a3 | ||
|
|
c294c17642 | ||
|
|
2883bf6295 | ||
|
|
14bf8bde7c | ||
|
|
f043f2890c | ||
|
|
057108a2f3 | ||
|
|
5879d4b3da | ||
|
|
16769d5687 | ||
|
|
5ea32c48b6 | ||
|
|
a06d922d78 | ||
|
|
0ed14fc73d | ||
|
|
61bd0c8bdd | ||
|
|
e5d67686db | ||
|
|
96462528c8 | ||
|
|
d92f60f757 | ||
|
|
cba8153d4a | ||
|
|
08067bc8f8 | ||
|
|
a714637fee | ||
|
|
956b9334d0 | ||
|
|
996dd90386 | ||
|
|
28abafb5b2 | ||
|
|
5488189e5f | ||
|
|
d9713aa696 | ||
|
|
eab03a1da5 | ||
|
|
1f139ba3bf | ||
|
|
4040a5d9c7 | ||
|
|
01c4816d1c | ||
|
|
c27c21d5c9 | ||
|
|
f967ba07f9 | ||
|
|
91d5f92c55 | ||
|
|
965afb3f77 | ||
|
|
68bf4ed148 | ||
|
|
8985adcf67 | ||
|
|
91cbeb3f82 | ||
|
|
91ae9eda1a | ||
|
|
54851bd1cf | ||
|
|
3c5838283e | ||
|
|
df6ffba18c | ||
|
|
4077201f09 | ||
|
|
05a91adc53 | ||
|
|
1c75cb8be0 |
No files matched your search
@@ -23,6 +23,15 @@ runs:
|
||||
with:
|
||||
node-version: ${{ inputs.node-version }}
|
||||
|
||||
- name: Install zstd
|
||||
shell: bash
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
if ! command -v zstd >/dev/null 2>&1; then
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y zstd
|
||||
fi
|
||||
|
||||
- name: Clone and build frontend
|
||||
shell: bash
|
||||
run: |
|
||||
@@ -38,9 +47,12 @@ runs:
|
||||
npm run build
|
||||
cd ..
|
||||
|
||||
mkdir -p web/public/defaultTheme/dist
|
||||
rm -rf web/public/defaultTheme/dist/*
|
||||
cp -r komari-web/dist/* web/public/defaultTheme/dist/
|
||||
mkdir -p web/public/defaultTheme
|
||||
rm -rf web/public/defaultTheme/dist web/public/defaultTheme/dist.tar.zst
|
||||
test -f komari-web/dist/index.html
|
||||
tar -cf "$RUNNER_TEMP/komari-default-dist.tar" -C komari-web/dist .
|
||||
zstd -19 -T0 -q -f "$RUNNER_TEMP/komari-default-dist.tar" -o web/public/defaultTheme/dist.tar.zst
|
||||
rm -f "$RUNNER_TEMP/komari-default-dist.tar"
|
||||
cp -f komari-web/komari-theme.json web/public/defaultTheme/
|
||||
if [ -f komari-web/preview.png ]; then cp -f komari-web/preview.png web/public/defaultTheme/; fi
|
||||
if [ -f komari-web/perview.png ]; then cp -f komari-web/perview.png web/public/defaultTheme/; fi
|
||||
|
||||
@@ -22,3 +22,4 @@ runs:
|
||||
mkdir -p web/public/defaultTheme
|
||||
rm -rf web/public/defaultTheme/*
|
||||
tar -xzf "$RUNNER_TEMP/frontend-artifact/${{ inputs.artifact-name }}.tar.gz" -C web/public/defaultTheme
|
||||
test -s web/public/defaultTheme/dist.tar.zst
|
||||
@@ -98,7 +98,11 @@ jobs:
|
||||
fi
|
||||
VERSION="${{ github.sha }}"
|
||||
VERSION_HASH="${{ github.sha }}"
|
||||
go build -trimpath -ldflags="-s -w -X github.com/komari-monitor/komari/utils.CurrentVersion=${VERSION} -X github.com/komari-monitor/komari/utils.VersionHash=${VERSION_HASH}" -o $BINARY_NAME
|
||||
LDFLAGS="-s -w -X github.com/komari-monitor/komari/utils.CurrentVersion=${VERSION} -X github.com/komari-monitor/komari/utils.VersionHash=${VERSION_HASH}"
|
||||
if [ "${{ matrix.goos }}" = "linux" ]; then
|
||||
LDFLAGS="$LDFLAGS -extldflags=-Wl,--strip-all"
|
||||
fi
|
||||
go build -trimpath -ldflags="$LDFLAGS" -o "$BINARY_NAME"
|
||||
|
||||
- name: Upload binary as artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
|
||||
@@ -14,11 +14,6 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
#- name: Create dummy index.html
|
||||
# run: |
|
||||
# mkdir -p web/public/defaultTheme/dist
|
||||
# echo "<html><body><h1>This is komari development server.</h1></body></html>" > web/public/defaultTheme/dist/index.html
|
||||
|
||||
- name: Clone and build frontend
|
||||
uses: ./.github/actions/build-frontend
|
||||
|
||||
@@ -33,7 +28,7 @@ jobs:
|
||||
GOARCH: amd64
|
||||
run: |
|
||||
VERSION_SHA="${{ github.sha }}"
|
||||
go build -ldflags "-X github.com/komari-monitor/komari/utils.CurrentVersion=dev -X github.com/komari-monitor/komari/utils.VersionHash=${VERSION_SHA}" -o komari .
|
||||
go build -ldflags "-s -w -extldflags=-Wl,--strip-all -X github.com/komari-monitor/komari/utils.CurrentVersion=dev -X github.com/komari-monitor/komari/utils.VersionHash=${VERSION_SHA}" -o komari .
|
||||
|
||||
- name: Upload binary as artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
|
||||
@@ -50,7 +50,7 @@ jobs:
|
||||
run: |
|
||||
VERSION="${{ github.ref_name }}"
|
||||
VERSION_HASH="${{ github.sha }}"
|
||||
LDFLAGS="-s -w -X github.com/komari-monitor/komari/utils.CurrentVersion=${VERSION} -X github.com/komari-monitor/komari/utils.VersionHash=${VERSION_HASH}"
|
||||
LDFLAGS="-s -w -extldflags=-Wl,--strip-all -X github.com/komari-monitor/komari/utils.CurrentVersion=${VERSION} -X github.com/komari-monitor/komari/utils.VersionHash=${VERSION_HASH}"
|
||||
|
||||
echo "Building for linux/amd64..."
|
||||
GOARCH=amd64 CC="zig cc -target x86_64-linux-musl" go build -trimpath -ldflags="$LDFLAGS" -o komari-linux-amd64
|
||||
|
||||
@@ -102,7 +102,11 @@ jobs:
|
||||
run: |
|
||||
BINARY_NAME=komari-${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.binary_extension }}
|
||||
VERSION_HASH="$(git rev-parse HEAD)"
|
||||
go build -trimpath -ldflags="-s -w -X github.com/komari-monitor/komari/utils.CurrentVersion=${RELEASE_TAG} -X github.com/komari-monitor/komari/utils.VersionHash=${VERSION_HASH}" -o "$BINARY_NAME"
|
||||
LDFLAGS="-s -w -X github.com/komari-monitor/komari/utils.CurrentVersion=${RELEASE_TAG} -X github.com/komari-monitor/komari/utils.VersionHash=${VERSION_HASH}"
|
||||
if [ "${{ matrix.goos }}" = "linux" ]; then
|
||||
LDFLAGS="$LDFLAGS -extldflags=-Wl,--strip-all"
|
||||
fi
|
||||
go build -trimpath -ldflags="$LDFLAGS" -o "$BINARY_NAME"
|
||||
|
||||
- name: Upload binary to release
|
||||
env:
|
||||
|
||||
@@ -56,7 +56,7 @@ jobs:
|
||||
VERSION: ${{ github.event.inputs.version }}
|
||||
run: |
|
||||
VERSION_HASH="${{ github.sha }}"
|
||||
LDFLAGS="-s -w -X github.com/komari-monitor/komari/utils.CurrentVersion=${VERSION} -X github.com/komari-monitor/komari/utils.VersionHash=${VERSION_HASH}"
|
||||
LDFLAGS="-s -w -extldflags=-Wl,--strip-all -X github.com/komari-monitor/komari/utils.CurrentVersion=${VERSION} -X github.com/komari-monitor/komari/utils.VersionHash=${VERSION_HASH}"
|
||||
|
||||
echo "Building for linux/amd64..."
|
||||
GOARCH=amd64 CC="zig cc -target x86_64-linux-musl" go build -trimpath -ldflags="$LDFLAGS" -o komari-linux-amd64
|
||||
|
||||
@@ -121,7 +121,11 @@ jobs:
|
||||
fi
|
||||
VERSION="${{ github.event.release.tag_name }}"
|
||||
VERSION_HASH="${{ github.sha }}" # Use commit SHA for hash
|
||||
go build -trimpath -ldflags="-s -w -X github.com/komari-monitor/komari/utils.CurrentVersion=${VERSION} -X github.com/komari-monitor/komari/utils.VersionHash=${VERSION_HASH}" -o $BINARY_NAME
|
||||
LDFLAGS="-s -w -X github.com/komari-monitor/komari/utils.CurrentVersion=${VERSION} -X github.com/komari-monitor/komari/utils.VersionHash=${VERSION_HASH}"
|
||||
if [ "${{ matrix.goos }}" = "linux" ]; then
|
||||
LDFLAGS="$LDFLAGS -extldflags=-Wl,--strip-all"
|
||||
fi
|
||||
go build -trimpath -ldflags="$LDFLAGS" -o "$BINARY_NAME"
|
||||
|
||||
- name: Upload binary as artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
|
||||
@@ -150,7 +150,11 @@ jobs:
|
||||
fi
|
||||
VERSION="${{ needs.prepare.outputs.version }}"
|
||||
VERSION_HASH="${{ github.sha }}" # Use commit SHA for hash
|
||||
go build -trimpath -ldflags="-s -w -X github.com/komari-monitor/komari/utils.CurrentVersion=${VERSION} -X github.com/komari-monitor/komari/utils.VersionHash=${VERSION_HASH}" -o $BINARY_NAME
|
||||
LDFLAGS="-s -w -X github.com/komari-monitor/komari/utils.CurrentVersion=${VERSION} -X github.com/komari-monitor/komari/utils.VersionHash=${VERSION_HASH}"
|
||||
if [ "${{ matrix.goos }}" = "linux" ]; then
|
||||
LDFLAGS="$LDFLAGS -extldflags=-Wl,--strip-all"
|
||||
fi
|
||||
go build -trimpath -ldflags="$LDFLAGS" -o "$BINARY_NAME"
|
||||
|
||||
- name: Upload binary as artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
|
||||
@@ -15,6 +15,7 @@ komari.db
|
||||
/utils/geoip/data
|
||||
.vscode/
|
||||
/backup
|
||||
/cache
|
||||
|
||||
# Test binary, built with `go test -c`
|
||||
*.test
|
||||
@@ -34,4 +35,7 @@ go.work.sum
|
||||
|
||||
# build
|
||||
komari
|
||||
/dist/
|
||||
.codex
|
||||
.gocache/
|
||||
/web/filemanager/data
|
||||
+2
-4
@@ -8,13 +8,11 @@ ARG TARGETARCH
|
||||
|
||||
RUN apk add --no-cache ca-certificates curl tzdata
|
||||
|
||||
COPY komari-${TARGETOS}-${TARGETARCH} /app/komari
|
||||
|
||||
|
||||
RUN chmod +x /app/komari
|
||||
COPY --chmod=755 komari-${TARGETOS}-${TARGETARCH} /app/komari
|
||||
|
||||
ENV GIN_MODE=release
|
||||
ENV KOMARI_LISTEN=0.0.0.0:25774
|
||||
ENV GODEBUG=disablethp=1
|
||||
|
||||
EXPOSE 25774
|
||||
|
||||
|
||||
@@ -5,43 +5,64 @@
|
||||
|
||||

|
||||
|
||||
[简体中文](./README_zh-cn.md)
|
||||
[English](./README.md) | [简体中文](./README_zh-cn.md)
|
||||
|
||||
Komari is a lightweight, self-hosted server monitoring tool designed to provide a simple and efficient solution for monitoring server performance. It supports viewing server status through a web interface and collects data through a lightweight agent.
|
||||
Komari is a lightweight, self-hosted server monitoring solution. It provides a simple and efficient way to track server performance through a web interface, with metrics collected by a lightweight agent.
|
||||
|
||||
> [!WARNING]
|
||||
> Komari is a self-hosted monitoring and control program intended only for systems you own or are authorized to administer. Do not weaponize Komari, deploy it without consent, or use it for unauthorized access, persistence, command execution, or other abusive activity. For context on real-world abuse risks, see Huntress' analysis: [Komari C2 agent abuse](https://www.huntress.com/blog/komari-c2-agent-abuse).
|
||||
> Users are solely responsible for how they deploy and operate Komari. The developers do not accept responsibility for unauthorized or abusive use, or for any resulting consequences.
|
||||
> On Windows, when remote control is enabled, the client displays a Windows notification at each user login to remind the user that Komari is remote control software.
|
||||
> Komari is a self-hosted monitoring and control application. Deploy it only on systems you own or are authorized to manage. You are solely responsible for how you deploy and use Komari. The developers accept no liability for unauthorized access, persistence, command execution, other misuse, or any resulting consequences.
|
||||
|
||||
[文档/Documentation](https://komari-document.pages.dev/) | [文档(镜像站 By Geekertao)](https://www.komari.wiki) | [Telegram Group](https://t.me/komari_monitor)
|
||||
[Documentation](https://www.komari.wiki/) | [Telegram Group](https://t.me/komari_monitor)
|
||||
|
||||
## Features
|
||||
|
||||
- **Lightweight and Efficient**: Low resource consumption, suitable for servers of all sizes.
|
||||
- **Self-hosted**: Complete control over data privacy, easy to deploy.
|
||||
- **Web Interface**: Intuitive monitoring dashboard, easy to use.
|
||||
- **Real-time monitoring**: Displays monitoring data at one-second intervals.
|
||||
- **Lightweight and efficient**: Uses minimal system resources and works well on servers of any size.
|
||||
- **Self-hosted**: Keeps you in control of your data and privacy.
|
||||
- **Web interface**: Provides an intuitive, easy-to-use monitoring dashboard.
|
||||
- **Extensible**: Supports custom themes and plugins.
|
||||
|
||||
## One-click Deployment
|
||||
## Quick Start
|
||||
|
||||
- Rainyun - CNY 4.5/month
|
||||
| Platform | Description |
|
||||
| --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| <a href="https://app.rainyun.com/apps/rca/store/6780/NzYxNzAz_"><img src="https://rainyun-apps.cn-nb1.rains3.com/materials/deploy-on-rainyun-cn.svg" alt="Rainyun" width="180"></a> | Deploy websites, databases, and hundreds of popular apps in seconds with flexible hourly billing. [Get started for just ¥5/month. Deploy now!](https://app.rainyun.com/apps/rca/store/6780/NzYxNzAz_) |
|
||||
| <a href="https://apps.fit2cloud.com/1panel/komari"><img src="https://raw.githubusercontent.com/komari-monitor/public/refs/heads/main/images/1panel-logo-blue.png" alt="1Panel App Store" width="180"></a> | A modern, open-source Linux server management panel for websites, databases, containers, files, backups, security, and AI, with one-click deployment from its app store. |
|
||||
|
||||
[](https://app.rainyun.com/apps/rca/store/6780/NzYxNzAz_)
|
||||
For instructions on Docker deployment, binary installation, building from source, and updates, see the [installation guide](https://www.komari.wiki/en/install/quick-start).
|
||||
|
||||
- 1Panel App Store
|
||||
## Screenshots
|
||||
|
||||
Available on 1Panel App Store. Install via **App Store > Utilities > Komari**.
|
||||
| Page | Screenshot |
|
||||
| ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Home Dashboard | <img src="https://b2.akz.moe/awesome-pictures/komari-screenshot/%E4%B8%BB%E9%A1%B5%E4%BB%AA%E8%A1%A8%E7%9B%98-en.webp" width="800" alt="Home Dashboard"> |
|
||||
| Admin Dashboard | <img src="https://b2.akz.moe/awesome-pictures/komari-screenshot/%E5%90%8E%E5%8F%B0%E4%BB%AA%E8%A1%A8%E7%9B%98-en.webp" width="800" alt="Admin Dashboard"> |
|
||||
| History Charts | <img src="https://b2.akz.moe/awesome-pictures/komari-screenshot/%E5%8E%86%E5%8F%B2%E5%9B%BE%E8%A1%A8-en.webp" width="800" alt="History Charts"> |
|
||||
| Web Terminal | <img src="https://b2.akz.moe/awesome-pictures/komari-screenshot/%E7%BD%91%E9%A1%B5%E7%BB%88%E7%AB%AF.webp" width="800" alt="Web Terminal"> |
|
||||
| Customizable Themes | <img src="https://b2.akz.moe/awesome-pictures/komari-screenshot/%E4%B8%BB%E9%A2%98%E5%8F%AF%E8%87%AA%E5%AE%9A%E4%B9%89-en.webp" width="800" alt="Customizable Themes"> |
|
||||
| Theme Market | <img src="https://b2.akz.moe/awesome-pictures/komari-screenshot/%E4%B8%BB%E9%A2%98%E5%B8%82%E5%9C%BA-en.webp" width="800" alt="Theme Market"> |
|
||||
|
||||
For Docker, binary, source-build, and update instructions, see the [installation guide](https://komari-document.pages.dev/en/install/quick-start).
|
||||
## Sponsors
|
||||
|
||||
## Contributing
|
||||
Interested in sponsoring Komari? Contact the developer via [email](mailto:komari@akz.moe) or [Telegram](https://t.me/mamomoe).
|
||||
|
||||
Issues and Pull Requests are welcome!
|
||||
| Sponsor | Description |
|
||||
| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| <a href="https://mooproxy.net/?utm_source=komari"><img src="https://raw.githubusercontent.com/komari-monitor/public/refs/heads/main/images/Mooproxy-logo-transparent.png" alt="mooproxy.net" width="180"></a> | [MooProxy · Rotating residential proxies from $0.20/GB. Sign up for 500MB of free traffic and try it today!](https://mooproxy.net/?utm_source=komari) |
|
||||
| <a href="https://axisnow.io/zh?utm=komari"><img src="https://raw.githubusercontent.com/komari-monitor/public/refs/heads/main/images/AxisNow.jpg" alt="AxisNow" width="180"></a> | [Self-Hosted Private CDN \| Subscription-Based CDN-Like Service \| A Fully Controlled, Flexible, Modular CDN Network](https://axisnow.io/zh?utm=komari) |
|
||||
| <a href="https://whmcs.as211392.com/aff.php?aff=110"><img src="https://raw.githubusercontent.com/komari-monitor/public/refs/heads/main/images/dreamcloud.png" alt="Dream Cloud" width="180"></a> | Cost-effective Asia-Pacific hosting with direct connectivity and robust DDoS protection, backed by transparent capacity claims. |
|
||||
| <a href="https://sharon.io"><img src="https://raw.githubusercontent.com/komari-monitor/public/refs/heads/main/images/sharon-networks.webp" alt="Sharon Networks" width="180"></a> | Premium China-optimized connectivity from Asia-Pacific data centers, featuring low latency, high bandwidth, and Tbps-scale local DDoS mitigation. Join the [Telegram community](https://t.me/SharonNetwork) to participate in charitable initiatives and community giveaways. |
|
||||
|
||||
## Acknowledgements
|
||||
## Contributors
|
||||
|
||||
| Acknowledgement | Details |
|
||||
| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| <a href="https://whmcs.as211392.com/aff.php?aff=110"><img src="https://raw.githubusercontent.com/komari-monitor/public/refs/heads/main/images/dreamcloud.png" alt="Dream Cloud" width="180"></a> | 极高性价比解锁直连亚太高防,真高防,不虚标,打死退款 |
|
||||
| <a href="https://sharon.io"><img src="https://raw.githubusercontent.com/komari-monitor/public/refs/heads/main/images/sharon-networks.webp" alt="Sharon Networks" width="180"></a> | 亚太数据中心提供顶级的中国优化网络接入 · 低延时&高带宽&提供Tbps级本地清洗高防服务, 为您的业务保驾护航, 为您的客户提供极致体验. 加入社区 [Telegram群组](https://t.me/SharonNetwork) 可参与公益募捐或群内抽奖免费使用 |
|
||||
| Open-source community | Everyone who has submitted pull requests or created themes. |
|
||||
Thanks to everyone who has contributed code, themes, plugins, documentation, translations, bug reports, or feedback to Komari.
|
||||
|
||||
<a href="https://github.com/komari-monitor/komari/graphs/contributors"><img src="https://contributors-img.web.app/image?repo=komari-monitor/komari" alt="Komari contributors" width="600"></a>
|
||||
|
||||
## Support the Project
|
||||
|
||||
If Komari has been useful to you, consider buying me a coffee. Thank you for your support!
|
||||
|
||||
| TRON Network |
|
||||
| ------------------------------------------------------------------------------------------- |
|
||||
|  |
|
||||
+37
-18
@@ -5,45 +5,64 @@
|
||||
|
||||

|
||||
|
||||
[简体中文](./README_zh-cn.md)
|
||||
[English](./README.md) | [简体中文](./README_zh-cn.md)
|
||||
|
||||
Komari 是一款轻量级的自托管服务器监控工具,旨在提供简单、高效的服务器性能监控解决方案。它支持通过 Web 界面查看服务器状态,并通过轻量级 Agent 收集数据。
|
||||
|
||||
> [!WARNING]
|
||||
> Komari 是一款自托管的监控/控制程序,仅应部署在你拥有或已获得授权管理的系统上。请勿将 Komari 武器化,或在未获授权的情况下部署、访问、持久化、执行命令及从事其他滥用行为。关于现实中的滥用风险,可参考 Huntress 的分析:[Komari C2 agent abuse](https://www.huntress.com/blog/komari-c2-agent-abuse)。
|
||||
> 用户需要自行承担部署和使用 Komari 的责任。开发者不对未经授权或滥用行为及其后果承担责任。
|
||||
> 在 Windows 端开启远程控制后,客户端会在每次用户登录时通过 Windows 通知提醒用户 Komari 是一款远程控制软件。
|
||||
> Komari 是一款自托管的监控/控制程序,仅应部署在你拥有或已获得授权管理的系统上。在未获授权的情况下部署、访问、持久化、执行命令及从事其他滥用行为,用户需要自行承担部署和使用 Komari 的责任。开发者不对未经授权或滥用行为及其后果承担责任。
|
||||
|
||||
[文档/Documentation](https://www.komari.wiki/) | [Telegram 群](https://t.me/komari_monitor)
|
||||
[文档](https://www.komari.wiki/) | [Telegram 群](https://t.me/komari_monitor)
|
||||
|
||||
## 特性
|
||||
|
||||
- **实时监控**: 秒级实时数据展示。
|
||||
- **轻量高效**:低资源占用,适合各种规模的服务器。
|
||||
- **自托管**:完全掌控数据隐私,部署简单。
|
||||
- **Web 界面**:直观的监控仪表盘,易于使用。
|
||||
- **极强的可扩展性**: 支持自定义主题和插件。
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 0. 容器云一键部署
|
||||
|
||||
- 雨云云应用 - CNY 4.5/月
|
||||
|
||||
[](https://app.rainyun.com/apps/rca/store/6780/NzYxNzAz_)
|
||||
|
||||
- 1Panel 应用商店
|
||||
|
||||
已上架1Panel应用商店,应用商店-实用工具-Komari 即可安装
|
||||
| 平台 | 介绍 |
|
||||
| -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| <a href="https://app.rainyun.com/apps/rca/store/6780/NzYxNzAz_"><img src="https://rainyun-apps.cn-nb1.rains3.com/materials/deploy-on-rainyun-cn.svg" alt="Rainyun" width="180"></a> | 秒级部署网站、数据库及数百款热门 App,并采用按小时灵活计费。[每月5元,立即部署](https://app.rainyun.com/apps/rca/store/6780/NzYxNzAz_) |
|
||||
| <a href="https://apps.fit2cloud.com/1panel/komari"><img src="https://raw.githubusercontent.com/komari-monitor/public/refs/heads/main/images/1panel-logo-blue.png" alt="1Panel Appstore" width="180"></a> | 现代化、开源的Linux 服务器运维管理面板,提供网站、数据库、容器、文件、备份、安全与AI 管理能力,支持应用商店一键部署。 |
|
||||
|
||||
Docker、二进制文件、源码构建和更新说明,请参阅 [安装指南](https://www.komari.wiki/install/quick-start).
|
||||
|
||||
## 贡献
|
||||
## 截图
|
||||
|
||||
欢迎提交 Issue 或 Pull Request!
|
||||
| 页面 | 截图 |
|
||||
| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| 主页仪表盘 | <img src="https://b2.akz.moe/awesome-pictures/komari-screenshot/%E4%B8%BB%E9%A1%B5%E4%BB%AA%E8%A1%A8%E7%9B%98.webp" width="800" alt="主页仪表盘"> |
|
||||
| 后台仪表盘 | <img src="https://b2.akz.moe/awesome-pictures/komari-screenshot/%E5%90%8E%E5%8F%B0%E4%BB%AA%E8%A1%A8%E7%9B%98.webp" width="800" alt="后台仪表盘"> |
|
||||
| 历史图表 | <img src="https://b2.akz.moe/awesome-pictures/komari-screenshot/%E5%8E%86%E5%8F%B2%E5%9B%BE%E8%A1%A8.webp" width="800" alt="历史图表"> |
|
||||
| 网页终端 | <img src="https://b2.akz.moe/awesome-pictures/komari-screenshot/%E7%BD%91%E9%A1%B5%E7%BB%88%E7%AB%AF.webp" width="800" alt="网页终端"> |
|
||||
| 主题可自定义 | <img src="https://b2.akz.moe/awesome-pictures/komari-screenshot/%E4%B8%BB%E9%A2%98%E5%8F%AF%E8%87%AA%E5%AE%9A%E4%B9%89.webp" width="800" alt="主题可自定义"> |
|
||||
| 主题市场 | <img src="https://b2.akz.moe/awesome-pictures/komari-screenshot/%E4%B8%BB%E9%A2%98%E5%B8%82%E5%9C%BA.webp" width="800" alt="主题市场"> |
|
||||
|
||||
## 鸣谢
|
||||
## 赞助商
|
||||
|
||||
有意赞助 Komari?请通过 [电子邮箱](mailto:komari@akz.moe) 或 [Telegram](https://t.me/mamomoe) 联系开发者。
|
||||
|
||||
| 赞助商 | 描述 |
|
||||
| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| <a href="https://mooproxy.net/?utm_source=komari"><img src="https://raw.githubusercontent.com/komari-monitor/public/refs/heads/main/images/Mooproxy-logo-transparent.png" alt="mooproxy.net" width="180"></a> | [MooProxy · 动态住宅代理IP低至 $0.20/GB,注册即送 500MB 免费流量,立即体验!](https://mooproxy.net/?utm_source=komari) |
|
||||
| <a href="https://axisnow.io/zh?utm=komari"><img src="https://raw.githubusercontent.com/komari-monitor/public/refs/heads/main/images/AxisNow.jpg" alt="AxisNow" width="180"></a> | [自建私有部署CDN \| 订阅式高仿CDN \| 自主可控、灵活组合的CDN网络](https://axisnow.io/zh?utm=komari) |
|
||||
| <a href="https://whmcs.as211392.com/aff.php?aff=110"><img src="https://raw.githubusercontent.com/komari-monitor/public/refs/heads/main/images/dreamcloud.png" alt="Dream Cloud" width="180"></a> | 极高性价比解锁直连亚太高防,真高防,不虚标,打死退款 |
|
||||
| <a href="https://sharon.io"><img src="https://raw.githubusercontent.com/komari-monitor/public/refs/heads/main/images/sharon-networks.webp" alt="Sharon Networks" width="180"></a> | 亚太数据中心提供顶级的中国优化网络接入 · 低延时&高带宽&提供Tbps级本地清洗高防服务, 为您的业务保驾护航, 为您的客户提供极致体验. 加入社区 [Telegram群组](https://t.me/SharonNetwork) 可参与公益募捐或群内抽奖免费使用 |
|
||||
| 开源社区 | 提交 PR、制作主题的各位开发者 |
|
||||
|
||||
## 贡献者
|
||||
|
||||
感谢所有为 Komari 贡献代码、主题、插件、文档、翻译、问题报告或反馈的朋友。
|
||||
|
||||
<a href="https://github.com/komari-monitor/komari/graphs/contributors"><img src="https://contributors-img.web.app/image?repo=komari-monitor/komari" alt="Komari 贡献者" width="600"></a>
|
||||
|
||||
## 支持项目
|
||||
|
||||
如果 Komari 对你有所帮助,欢迎请作者喝一杯奶茶。感谢你的支持!
|
||||
|
||||
| TRON Network |
|
||||
| --------------------------------------------------------------------------- |
|
||||
|  |
|
||||
@@ -1,20 +0,0 @@
|
||||
package flags
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestNormalizeDatabaseType(t *testing.T) {
|
||||
tests := map[string]string{
|
||||
"": DatabaseTypeSQLite,
|
||||
"sqlite": DatabaseTypeSQLite,
|
||||
" SQLite ": DatabaseTypeSQLite,
|
||||
"SQLITE": DatabaseTypeSQLite,
|
||||
"postgres": "postgres",
|
||||
" postgres": "postgres",
|
||||
}
|
||||
|
||||
for input, want := range tests {
|
||||
if got := NormalizeDatabaseType(input); got != want {
|
||||
t.Fatalf("NormalizeDatabaseType(%q) = %q, want %q", input, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
|
||||
appserver "github.com/komari-monitor/komari/internal/server"
|
||||
logger "github.com/komari-monitor/komari/utils/log"
|
||||
frontendpublic "github.com/komari-monitor/komari/web/public"
|
||||
|
||||
"github.com/komari-monitor/komari/cmd/flags"
|
||||
"github.com/spf13/cobra"
|
||||
@@ -32,6 +33,10 @@ func init() {
|
||||
// 具体各阶段的职责与顺序见 internal/server.App。这里只负责串联:
|
||||
// 任一初始化阶段失败即中止启动,避免在半初始化状态下对外提供服务。
|
||||
func RunServer() {
|
||||
if err := frontendpublic.PrepareDefaultDist(); err != nil {
|
||||
logger.Fatalf("server", "prepare default frontend failed: %v", err)
|
||||
}
|
||||
|
||||
app := appserver.New(appserver.Options{ListenAddr: flags.Listen})
|
||||
if err := app.Bootstrap(); err != nil {
|
||||
_ = app.Shutdown()
|
||||
|
||||
@@ -35,21 +35,11 @@ func Enable2Fa(uuid, secret string) error {
|
||||
func Verify2Fa(uuid, code string) (bool, error) {
|
||||
db := dbcore.GetDBInstance()
|
||||
var user models.User
|
||||
err := db.Where("uuid = ?", uuid).First(&user).Error
|
||||
if err != nil {
|
||||
if err := db.Where("uuid = ?", uuid).First(&user).Error; err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
if user.TwoFactor == "" {
|
||||
return false, nil // 用户未启用2FA
|
||||
}
|
||||
|
||||
valid := totp.Validate(code, user.TwoFactor)
|
||||
if !valid {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
return true, nil
|
||||
// 用户未启用2FA时视为校验失败
|
||||
return user.TwoFactor != "" && totp.Validate(code, user.TwoFactor), nil
|
||||
}
|
||||
|
||||
func Disable2Fa(uuid string) error {
|
||||
|
||||
@@ -14,15 +14,12 @@ import (
|
||||
|
||||
const constantSalt = "06Wm4Jv1Hkxx"
|
||||
|
||||
// CheckPassword 检查密码是否正确
|
||||
//
|
||||
// 如果密码正确,返回用户的 UUID 和 true;否则返回空字符串和 false
|
||||
func CheckPassword(username, passwd string) (uuid string, success bool) {
|
||||
// CheckPassword 检查密码,正确时返回用户 UUID 和 true
|
||||
func CheckPassword(username, passwd string) (uuid string, ok bool) {
|
||||
db := dbcore.GetDBInstance()
|
||||
var user models.User
|
||||
result := db.Where("username = ?", username).First(&user)
|
||||
if result.Error != nil {
|
||||
// 静默处理错误,不显示日志
|
||||
return "", false
|
||||
}
|
||||
if hashPasswd(passwd) != user.Passwd {
|
||||
@@ -32,7 +29,7 @@ func CheckPassword(username, passwd string) (uuid string, success bool) {
|
||||
}
|
||||
|
||||
// ForceResetPassword 强制重置用户密码
|
||||
func ForceResetPassword(username, passwd string) (err error) {
|
||||
func ForceResetPassword(username, passwd string) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
result := db.Model(&models.User{}).Where("username = ?", username).Update("passwd", hashPasswd(passwd))
|
||||
if result.Error != nil {
|
||||
@@ -46,82 +43,54 @@ func ForceResetPassword(username, passwd string) (err error) {
|
||||
|
||||
// hashPasswd 对密码进行加盐哈希
|
||||
func hashPasswd(passwd string) string {
|
||||
saltedPassword := passwd + constantSalt
|
||||
hash := sha256.New()
|
||||
hash.Write([]byte(saltedPassword))
|
||||
hashedPassword := base64.StdEncoding.EncodeToString(hash.Sum(nil))
|
||||
return hashedPassword
|
||||
h := sha256.Sum256([]byte(passwd + constantSalt))
|
||||
return base64.StdEncoding.EncodeToString(h[:])
|
||||
}
|
||||
|
||||
func CreateAccount(username, passwd string) (user models.User, err error) {
|
||||
return CreateAccountWithDB(dbcore.GetDBInstance(), username, passwd)
|
||||
}
|
||||
|
||||
func CreateAccountWithDB(db *gorm.DB, username, passwd string) (user models.User, err error) {
|
||||
hashedPassword := hashPasswd(passwd)
|
||||
user = models.User{
|
||||
func CreateAccountWithDB(db *gorm.DB, username, passwd string) (models.User, error) {
|
||||
user := models.User{
|
||||
UUID: uuid.New().String(),
|
||||
Username: username,
|
||||
Passwd: hashedPassword,
|
||||
Passwd: hashPasswd(passwd),
|
||||
}
|
||||
err = db.Create(&user).Error
|
||||
if err != nil {
|
||||
return models.User{}, err
|
||||
}
|
||||
return user, nil
|
||||
return user, db.Create(&user).Error
|
||||
}
|
||||
|
||||
func DeleteAccountByUsername(username string) (err error) {
|
||||
return DeleteAccountByUsernameWithDB(dbcore.GetDBInstance(), username)
|
||||
}
|
||||
|
||||
func DeleteAccountByUsernameWithDB(db *gorm.DB, username string) (err error) {
|
||||
err = db.Where("username = ?", username).Delete(&models.User{}).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
func DeleteAccountByUsernameWithDB(db *gorm.DB, username string) error {
|
||||
return db.Where("username = ?", username).Delete(&models.User{}).Error
|
||||
}
|
||||
|
||||
func GetUserByUUID(uuid string) (user models.User, err error) {
|
||||
func GetUserByUUID(uuid string) (models.User, error) {
|
||||
var user models.User
|
||||
return user, dbcore.GetDBInstance().Where("uuid = ?", uuid).First(&user).Error
|
||||
}
|
||||
|
||||
// GetUserBySSO 通过 SSO 信息获取用户
|
||||
func GetUserBySSO(ssoID string) (models.User, error) {
|
||||
db := dbcore.GetDBInstance()
|
||||
err = db.Where("uuid = ?", uuid).First(&user).Error
|
||||
if err != nil {
|
||||
return models.User{}, err
|
||||
var user models.User
|
||||
if err := db.Where("sso_id = ?", ssoID).First(&user).Error; err != nil {
|
||||
return models.User{}, fmt.Errorf("用户不存在:%s", ssoID)
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// 通过 SSO 信息获取用户
|
||||
func GetUserBySSO(ssoID string) (user models.User, err error) {
|
||||
db := dbcore.GetDBInstance()
|
||||
|
||||
// 首先尝试查找已存在的用户
|
||||
err = db.Where("sso_id = ?", ssoID).First(&user).Error
|
||||
if err == nil {
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// 如果找不到用户,返回明确的错误信息
|
||||
return models.User{}, fmt.Errorf("用户不存在:%s", ssoID)
|
||||
}
|
||||
|
||||
func BindingExternalAccount(uuid string, sso_id string) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
err := db.Model(&models.User{}).Where("uuid = ?", uuid).Update("sso_id", sso_id).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
return db.Model(&models.User{}).Where("uuid = ?", uuid).Update("sso_id", sso_id).Error
|
||||
}
|
||||
|
||||
func UnbindExternalAccount(uuid string) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
err := db.Model(&models.User{}).Where("uuid = ?", uuid).Update("sso_id", "").Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
return db.Model(&models.User{}).Where("uuid = ?", uuid).Update("sso_id", "").Error
|
||||
}
|
||||
|
||||
func UpdateUser(uuid string, name, password, sso_type *string) error {
|
||||
@@ -143,8 +112,7 @@ func UpdateUser(uuid string, name, password, sso_type *string) error {
|
||||
updates["sso_type"] = *sso_type
|
||||
}
|
||||
updates["updated_at"] = time.Now().UTC()
|
||||
err := db.Model(&models.User{}).Where("uuid = ?", uuid).Updates(updates).Error
|
||||
if err != nil {
|
||||
if err := db.Model(&models.User{}).Where("uuid = ?", uuid).Updates(updates).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if password != nil {
|
||||
|
||||
@@ -16,13 +16,10 @@ import (
|
||||
)
|
||||
|
||||
// GetAllSessions 获取所有会话
|
||||
func GetAllSessions() (sessions []models.Session, err error) {
|
||||
func GetAllSessions() ([]models.Session, error) {
|
||||
db := dbcore.GetDBInstance()
|
||||
err = db.Find(&sessions).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return sessions, nil
|
||||
var sessions []models.Session
|
||||
return sessions, db.Find(&sessions).Error
|
||||
}
|
||||
|
||||
// CreateSession 创建新会话
|
||||
@@ -48,7 +45,7 @@ func CreateSession(uuid string, expires int, userAgent, ip, login_method string)
|
||||
if ipinfo != nil && ipinfo.Name != "" {
|
||||
loc = ipinfo.Name
|
||||
}
|
||||
messageSender.SendEvent(models.EventMessage{
|
||||
_ = messageSender.SendNotification(models.EventMessage{
|
||||
Event: messageevent.Login,
|
||||
Time: time.Now().UTC(),
|
||||
Message: fmt.Sprintf("%s: %s (%s)\n%s", login_method, ip, loc, userAgent),
|
||||
@@ -57,19 +54,14 @@ func CreateSession(uuid string, expires int, userAgent, ip, login_method string)
|
||||
}
|
||||
}()
|
||||
|
||||
err := db.Create(&sessionRecord).Error
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return session, nil
|
||||
return session, db.Create(&sessionRecord).Error
|
||||
}
|
||||
|
||||
// GetSession 根据会话 ID 获取 UUID
|
||||
func GetSession(session string) (uuid string, err error) {
|
||||
func GetSession(session string) (string, error) {
|
||||
db := dbcore.GetDBInstance()
|
||||
var sessionRecord models.Session
|
||||
err = db.Where("session = ?", session).First(&sessionRecord).Error
|
||||
if err != nil {
|
||||
if err := db.Where("session = ?", session).First(&sessionRecord).Error; err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
@@ -83,32 +75,22 @@ func GetSession(session string) (uuid string, err error) {
|
||||
}
|
||||
|
||||
func GetUserBySession(session string) (models.User, error) {
|
||||
db := dbcore.GetDBInstance()
|
||||
var sessionRecord models.Session
|
||||
err := db.Where("session = ?", session).First(&sessionRecord).Error
|
||||
if err != nil {
|
||||
if err := dbcore.GetDBInstance().Where("session = ?", session).First(&sessionRecord).Error; err != nil {
|
||||
return models.User{}, err
|
||||
}
|
||||
return GetUserByUUID(sessionRecord.UUID)
|
||||
}
|
||||
|
||||
// DeleteSession 删除指定会话
|
||||
func DeleteSession(session string) (err error) {
|
||||
func DeleteSession(session string) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
result := db.Where("session = ?", session).Delete(&models.Session{})
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
return nil
|
||||
return db.Where("session = ?", session).Delete(&models.Session{}).Error
|
||||
}
|
||||
|
||||
func DeleteAllSessions() error {
|
||||
db := dbcore.GetDBInstance()
|
||||
result := db.Where("1 = 1").Delete(&models.Session{})
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
return nil
|
||||
return db.Where("1 = 1").Delete(&models.Session{}).Error
|
||||
}
|
||||
|
||||
func UpdateLatest(session, useragent, ip string) error {
|
||||
@@ -122,9 +104,5 @@ func UpdateLatest(session, useragent, ip string) error {
|
||||
|
||||
func RemoveExpiredSessions() error {
|
||||
db := dbcore.GetDBInstance()
|
||||
result := db.Where("expires < ?", time.Now().UTC()).Delete(&models.Session{})
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
return nil
|
||||
return db.Where("expires < ?", time.Now().UTC()).Delete(&models.Session{}).Error
|
||||
}
|
||||
+13
-54
@@ -16,12 +16,7 @@ import (
|
||||
)
|
||||
|
||||
func DeleteClient(clientUuid string) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
err := db.Delete(&models.Client{}, "uuid = ?", clientUuid).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
return dbcore.GetDBInstance().Delete(&models.Client{}, "uuid = ?", clientUuid).Error
|
||||
}
|
||||
|
||||
func SaveClientInfo(update map[string]interface{}) error {
|
||||
@@ -31,11 +26,6 @@ func SaveClientInfo(update map[string]interface{}) error {
|
||||
return fmt.Errorf("invalid client UUID")
|
||||
}
|
||||
|
||||
// 确保更新的字段不为空
|
||||
if len(update) == 0 {
|
||||
return fmt.Errorf("no fields to update")
|
||||
}
|
||||
|
||||
update["updated_at"] = time.Now().UTC()
|
||||
|
||||
toFloat64 := func(value interface{}) (float64, bool) {
|
||||
@@ -114,11 +104,7 @@ func SaveClientInfo(update map[string]interface{}) error {
|
||||
return err
|
||||
}
|
||||
|
||||
err := db.Model(&models.Client{}).Where("uuid = ?", clientUUID).Updates(update).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
return db.Model(&models.Client{}).Where("uuid = ?", clientUUID).Updates(update).Error
|
||||
}
|
||||
|
||||
// CreateClient 创建新客户端
|
||||
@@ -135,8 +121,7 @@ func CreateClient() (clientUUID, token string, err error) {
|
||||
UpdatedAt: time.Now().UTC(),
|
||||
}
|
||||
|
||||
err = db.Create(&client).Error
|
||||
if err != nil {
|
||||
if err := db.Create(&client).Error; err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if err := tasks.AddDefaultOnClientUUID(clientUUID); err != nil {
|
||||
@@ -160,8 +145,7 @@ func CreateClientWithName(name string) (clientUUID, token string, err error) {
|
||||
UpdatedAt: time.Now().UTC(),
|
||||
}
|
||||
|
||||
err = db.Create(&client).Error
|
||||
if err != nil {
|
||||
if err := db.Create(&client).Error; err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if err := tasks.AddDefaultOnClientUUID(clientUUID); err != nil {
|
||||
@@ -170,41 +154,25 @@ func CreateClientWithName(name string) (clientUUID, token string, err error) {
|
||||
return clientUUID, token, nil
|
||||
}
|
||||
|
||||
/*
|
||||
// GetAllClients 获取所有客户端配置
|
||||
|
||||
func getAllClients() (clients []models.Client, err error) {
|
||||
db := dbcore.GetDBInstance()
|
||||
err = db.Find(&clients).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return clients, nil
|
||||
}
|
||||
*/
|
||||
func GetClientByUUID(uuid string) (client models.Client, err error) {
|
||||
db := dbcore.GetDBInstance()
|
||||
err = db.Where("uuid = ?", uuid).First(&client).Error
|
||||
if err != nil {
|
||||
func GetClientByUUID(uuid string) (models.Client, error) {
|
||||
var client models.Client
|
||||
if err := dbcore.GetDBInstance().Where("uuid = ?", uuid).First(&client).Error; err != nil {
|
||||
return models.Client{}, err
|
||||
}
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func GetClientTokenByUUID(uuid string) (token string, err error) {
|
||||
db := dbcore.GetDBInstance()
|
||||
func GetClientTokenByUUID(uuid string) (string, error) {
|
||||
var client models.Client
|
||||
err = db.Where("uuid = ?", uuid).First(&client).Error
|
||||
if err != nil {
|
||||
if err := dbcore.GetDBInstance().Where("uuid = ?", uuid).First(&client).Error; err != nil {
|
||||
return "", err
|
||||
}
|
||||
return client.Token, nil
|
||||
}
|
||||
|
||||
func GetAllClientBasicInfo() (clients []models.Client, err error) {
|
||||
db := dbcore.GetDBInstance()
|
||||
err = db.Find(&clients).Error
|
||||
if err != nil {
|
||||
func GetAllClientBasicInfo() ([]models.Client, error) {
|
||||
var clients []models.Client
|
||||
if err := dbcore.GetDBInstance().Find(&clients).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return clients, nil
|
||||
@@ -217,11 +185,6 @@ func SaveClient(updates map[string]interface{}) error {
|
||||
return fmt.Errorf("invalid client UUID")
|
||||
}
|
||||
|
||||
// 确保更新的字段不为空
|
||||
if len(updates) == 0 {
|
||||
return fmt.Errorf("no fields to update")
|
||||
}
|
||||
|
||||
if v, exists := updates["traffic_limit"]; exists {
|
||||
if val, ok := v.(float64); ok {
|
||||
if val < 0 || val > math.MaxInt64-1 {
|
||||
@@ -254,9 +217,5 @@ func SaveClient(updates map[string]interface{}) error {
|
||||
|
||||
updates["updated_at"] = time.Now().UTC()
|
||||
|
||||
err := db.Model(&models.Client{}).Where("uuid = ?", clientUUID).Updates(updates).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
return db.Model(&models.Client{}).Where("uuid = ?", clientUUID).Updates(updates).Error
|
||||
}
|
||||
@@ -6,22 +6,19 @@ import (
|
||||
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
v1 "github.com/komari-monitor/komari/protocol/v1"
|
||||
v2 "github.com/komari-monitor/komari/protocol/v2"
|
||||
)
|
||||
|
||||
func GetClientUUIDByToken(token string) (clientUUID string, err error) {
|
||||
db := dbcore.GetDBInstance()
|
||||
func GetClientUUIDByToken(token string) (string, error) {
|
||||
var client models.Client
|
||||
err = db.Where("token = ?", token).First(&client).Error
|
||||
if err != nil {
|
||||
if err := dbcore.GetDBInstance().Where("token = ?", token).First(&client).Error; err != nil {
|
||||
return "", err
|
||||
}
|
||||
return client.UUID, nil
|
||||
}
|
||||
|
||||
// 检查数据防止异常数据导致数据库损坏
|
||||
func ReportVerify(report v1.Report) error {
|
||||
// 防止输入不合理范围
|
||||
// ReportVerify 检查上报数据,防止异常数据导致数据库损坏
|
||||
func ReportVerify(report v2.Report) error {
|
||||
if report.CPU.Usage < 0 || report.CPU.Usage > 100 {
|
||||
return fmt.Errorf("CPU.Usage must be between 0 and 100")
|
||||
}
|
||||
@@ -56,28 +53,24 @@ func ReportVerify(report v1.Report) error {
|
||||
}
|
||||
|
||||
// [int64] 防止数据溢出
|
||||
// Ram 验证
|
||||
if err := checkInt64("Ram.Used", report.Ram.Used); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := checkInt64("Ram.Total", report.Ram.Total); err != nil {
|
||||
return err
|
||||
}
|
||||
// Swap 验证
|
||||
if err := checkInt64("Swap.Used", report.Swap.Used); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := checkInt64("Swap.Total", report.Swap.Total); err != nil {
|
||||
return err
|
||||
}
|
||||
// Disk 验证
|
||||
if err := checkInt64("Disk.Used", report.Disk.Used); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := checkInt64("Disk.Total", report.Disk.Total); err != nil {
|
||||
return err
|
||||
}
|
||||
// Network 验证
|
||||
if err := checkInt64("Network.Up", report.Network.Up); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -90,7 +83,7 @@ func ReportVerify(report v1.Report) error {
|
||||
if err := checkInt64("Network.TotalDown", report.Network.TotalDown); err != nil {
|
||||
return err
|
||||
}
|
||||
// 拒绝所有负数Int
|
||||
// 拒绝所有负数 Int
|
||||
if report.Process < 0 {
|
||||
return fmt.Errorf("Process must be non-negative: %d", report.Process)
|
||||
}
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
package clipboard
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
)
|
||||
|
||||
// CreateClipboard 创建剪贴板记录
|
||||
func CreateClipboard(cb *models.Clipboard) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
cb.CreatedAt = time.Now().UTC()
|
||||
cb.UpdatedAt = cb.CreatedAt
|
||||
return db.Create(cb).Error
|
||||
}
|
||||
|
||||
// GetClipboardByID 根据ID获取剪贴板记录
|
||||
func GetClipboardByID(id int) (*models.Clipboard, error) {
|
||||
var cb models.Clipboard
|
||||
db := dbcore.GetDBInstance()
|
||||
if err := db.First(&cb, id).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &cb, nil
|
||||
}
|
||||
|
||||
// UpdateClipboardFields 更新剪贴板记录
|
||||
func UpdateClipboardFields(id int, fields map[string]interface{}) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
delete(fields, "created_at")
|
||||
fields["updated_at"] = time.Now().UTC()
|
||||
return db.Model(&models.Clipboard{}).Where("id = ?", id).Updates(fields).Error
|
||||
}
|
||||
|
||||
// DeleteClipboard 删除剪贴板记录
|
||||
func DeleteClipboard(id int) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
// Check if record exists first
|
||||
var cb models.Clipboard
|
||||
if err := db.First(&cb, id).Error; err != nil {
|
||||
return err // Record not found or other error
|
||||
}
|
||||
return db.Delete(&cb).Error
|
||||
}
|
||||
|
||||
// DeleteClipboardBatch 批量删除剪贴板记录
|
||||
func DeleteClipboardBatch(ids []int) error {
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
db := dbcore.GetDBInstance()
|
||||
return db.Where("id IN ?", ids).Delete(&models.Clipboard{}).Error
|
||||
}
|
||||
|
||||
// ListClipboard 列出所有剪贴板记录
|
||||
func ListClipboard() ([]models.Clipboard, error) {
|
||||
var list []models.Clipboard
|
||||
db := dbcore.GetDBInstance()
|
||||
if err := db.Find(&list).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
@@ -186,6 +186,13 @@ const (
|
||||
// versionID 是当前构建的版本标识,由 SetVersionID 在 Initialize 前注入。
|
||||
var versionID string
|
||||
|
||||
// nil means a fresh install; an empty value means an unmarked legacy install.
|
||||
var lastRunVersion *string
|
||||
|
||||
func LastRunVersion() *string {
|
||||
return lastRunVersion
|
||||
}
|
||||
|
||||
// dbFileExistedAtStartup 记录本次进程启动、打开数据库之前 komari.db 是否已存在,
|
||||
// 用于区分“全新安装”与“从旧版升级(无版本标记)”。在 doInitialize 打开数据库
|
||||
// 之前采集。
|
||||
@@ -221,13 +228,17 @@ func resolveDatabaseFile() string {
|
||||
//
|
||||
// 备份失败不阻止启动,但打印明确错误;备份成功(或无需备份)后写入/更新版本。
|
||||
func backupOnVersionUpgrade() {
|
||||
if versionID == "" {
|
||||
return
|
||||
}
|
||||
|
||||
prevVersion, readErr := config.GetAs[string](SystemVersionKey)
|
||||
prevVersion = strings.TrimSpace(prevVersion)
|
||||
versionRecorded := readErr == nil && prevVersion != ""
|
||||
lastRunVersion = nil
|
||||
if versionRecorded || dbFileExistedAtStartup {
|
||||
lastRunVersion = &prevVersion
|
||||
}
|
||||
|
||||
if versionID == "" {
|
||||
return
|
||||
}
|
||||
|
||||
// 版本未变化,无需备份。
|
||||
if versionRecorded && prevVersion == versionID {
|
||||
@@ -444,19 +455,18 @@ func doInitialize() error {
|
||||
// 若升级时旧表仍存在,管理员可通过升级向导显式导入并清理。
|
||||
// models.Record / models.PingRecord / models.GPURecord 结构体仍作为
|
||||
// metric store 的读写 DTO 和旧表导入 DTO 保留在 models 包中。
|
||||
// models.TrafficReportNotification 同样仅为旧数据库兼容保留,不再建表。
|
||||
|
||||
err = instance.AutoMigrate(
|
||||
&models.User{},
|
||||
&models.Client{},
|
||||
&models.Log{},
|
||||
&models.Clipboard{},
|
||||
&models.LoadNotification{},
|
||||
&models.OfflineNotification{},
|
||||
&models.TrafficReportNotification{},
|
||||
&models.PingTask{},
|
||||
&models.OidcProvider{},
|
||||
&models.MessageSenderProvider{},
|
||||
&models.ThemeConfiguration{},
|
||||
&models.PluginConfiguration{},
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create tables: %w", err)
|
||||
|
||||
@@ -5,15 +5,6 @@ import (
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
)
|
||||
|
||||
func GetAllMessageSenderConfigs() []models.MessageSenderProvider {
|
||||
db := dbcore.GetDBInstance()
|
||||
var result []models.MessageSenderProvider
|
||||
if err := db.Find(&result).Error; err != nil {
|
||||
return nil
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func GetMessageSenderConfigByName(name string) (*models.MessageSenderProvider, error) {
|
||||
db := dbcore.GetDBInstance()
|
||||
var config models.MessageSenderProvider
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,152 +0,0 @@
|
||||
package metricstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/komari-monitor/komari/pkg/metric"
|
||||
)
|
||||
|
||||
func TestMigrateBetweenStoresCopiesPersistedRollupsAfterReopen(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
policy := defaultRollupPolicy()
|
||||
sourcePath := filepath.Join(t.TempDir(), "source.db")
|
||||
source, err := metric.Open(ctx, metric.SQLite(sourcePath,
|
||||
metric.WithMaxOpenConns(1),
|
||||
metric.WithMaxIdleConns(1),
|
||||
metric.WithRollupPolicy(policy),
|
||||
))
|
||||
if err != nil {
|
||||
t.Fatalf("open source: %v", err)
|
||||
}
|
||||
|
||||
definitions := []metric.Definition{
|
||||
{Name: "a.metric", Description: "migrated definition", Type: metric.TypeGauge, Unit: "%", RetentionDays: 30, Metadata: map[string]string{"owner": "test"}},
|
||||
{Name: "z.empty", Type: metric.TypeCounter, Unit: "bytes", RetentionDays: 7, Metadata: map[string]string{}},
|
||||
}
|
||||
for _, definition := range definitions {
|
||||
if err := source.UpsertMetric(ctx, definition); err != nil {
|
||||
t.Fatalf("upsert source metric %q: %v", definition.Name, err)
|
||||
}
|
||||
}
|
||||
|
||||
base := time.Date(2026, 7, 26, 12, 0, 0, 0, time.UTC)
|
||||
digest := metric.NewTDigest(policy.Compression)
|
||||
for _, value := range []float64{10, 20, 30} {
|
||||
digest.Add(value, 1)
|
||||
}
|
||||
sourceRollups := []metric.PersistedRollup{
|
||||
{
|
||||
MetricName: "a.metric", EntityID: "agent-1", Tags: map[string]string{"device": "cpu0"}, Labels: map[string]string{"zone": "ap"},
|
||||
Resolution: time.Minute, Bucket: base, Count: 3, Sum: 60, SumSq: 1400, Min: 10, Max: 30,
|
||||
FirstValue: 10, FirstTime: base.Add(time.Second), LastValue: 30, LastTime: base.Add(40 * time.Second),
|
||||
Digest: digest.Encode(), CreatedAt: base.Add(time.Hour),
|
||||
},
|
||||
{
|
||||
MetricName: "a.metric", EntityID: "agent-2", Tags: map[string]string{}, Labels: map[string]string{"zone": "eu"},
|
||||
Resolution: time.Hour, Bucket: base, Count: 12, Sum: 60, SumSq: 300, Min: 5, Max: 5,
|
||||
FirstValue: 5, FirstTime: base.Add(time.Minute), LastValue: 5, LastTime: base.Add(50 * time.Minute),
|
||||
CreatedAt: base.Add(time.Hour),
|
||||
},
|
||||
}
|
||||
if err := source.ImportRollups(ctx, sourceRollups); err != nil {
|
||||
t.Fatalf("seed source rollups: %v", err)
|
||||
}
|
||||
if err := source.Close(); err != nil {
|
||||
t.Fatalf("close source: %v", err)
|
||||
}
|
||||
|
||||
source, err = metric.Open(ctx, metric.SQLite(sourcePath,
|
||||
metric.WithMaxOpenConns(1),
|
||||
metric.WithMaxIdleConns(1),
|
||||
metric.WithRollupPolicy(policy),
|
||||
))
|
||||
if err != nil {
|
||||
t.Fatalf("reopen source: %v", err)
|
||||
}
|
||||
defer source.Close()
|
||||
raw, err := source.Query(ctx, metric.Query{
|
||||
MetricName: "a.metric", Start: base.Add(-time.Hour), End: base.Add(2 * time.Hour),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("query reopened raw window: %v", err)
|
||||
}
|
||||
if len(raw) != 0 {
|
||||
t.Fatalf("reopened source raw points = %d, want 0", len(raw))
|
||||
}
|
||||
|
||||
target, err := metric.Open(ctx, metric.SQLite(filepath.Join(t.TempDir(), "target.db"),
|
||||
metric.WithMaxOpenConns(1),
|
||||
metric.WithMaxIdleConns(1),
|
||||
metric.WithRollupPolicy(policy),
|
||||
))
|
||||
if err != nil {
|
||||
t.Fatalf("open target: %v", err)
|
||||
}
|
||||
defer target.Close()
|
||||
|
||||
type progressEvent struct {
|
||||
metricName string
|
||||
metricIndex, totalMetrics int
|
||||
added int64
|
||||
}
|
||||
var events []progressEvent
|
||||
total, err := migrateBetweenStores(ctx, source, target, func(name string, index, definitions int, added int64) {
|
||||
events = append(events, progressEvent{name, index, definitions, added})
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("migrate stores: %v", err)
|
||||
}
|
||||
if total != int64(len(sourceRollups)) {
|
||||
t.Fatalf("migrated rollups = %d, want %d", total, len(sourceRollups))
|
||||
}
|
||||
wantEvents := []progressEvent{
|
||||
{metricName: "a.metric", metricIndex: 0, totalMetrics: 2},
|
||||
{metricName: "a.metric", metricIndex: 0, totalMetrics: 2, added: 2},
|
||||
{metricName: "z.empty", metricIndex: 1, totalMetrics: 2},
|
||||
}
|
||||
if !reflect.DeepEqual(events, wantEvents) {
|
||||
t.Fatalf("migration events = %#v, want %#v", events, wantEvents)
|
||||
}
|
||||
|
||||
for _, want := range definitions {
|
||||
got, err := target.GetMetric(ctx, want.Name)
|
||||
if err != nil {
|
||||
t.Fatalf("get target metric %q: %v", want.Name, err)
|
||||
}
|
||||
if got.Name != want.Name || got.Description != want.Description || got.Type != want.Type || got.Unit != want.Unit || got.RetentionDays != want.RetentionDays || !reflect.DeepEqual(got.Metadata, want.Metadata) {
|
||||
t.Fatalf("target definition = %#v, want mutable fields from %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
exported := exportMigrationTestRollups(t, target, "a.metric")
|
||||
if !reflect.DeepEqual(exported, sourceRollups) {
|
||||
t.Fatalf("target rollups differ:\n got: %#v\nwant: %#v", exported, sourceRollups)
|
||||
}
|
||||
secondTotal, err := MigrateBetweenStores(ctx, source, target)
|
||||
if err != nil {
|
||||
t.Fatalf("repeat migration: %v", err)
|
||||
}
|
||||
if secondTotal != total {
|
||||
t.Fatalf("repeat migrated rollups = %d, want %d", secondTotal, total)
|
||||
}
|
||||
if got := exportMigrationTestRollups(t, target, "a.metric"); !reflect.DeepEqual(got, sourceRollups) {
|
||||
t.Fatalf("repeat migration changed target rollups: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func exportMigrationTestRollups(t *testing.T, store *metric.Store, metricName string) []metric.PersistedRollup {
|
||||
t.Helper()
|
||||
var result []metric.PersistedRollup
|
||||
_, err := store.ExportRollups(context.Background(), metricName, 1, func(batch []metric.PersistedRollup) error {
|
||||
result = append(result, batch...)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("export %q rollups: %v", metricName, err)
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -1,440 +0,0 @@
|
||||
package metricstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/komari-monitor/komari/pkg/metric"
|
||||
v1 "github.com/komari-monitor/komari/protocol/v1"
|
||||
)
|
||||
|
||||
func useReportTestStore(t *testing.T, policy *metric.RollupPolicy) *metric.Store {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
opts := []metric.Option{metric.WithMaxOpenConns(1)}
|
||||
if policy != nil {
|
||||
opts = append(opts, metric.WithRollupPolicy(*policy))
|
||||
}
|
||||
dsn := fmt.Sprintf("file:report-%d?mode=memory&cache=shared", time.Now().UnixNano())
|
||||
s, err := metric.Open(ctx, metric.SQLite(dsn, opts...))
|
||||
if err != nil {
|
||||
t.Fatalf("open metric store: %v", err)
|
||||
}
|
||||
if err := createMetricDefinitions(ctx, s); err != nil {
|
||||
_ = s.Close()
|
||||
t.Fatalf("create metric definitions: %v", err)
|
||||
}
|
||||
|
||||
storeMu.Lock()
|
||||
previous := store
|
||||
store = s
|
||||
storeMu.Unlock()
|
||||
clearReportTrafficStates()
|
||||
t.Cleanup(func() {
|
||||
clearReportTrafficStates()
|
||||
storeMu.Lock()
|
||||
store = previous
|
||||
storeMu.Unlock()
|
||||
_ = s.Close()
|
||||
})
|
||||
return s
|
||||
}
|
||||
|
||||
func TestWriteReportStoresMinuteMetricsAndResetAwareTraffic(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
policy := defaultRollupPolicy()
|
||||
s := useReportTestStore(t, &policy)
|
||||
base := time.Now().UTC().Truncate(time.Minute).Add(5 * time.Second)
|
||||
now := base.Add(45 * time.Second)
|
||||
|
||||
report := v1.Report{
|
||||
UUID: "node-a",
|
||||
UpdatedAt: base,
|
||||
CPU: v1.CPUReport{Usage: 12.5},
|
||||
Ram: v1.RamReport{Used: 100, Total: 1000},
|
||||
Swap: v1.RamReport{Used: 20, Total: 200},
|
||||
Load: v1.LoadReport{Load1: 0.5},
|
||||
Disk: v1.DiskReport{Used: 300, Total: 3000},
|
||||
Network: v1.NetworkReport{Up: 3, Down: 4, TotalUp: 100, TotalDown: 200},
|
||||
Process: 7,
|
||||
Connections: v1.ConnectionsReport{TCP: 8, UDP: 9},
|
||||
GPU: &v1.GPUDetailReport{
|
||||
AverageUsage: 25,
|
||||
DetailedInfo: []v1.GPUDeviceInfo{{
|
||||
Name: "GPU 0", MemoryUsed: 400, MemoryTotal: 800, Utilization: 30, Temperature: 55,
|
||||
}},
|
||||
},
|
||||
}
|
||||
if _, err := WriteReport(ctx, report); err != nil {
|
||||
t.Fatalf("write first report: %v", err)
|
||||
}
|
||||
|
||||
report.UpdatedAt = base.Add(3 * time.Second)
|
||||
report.Network.TotalUp = 150
|
||||
report.Network.TotalDown = 260
|
||||
if _, err := WriteReport(ctx, report); err != nil {
|
||||
t.Fatalf("write second report: %v", err)
|
||||
}
|
||||
|
||||
report.UpdatedAt = base.Add(6 * time.Second)
|
||||
report.Network.TotalUp = 20
|
||||
report.Network.TotalDown = 30
|
||||
if _, err := WriteReport(ctx, report); err != nil {
|
||||
t.Fatalf("write reset report: %v", err)
|
||||
}
|
||||
|
||||
assertMetricValues(t, s, MetricTrafficUp, report.UUID, base.Add(-time.Second), base.Add(time.Minute), []float64{0, 50, 20})
|
||||
assertMetricValues(t, s, MetricTrafficDown, report.UUID, base.Add(-time.Second), base.Add(time.Minute), []float64{0, 60, 30})
|
||||
assertMetricValues(t, s, MetricNetTotalUp, report.UUID, base.Add(-time.Second), base.Add(time.Minute), []float64{100, 150, 20})
|
||||
assertMetricAggregate(t, s, MetricTrafficUp, report.UUID, base.Add(-time.Second), base.Add(time.Minute), metric.AggSum, 70, 3)
|
||||
assertMetricAggregate(t, s, MetricTrafficDown, report.UUID, base.Add(-time.Second), base.Add(time.Minute), metric.AggSum, 90, 3)
|
||||
|
||||
gpuPoints, err := s.Query(ctx, metric.Query{
|
||||
MetricName: MetricGPUDeviceUsage,
|
||||
EntityID: report.UUID,
|
||||
Start: base.Add(-time.Second),
|
||||
End: base.Add(time.Minute),
|
||||
Tags: map[string]string{"device_index": "0"},
|
||||
Order: metric.OrderAsc,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("query GPU points: %v", err)
|
||||
}
|
||||
if len(gpuPoints) != 3 || !gpuPoints[2].Timestamp.Equal(base.Add(6*time.Second)) || gpuPoints[2].Tags["device_name"] != "GPU 0" {
|
||||
t.Fatalf("unexpected GPU points: %#v", gpuPoints)
|
||||
}
|
||||
|
||||
if _, err := s.Compact(ctx, now); err != nil {
|
||||
t.Fatalf("compact reports: %v", err)
|
||||
}
|
||||
deleteReportTrafficState(report.UUID)
|
||||
report.UpdatedAt = now
|
||||
report.Network.TotalUp = 35
|
||||
report.Network.TotalDown = 50
|
||||
if _, err := WriteReport(ctx, report); err != nil {
|
||||
t.Fatalf("write after restoring rollup baseline: %v", err)
|
||||
}
|
||||
assertMetricValues(t, s, MetricTrafficUp, report.UUID, now.Add(-time.Second), now.Add(time.Second), []float64{15})
|
||||
assertMetricValues(t, s, MetricTrafficDown, report.UUID, now.Add(-time.Second), now.Add(time.Second), []float64{20})
|
||||
}
|
||||
|
||||
func TestWriteReportSkipsMetricsWithoutAgentData(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := useReportTestStore(t, nil)
|
||||
timestamp := time.Now().UTC()
|
||||
if _, err := WriteReport(ctx, v1.Report{
|
||||
UUID: "node-without-gpu", UpdatedAt: timestamp,
|
||||
}); err != nil {
|
||||
t.Fatalf("write report: %v", err)
|
||||
}
|
||||
points, err := s.Query(ctx, metric.Query{
|
||||
MetricName: MetricGPU, EntityID: "node-without-gpu",
|
||||
Start: timestamp.Add(-time.Second), End: timestamp.Add(time.Second),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("query GPU metric: %v", err)
|
||||
}
|
||||
if len(points) != 0 {
|
||||
t.Fatalf("GPU metric was written without GPU data: %#v", points)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReportBatcherFlushesQueuedReports(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := useReportTestStore(t, nil)
|
||||
StartReportBatcher()
|
||||
t.Cleanup(func() {
|
||||
if err := StopReportBatcher(ctx); err != nil {
|
||||
t.Errorf("stop report batcher: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
base := time.Now().UTC().Truncate(time.Minute).Add(10 * time.Second)
|
||||
first := v1.Report{
|
||||
UUID: "batched-node",
|
||||
UpdatedAt: base,
|
||||
CPU: v1.CPUReport{Usage: 10},
|
||||
Network: v1.NetworkReport{TotalUp: 100, TotalDown: 200},
|
||||
}
|
||||
second := first
|
||||
second.UpdatedAt = base.Add(3 * time.Second)
|
||||
second.CPU.Usage = 20
|
||||
second.Network.TotalUp = 150
|
||||
second.Network.TotalDown = 260
|
||||
|
||||
if _, err := WriteReport(ctx, first); err != nil {
|
||||
t.Fatalf("queue first report: %v", err)
|
||||
}
|
||||
if _, err := WriteReport(ctx, second); err != nil {
|
||||
t.Fatalf("queue second report: %v", err)
|
||||
}
|
||||
points, err := s.Query(ctx, metric.Query{
|
||||
MetricName: MetricCPU,
|
||||
EntityID: first.UUID,
|
||||
Start: base.Add(-time.Second),
|
||||
End: base.Add(time.Minute),
|
||||
Order: metric.OrderAsc,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("query before flush: %v", err)
|
||||
}
|
||||
if len(points) != 0 {
|
||||
t.Fatalf("queued reports were written before flush: %#v", points)
|
||||
}
|
||||
|
||||
if err := FlushReportBatch(ctx); err != nil {
|
||||
t.Fatalf("flush report batch: %v", err)
|
||||
}
|
||||
assertMetricValues(t, s, MetricCPU, first.UUID, base.Add(-time.Second), base.Add(time.Minute), []float64{10, 20})
|
||||
assertMetricValues(t, s, MetricTrafficUp, first.UUID, base.Add(-time.Second), base.Add(time.Minute), []float64{0, 50})
|
||||
assertMetricValues(t, s, MetricTrafficDown, first.UUID, base.Add(-time.Second), base.Add(time.Minute), []float64{0, 60})
|
||||
assertMetricAggregate(t, s, MetricCPU, first.UUID, base.Add(-time.Second), base.Add(time.Minute), metric.AggAvg, 15, 2)
|
||||
}
|
||||
|
||||
func TestPingBatcherFlushesLatencyAndLossTogether(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := useReportTestStore(t, nil)
|
||||
StartReportBatcher()
|
||||
t.Cleanup(func() {
|
||||
if err := StopReportBatcher(ctx); err != nil {
|
||||
t.Errorf("stop report batcher: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
base := time.Now().UTC().Truncate(time.Second)
|
||||
records := []models.PingRecord{
|
||||
{Client: "ping-node", TaskId: 7, Time: base, Value: 24},
|
||||
{Client: "ping-node", TaskId: 7, Time: base.Add(time.Minute), Value: -1},
|
||||
{Client: "ping-node", TaskId: 8, Time: base, Value: 31},
|
||||
}
|
||||
for _, record := range records {
|
||||
if err := WritePingRecord(ctx, record); err != nil {
|
||||
t.Fatalf("queue ping record: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
for _, name := range []string{MetricPingLatency, MetricPingLoss} {
|
||||
points, err := s.Query(ctx, metric.Query{
|
||||
MetricName: name,
|
||||
EntityID: "ping-node",
|
||||
Start: base.Add(-time.Second),
|
||||
End: base.Add(2 * time.Minute),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("query queued %s points: %v", name, err)
|
||||
}
|
||||
if len(points) != 0 {
|
||||
t.Fatalf("queued %s points were written before flush: %#v", name, points)
|
||||
}
|
||||
}
|
||||
|
||||
if err := FlushReportBatch(ctx); err != nil {
|
||||
t.Fatalf("flush ping batch: %v", err)
|
||||
}
|
||||
|
||||
latency, err := s.Query(ctx, metric.Query{
|
||||
MetricName: MetricPingLatency,
|
||||
EntityID: "ping-node",
|
||||
Tags: map[string]string{"task_id": "7"},
|
||||
Start: base.Add(-time.Second),
|
||||
End: base.Add(2 * time.Minute),
|
||||
Order: metric.OrderAsc,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("query flushed latency points: %v", err)
|
||||
}
|
||||
if len(latency) != 2 || latency[0].Value != 24 || latency[1].Value != -1 {
|
||||
t.Fatalf("latency points = %#v, want both original samples", latency)
|
||||
}
|
||||
|
||||
loss, err := s.Query(ctx, metric.Query{
|
||||
MetricName: MetricPingLoss,
|
||||
EntityID: "ping-node",
|
||||
Tags: map[string]string{"task_id": "7"},
|
||||
Start: base.Add(-time.Second),
|
||||
End: base.Add(2 * time.Minute),
|
||||
Order: metric.OrderAsc,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("query flushed loss points: %v", err)
|
||||
}
|
||||
if len(loss) != 2 || loss[0].Value != 0 || loss[1].Value != 1 {
|
||||
t.Fatalf("loss points = %#v, want success and loss samples", loss)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReportBatchKeepsEverySample(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := useReportTestStore(t, nil)
|
||||
base := time.Now().UTC().Truncate(time.Second)
|
||||
pending := []v1.Report{
|
||||
{UUID: "node-a", UpdatedAt: base, CPU: v1.CPUReport{Usage: 10}, Network: v1.NetworkReport{TotalUp: 100}},
|
||||
{UUID: "node-a", UpdatedAt: base, CPU: v1.CPUReport{Usage: 20}, Network: v1.NetworkReport{TotalUp: 150}},
|
||||
{UUID: "node-b", UpdatedAt: base, CPU: v1.CPUReport{Usage: 30}, Network: v1.NetworkReport{TotalUp: 200}},
|
||||
{UUID: "node-b", UpdatedAt: base.Add(time.Second), CPU: v1.CPUReport{Usage: 40}, Network: v1.NetworkReport{TotalUp: 260}},
|
||||
}
|
||||
|
||||
if err := writePendingReports(ctx, &pending); err != nil {
|
||||
t.Fatalf("write report batch: %v", err)
|
||||
}
|
||||
if len(pending) != 0 {
|
||||
t.Fatalf("pending reports = %d, want 0", len(pending))
|
||||
}
|
||||
assertMetricValues(t, s, MetricCPU, "node-a", base.Add(-time.Second), base.Add(time.Minute), []float64{10, 20})
|
||||
assertMetricValues(t, s, MetricCPU, "node-b", base.Add(-time.Second), base.Add(time.Minute), []float64{30, 40})
|
||||
}
|
||||
|
||||
func TestReportQueueFullReturnsError(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
useReportTestStore(t, nil)
|
||||
worker := &reportBatchWorker{
|
||||
queue: make(chan v1.Report, 1),
|
||||
requests: make(chan reportBatchRequest, 1),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
worker.queue <- v1.Report{UUID: "already-queued"}
|
||||
reportBatcherMu.Lock()
|
||||
reportBatcher = worker
|
||||
reportBatcherMu.Unlock()
|
||||
t.Cleanup(func() {
|
||||
reportBatcherMu.Lock()
|
||||
if reportBatcher == worker {
|
||||
reportBatcher = nil
|
||||
}
|
||||
reportBatcherMu.Unlock()
|
||||
})
|
||||
|
||||
report := v1.Report{
|
||||
UUID: "realtime-node",
|
||||
UpdatedAt: time.Now().UTC(),
|
||||
}
|
||||
_, err := WriteReport(ctx, report)
|
||||
if !errors.Is(err, ErrReportBatchQueueFull) {
|
||||
t.Fatalf("queue-full error = %v, want %v", err, ErrReportBatchQueueFull)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordReconstructionUsesMetricSpecificAggregation(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := useReportTestStore(t, nil)
|
||||
base := time.Now().UTC().Truncate(time.Minute)
|
||||
entityID := "node-aggregation"
|
||||
points := []metric.Point{
|
||||
{MetricName: MetricCPU, EntityID: entityID, Timestamp: base.Add(time.Second), Value: 10},
|
||||
{MetricName: MetricCPU, EntityID: entityID, Timestamp: base.Add(2 * time.Second), Value: 30},
|
||||
{MetricName: MetricNetTotalUp, EntityID: entityID, Timestamp: base.Add(time.Second), Value: 100},
|
||||
{MetricName: MetricNetTotalUp, EntityID: entityID, Timestamp: base.Add(2 * time.Second), Value: 200},
|
||||
{MetricName: MetricTrafficUp, EntityID: entityID, Timestamp: base.Add(time.Second), Value: 10},
|
||||
{MetricName: MetricTrafficUp, EntityID: entityID, Timestamp: base.Add(2 * time.Second), Value: 20},
|
||||
}
|
||||
if err := s.WriteBatch(ctx, points); err != nil {
|
||||
t.Fatalf("write points: %v", err)
|
||||
}
|
||||
|
||||
records, err := GetRecordsByClientAndTime(ctx, entityID, base, base.Add(time.Hour))
|
||||
if err != nil {
|
||||
t.Fatalf("reconstruct records: %v", err)
|
||||
}
|
||||
if len(records) != 1 {
|
||||
t.Fatalf("records = %#v, want one bucket", records)
|
||||
}
|
||||
if records[0].Cpu != 20 || records[0].NetTotalUp != 200 || records[0].TrafficUp != 30 {
|
||||
t.Fatalf("unexpected aggregation result: %#v", records[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrafficCounterDelta(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
current int64
|
||||
previous int64
|
||||
want int64
|
||||
}{
|
||||
{name: "previous zero", current: 120, previous: 0, want: 120},
|
||||
{name: "monotonic counter", current: 250, previous: 200, want: 50},
|
||||
{name: "unchanged counter", current: 100, previous: 100, want: 0},
|
||||
{name: "counter reset", current: 15, previous: 250, want: 15},
|
||||
{name: "negative current", current: -1, previous: 100, want: 0},
|
||||
{name: "negative previous", current: 15, previous: -1, want: 0},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if got := TrafficCounterDelta(test.current, test.previous); got != test.want {
|
||||
t.Fatalf("TrafficCounterDelta(%d, %d) = %d, want %d", test.current, test.previous, got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteReportNormalizesReceiveTimeToUTC(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := useReportTestStore(t, nil)
|
||||
local := time.FixedZone("UTC+8", 8*60*60)
|
||||
receiveTime := time.Now().In(local).Add(-10 * time.Second)
|
||||
report := v1.Report{
|
||||
UUID: "utc-report",
|
||||
UpdatedAt: receiveTime,
|
||||
CPU: v1.CPUReport{Usage: 10},
|
||||
Network: v1.NetworkReport{TotalUp: 1, TotalDown: 2},
|
||||
}
|
||||
|
||||
saved, err := WriteReport(ctx, report)
|
||||
if err != nil {
|
||||
t.Fatalf("write report: %v", err)
|
||||
}
|
||||
if !saved.UpdatedAt.Equal(receiveTime) || saved.UpdatedAt.Location() != time.UTC {
|
||||
t.Fatalf("saved receive time = %s (%s), want UTC", saved.UpdatedAt, saved.UpdatedAt.Location())
|
||||
}
|
||||
points, err := s.Query(ctx, metric.Query{
|
||||
MetricName: MetricCPU,
|
||||
EntityID: report.UUID,
|
||||
Start: receiveTime.Add(-time.Nanosecond),
|
||||
End: receiveTime.Add(time.Nanosecond),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("query stored point: %v", err)
|
||||
}
|
||||
if len(points) != 1 || points[0].Timestamp.Location() != time.UTC || points[0].Timestamp.UnixMilli() != receiveTime.UnixMilli() {
|
||||
t.Fatalf("stored points = %#v, want one UTC millisecond point", points)
|
||||
}
|
||||
}
|
||||
|
||||
func assertMetricValues(t *testing.T, s *metric.Store, metricName, entityID string, start, end time.Time, want []float64) {
|
||||
t.Helper()
|
||||
points, err := s.Query(context.Background(), metric.Query{
|
||||
MetricName: metricName,
|
||||
EntityID: entityID,
|
||||
Start: start,
|
||||
End: end,
|
||||
Order: metric.OrderAsc,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("query %s: %v", metricName, err)
|
||||
}
|
||||
if len(points) != len(want) {
|
||||
t.Fatalf("%s point count = %d, want %d: %#v", metricName, len(points), len(want), points)
|
||||
}
|
||||
for i := range want {
|
||||
if points[i].Value != want[i] {
|
||||
t.Fatalf("%s point %d = %v, want %v", metricName, i, points[i].Value, want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertMetricAggregate(t *testing.T, s *metric.Store, metricName, entityID string, start, end time.Time, aggregation metric.Aggregation, want float64, wantCount int) {
|
||||
t.Helper()
|
||||
points, err := s.Series(context.Background(), metric.AggregateQuery{
|
||||
Query: metric.Query{MetricName: metricName, EntityID: entityID, Start: start, End: end},
|
||||
Aggregation: aggregation, Interval: time.Minute, PreserveSeries: true,
|
||||
}, end)
|
||||
if err != nil {
|
||||
t.Fatalf("aggregate %s: %v", metricName, err)
|
||||
}
|
||||
if len(points) != 1 || points[0].Value != want || points[0].Count != wantCount {
|
||||
t.Fatalf("aggregate %s = %#v, want value=%v count=%d", metricName, points, want, wantCount)
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
type Clipboard struct {
|
||||
Id int `json:"id" gorm:"primaryKey;autoIncrement;unique"`
|
||||
Text string `json:"text" gorm:"type:longtext"`
|
||||
Name string `json:"name" gorm:"type:varchar(255)"`
|
||||
Weight int `json:"weight" gorm:"type:int"`
|
||||
Remark string `json:"remark" gorm:"type:text"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
@@ -8,7 +8,4 @@ const (
|
||||
Login = "Login"
|
||||
Alert = "Alert"
|
||||
Traffic = "Traffic"
|
||||
DReport = "DReport" // 日报
|
||||
WReport = "WReport" // 周报
|
||||
MReport = "MReport" // 月报
|
||||
)
|
||||
@@ -8,9 +8,9 @@ type MessageSenderProvider struct {
|
||||
}
|
||||
|
||||
type EventMessage struct {
|
||||
Event string `json:"event"`
|
||||
Event any `json:"event"`
|
||||
Clients []Client `json:"clients"`
|
||||
Time time.Time `json:"time"`
|
||||
Message string `json:"message"`
|
||||
Emoji string `json:"emoji"`
|
||||
Message any `json:"message"`
|
||||
Emoji any `json:"emoji"`
|
||||
}
|
||||
@@ -12,18 +12,6 @@ type OfflineNotification struct {
|
||||
LastNotified *time.Time `json:"last_notified"` // 上次通知时间
|
||||
}
|
||||
|
||||
// LoadNotification 定义了基于资源占用达标时间比的负载通知规则
|
||||
type LoadNotification struct {
|
||||
Id uint `json:"id,omitempty" gorm:"primaryKey;autoIncrement"`
|
||||
Name string `json:"name" gorm:"type:varchar(255)"`
|
||||
Clients StringArray `json:"clients" gorm:"type:longtext"`
|
||||
Metric string `json:"metric" gorm:"type:varchar(50);not null;default:'cpu'"` // 监控指标,如 cpu, ram, load
|
||||
Threshold float32 `json:"threshold" gorm:"type:decimal(5,2);not null;default:80.00"` // 阈值百分比
|
||||
Ratio float32 `json:"ratio" gorm:"type:decimal(5,2);not null;default:0.80"` // 达标时间比
|
||||
Interval int `json:"interval" gorm:"type:int;not null;default:15"` // 监测间隔(分钟)
|
||||
LastNotified *time.Time `json:"last_notified"` // 上次通知时间
|
||||
}
|
||||
|
||||
// TrafficReportNotification 定义了流量定时报告的数据库模型
|
||||
type TrafficReportNotification struct {
|
||||
Client string `json:"client" gorm:"type:varchar(36);not null;index;unique;constraint:OnDelete:CASCADE,OnUpdate:CASCADE;foreignKey:client;references:UUID"`
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
package models
|
||||
|
||||
// Plugin is the manifest of an installed plugin (komari-plugin.json).
|
||||
//
|
||||
// Name, Description and Author accept either a plain string or an i18n map
|
||||
// like {"zh_CN": "...", "en": "..."}, mirroring the theme manifest. The
|
||||
// backend passes the value through and the frontend resolves it against the
|
||||
// current locale.
|
||||
type Plugin struct {
|
||||
Name any `json:"name"`
|
||||
Short string `json:"short"` // directory name under data/plugin
|
||||
Description any `json:"description"`
|
||||
Author any `json:"author"`
|
||||
Version string `json:"version"`
|
||||
URL string `json:"url"`
|
||||
Icon string `json:"icon"`
|
||||
Komari string `json:"komari"` // supported server version constraint, e.g. ">=0.0.1"
|
||||
Entry string `json:"entry"` // entry script, defaults to "script.js"
|
||||
Permissions PluginPermissions `json:"permissions"`
|
||||
Configuration Configuration `json:"configuration"` // declared config items, same shape as themes
|
||||
Pages []PluginPage `json:"pages,omitempty"` // injected admin pages
|
||||
}
|
||||
|
||||
// PluginPermissions declares the plugin capabilities that require admin
|
||||
// approval. Every field defaults to its zero value: nothing is granted
|
||||
// unless declared.
|
||||
//
|
||||
// The following capabilities are always granted and never require approval:
|
||||
// reading the plugin's own configuration (server.getConfig), registering
|
||||
// plugin-owned RPC methods (server.registerRPC), and reading/writing files
|
||||
// inside the plugin directory.
|
||||
type PluginPermissions struct {
|
||||
Node bool `json:"node"` // Node.js compatibility modules (runtime setting, not approval-relevant)
|
||||
AllowSystemRPC bool `json:"allowSystemRPC"` // server.call: call system RPC methods with admin authority
|
||||
AllowRoutes bool `json:"allowRoutes"` // server.route: register HTTP routes on the host engine
|
||||
AllowHooks bool `json:"allowHooks"` // server.hook: modify HTTP requests/responses and intercept WebSocket connections/frames
|
||||
AllowHTMLInject bool `json:"allowHTMLInject"` // server.injectHTML: embed CSS/JS into every HTML response
|
||||
AllowExec bool `json:"allowExec"` // child_process: execute child processes
|
||||
AllowListen bool `json:"allowListen"` // net/http servers: listen on local ports
|
||||
AllowAllFileAccess bool `json:"allowAllFileAccess"` // access files outside the plugin directory
|
||||
MaxHTTPBodyBytes int64 `json:"maxHTTPBodyBytes"` // runtime limit, not approval-relevant
|
||||
MaxChildOutputBytes int `json:"maxChildOutputBytes"` // runtime limit, not approval-relevant
|
||||
TimeoutSeconds int `json:"timeout"` // per-turn execution timeout in seconds, not approval-relevant
|
||||
}
|
||||
|
||||
// PageVisibility controls who can reach a plugin page.
|
||||
type PageVisibility string
|
||||
|
||||
const (
|
||||
// PageVisibilityAdmin limits the page to the admin sidebar.
|
||||
PageVisibilityAdmin PageVisibility = "admin"
|
||||
// PageVisibilityPublic serves the page through the public route without
|
||||
// navigation, mirroring the theme public behavior.
|
||||
PageVisibilityPublic PageVisibility = "public"
|
||||
)
|
||||
|
||||
// PageType controls how a plugin page is presented.
|
||||
type PageType string
|
||||
|
||||
const (
|
||||
// PageTypeIframe embeds a static file from the plugin directory.
|
||||
PageTypeIframe PageType = "iframe"
|
||||
// PageTypeRedirect navigates to an internal site path, mirroring the
|
||||
// theme redirect configuration. The URL must stay on the same origin.
|
||||
PageTypeRedirect PageType = "redirect"
|
||||
)
|
||||
|
||||
// PluginPage declares a page provided by the plugin. An iframe page is served
|
||||
// from the plugin directory (File); a redirect page navigates to an internal
|
||||
// site path (URL). Visibility defaults to admin so existing packages keep
|
||||
// their current behavior.
|
||||
type PluginPage struct {
|
||||
File string `json:"file"`
|
||||
Title any `json:"title"`
|
||||
Icon string `json:"icon"`
|
||||
Type PageType `json:"type,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
Visibility PageVisibility `json:"visibility,omitempty"`
|
||||
}
|
||||
|
||||
// PluginConfiguration stores the saved configuration values of one plugin,
|
||||
// mirroring ThemeConfiguration.
|
||||
type PluginConfiguration struct {
|
||||
Short string `json:"short" gorm:"primaryKey;unique;not null"`
|
||||
Data string `json:"data" gorm:"type:longtext" default:"{}"`
|
||||
}
|
||||
@@ -15,16 +15,37 @@ const (
|
||||
|
||||
// Theme represents a komari theme information
|
||||
type Theme struct {
|
||||
Name string `json:"name"` // 主题名称
|
||||
Name any `json:"name"` // 主题名称,支持字符串或多语言对象
|
||||
Short string `json:"short"` // 短名称,用作文件夹名
|
||||
Description string `json:"description"` // 主题描述
|
||||
Description any `json:"description"` // 主题描述,支持字符串或多语言对象
|
||||
Version string `json:"version"` // 版本号
|
||||
Author string `json:"author"` // 作者
|
||||
Author any `json:"author"` // 作者,支持字符串或多语言对象
|
||||
URL string `json:"url"` // 主题URL
|
||||
Preview string `json:"preview"` // 预览图片相对路径
|
||||
Configuration Configuration `json:"configuration"` // 声明配置项
|
||||
}
|
||||
|
||||
// IsLocalizedText reports whether a manifest text field has a usable value.
|
||||
func IsLocalizedText(value any) bool {
|
||||
switch text := value.(type) {
|
||||
case string:
|
||||
return strings.TrimSpace(text) != ""
|
||||
case map[string]any:
|
||||
for _, item := range text {
|
||||
if itemText, ok := item.(string); ok && strings.TrimSpace(itemText) != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
case map[string]string:
|
||||
for _, item := range text {
|
||||
if strings.TrimSpace(item) != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type Configuration struct {
|
||||
Type string `json:"type"` // managed raw redirect
|
||||
Icon string `json:"icon"` // 图标
|
||||
@@ -36,7 +57,7 @@ type ManagedThemeConfigurationItem struct {
|
||||
Key string `json:"key"`
|
||||
Name any `json:"name"`
|
||||
Required bool `json:"required"`
|
||||
Type string `json:"type"` // string number select switch title
|
||||
Type string `json:"type"` // string number select switch title textbox richtext nodes pingtasks
|
||||
Options string `json:"options"`
|
||||
Default any `json:"default"`
|
||||
Help any `json:"help"`
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
package notification
|
||||
|
||||
import (
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/komari-monitor/komari/utils/notifier"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func AddLoadNotification(clients []string, name string, metric string, threshold float32, ratio float32, interval int) (uint, error) {
|
||||
db := dbcore.GetDBInstance()
|
||||
notification := models.LoadNotification{
|
||||
Clients: clients,
|
||||
Name: name,
|
||||
Metric: metric,
|
||||
Threshold: threshold,
|
||||
Ratio: ratio,
|
||||
Interval: interval,
|
||||
}
|
||||
if err := db.Create(¬ification).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return notification.Id, ReloadLoadNotificationSchedule()
|
||||
}
|
||||
func DeleteLoadNotification(id []uint) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
result := db.Where("id IN ?", id).Delete(&models.LoadNotification{})
|
||||
if result.RowsAffected == 0 {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
return ReloadLoadNotificationSchedule()
|
||||
}
|
||||
|
||||
func EditLoadNotification(notifications []*models.LoadNotification) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
for _, notification := range notifications {
|
||||
result := db.Model(&models.LoadNotification{}).Where("id = ?", notification.Id).Updates(notification)
|
||||
if result.RowsAffected == 0 {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
}
|
||||
|
||||
return ReloadLoadNotificationSchedule()
|
||||
}
|
||||
|
||||
func GetAllLoadNotifications() ([]models.LoadNotification, error) {
|
||||
db := dbcore.GetDBInstance()
|
||||
var notifications []models.LoadNotification
|
||||
if err := db.Find(¬ifications).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return notifications, nil
|
||||
}
|
||||
|
||||
func SaveLoadNotification(record models.LoadNotification) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
return db.Create(&record).Error
|
||||
}
|
||||
|
||||
func ReloadLoadNotificationSchedule() error {
|
||||
db := dbcore.GetDBInstance()
|
||||
var loadNotifications []models.LoadNotification
|
||||
if err := db.Find(&loadNotifications).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return notifier.ReloadLoadNotificationSchedule(loadNotifications)
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
package notification
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
func validateTrafficReportNotification(notification models.TrafficReportNotification) error {
|
||||
if notification.Client == "" {
|
||||
return fmt.Errorf("client UUID cannot be empty")
|
||||
}
|
||||
if notification.Enable && !notification.Daily && !notification.Weekly && !notification.Monthly {
|
||||
return fmt.Errorf("at least one cadence must be selected when enabling traffic reports")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ValidateTrafficReportNotifications(notifications []models.TrafficReportNotification) error {
|
||||
for _, notification := range notifications {
|
||||
if err := validateTrafficReportNotification(notification); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildEnabledTrafficReportNotifications(uuids []string, existing []models.TrafficReportNotification) ([]models.TrafficReportNotification, error) {
|
||||
if len(uuids) == 0 {
|
||||
return nil, fmt.Errorf("at least one client UUID is required")
|
||||
}
|
||||
|
||||
existingByClient := make(map[string]models.TrafficReportNotification, len(existing))
|
||||
for _, notification := range existing {
|
||||
existingByClient[notification.Client] = notification
|
||||
}
|
||||
|
||||
notifications := make([]models.TrafficReportNotification, 0, len(uuids))
|
||||
for _, uuid := range uuids {
|
||||
if uuid == "" {
|
||||
return nil, fmt.Errorf("client UUID cannot be empty")
|
||||
}
|
||||
existingNotification, ok := existingByClient[uuid]
|
||||
if !ok || (!existingNotification.Daily && !existingNotification.Weekly && !existingNotification.Monthly) {
|
||||
return nil, fmt.Errorf("at least one cadence must be selected when enabling traffic reports")
|
||||
}
|
||||
notifications = append(notifications, models.TrafficReportNotification{
|
||||
Client: uuid,
|
||||
Enable: true,
|
||||
})
|
||||
}
|
||||
|
||||
return notifications, nil
|
||||
}
|
||||
|
||||
// ListTrafficReportNotifications 获取所有流量定时报告配置(关联客户端信息)
|
||||
func ListTrafficReportNotifications() ([]models.TrafficReportNotification, error) {
|
||||
db := dbcore.GetDBInstance()
|
||||
var notifications []models.TrafficReportNotification
|
||||
err := db.Model(&models.TrafficReportNotification{}).Preload("ClientInfo").Find(¬ifications).Error
|
||||
return notifications, err
|
||||
}
|
||||
|
||||
// EditTrafficReportNotifications 批量更新流量定时报告配置
|
||||
func EditTrafficReportNotifications(notifications []models.TrafficReportNotification) error {
|
||||
if err := ValidateTrafficReportNotifications(notifications); err != nil {
|
||||
return err
|
||||
}
|
||||
db := dbcore.GetDBInstance()
|
||||
return db.Model(&models.TrafficReportNotification{}).
|
||||
Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "client"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{"enable", "daily", "weekly", "monthly"}),
|
||||
}).
|
||||
Select("client", "enable", "daily", "weekly", "monthly").
|
||||
Create(notifications).Error
|
||||
}
|
||||
|
||||
// EnableTrafficReportNotifications 批量启用(仅更新 enable 字段)
|
||||
func EnableTrafficReportNotifications(uuids []string) error {
|
||||
if len(uuids) == 0 {
|
||||
return fmt.Errorf("at least one client UUID is required")
|
||||
}
|
||||
|
||||
db := dbcore.GetDBInstance()
|
||||
var existing []models.TrafficReportNotification
|
||||
if err := db.Where("client IN ?", uuids).Find(&existing).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
notifications, err := buildEnabledTrafficReportNotifications(uuids, existing)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return db.Model(&models.TrafficReportNotification{}).
|
||||
Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "client"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{"enable"}),
|
||||
}).
|
||||
Select("client", "enable").
|
||||
Create(notifications).Error
|
||||
}
|
||||
|
||||
// DisableTrafficReportNotifications 批量禁用
|
||||
func DisableTrafficReportNotifications(uuids []string) error {
|
||||
if len(uuids) == 0 {
|
||||
return fmt.Errorf("at least one client UUID is required")
|
||||
}
|
||||
db := dbcore.GetDBInstance()
|
||||
var notifications []models.TrafficReportNotification
|
||||
for _, uuid := range uuids {
|
||||
if uuid == "" {
|
||||
return fmt.Errorf("client UUID cannot be empty")
|
||||
}
|
||||
notifications = append(notifications, models.TrafficReportNotification{
|
||||
Client: uuid,
|
||||
Enable: false,
|
||||
})
|
||||
}
|
||||
return db.Model(&models.TrafficReportNotification{}).
|
||||
Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "client"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{"enable"}),
|
||||
}).
|
||||
Select("client", "enable").
|
||||
Create(notifications).Error
|
||||
}
|
||||
|
||||
// GetEnabledTrafficReportByType 获取启用了指定类型报告的客户端配置
|
||||
func GetEnabledTrafficReportByType(daily, weekly, monthly bool) ([]models.TrafficReportNotification, error) {
|
||||
db := dbcore.GetDBInstance()
|
||||
var notifications []models.TrafficReportNotification
|
||||
query := db.Model(&models.TrafficReportNotification{}).Where("enable = ?", true)
|
||||
if daily {
|
||||
query = query.Where("daily = ?", true)
|
||||
} else if weekly {
|
||||
query = query.Where("weekly = ?", true)
|
||||
} else if monthly {
|
||||
query = query.Where("monthly = ?", true)
|
||||
}
|
||||
err := query.Find(¬ifications).Error
|
||||
return notifications, err
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
package notification
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
)
|
||||
|
||||
func TestValidateTrafficReportNotificationsRejectsEnabledWithoutCadence(t *testing.T) {
|
||||
err := ValidateTrafficReportNotifications([]models.TrafficReportNotification{{
|
||||
Client: "client-a",
|
||||
Enable: true,
|
||||
}})
|
||||
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestBuildEnabledTrafficReportNotificationsRequiresExistingCadence(t *testing.T) {
|
||||
_, err := buildEnabledTrafficReportNotifications([]string{"client-a"}, nil)
|
||||
assert.Error(t, err)
|
||||
|
||||
_, err = buildEnabledTrafficReportNotifications([]string{"client-a"}, []models.TrafficReportNotification{{Client: "client-a"}})
|
||||
assert.Error(t, err)
|
||||
|
||||
notifications, err := buildEnabledTrafficReportNotifications([]string{"client-a"}, []models.TrafficReportNotification{{
|
||||
Client: "client-a",
|
||||
Daily: true,
|
||||
}})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, notifications, 1)
|
||||
assert.Equal(t, "client-a", notifications[0].Client)
|
||||
assert.True(t, notifications[0].Enable)
|
||||
}
|
||||
+1
-14
@@ -5,15 +5,6 @@ import (
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
)
|
||||
|
||||
func GetAllOidcConfigs() []models.OidcProvider {
|
||||
db := dbcore.GetDBInstance()
|
||||
var result []models.OidcProvider
|
||||
if err := db.Find(&result).Error; err != nil {
|
||||
return nil
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func GetOidcConfigByName(name string) (*models.OidcProvider, error) {
|
||||
db := dbcore.GetDBInstance()
|
||||
var config models.OidcProvider
|
||||
@@ -29,9 +20,5 @@ func DeleteOidcConfigByName(name string) error {
|
||||
}
|
||||
|
||||
func SaveOidcConfig(config *models.OidcProvider) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
if err := db.Save(config).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
return dbcore.GetDBInstance().Save(config).Error
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package records
|
||||
|
||||
import "github.com/komari-monitor/komari/database/models"
|
||||
|
||||
// LoadProjection is the field subset selected by a load_type request.
|
||||
type LoadProjection struct {
|
||||
Cpu *float32 `json:"cpu,omitempty"`
|
||||
Gpu *float32 `json:"gpu,omitempty"`
|
||||
Ram *int64 `json:"ram,omitempty"`
|
||||
RamTotal *int64 `json:"ram_total,omitempty"`
|
||||
Swap *int64 `json:"swap,omitempty"`
|
||||
SwapTotal *int64 `json:"swap_total,omitempty"`
|
||||
Load *float32 `json:"load,omitempty"`
|
||||
Temp *float32 `json:"temp,omitempty"`
|
||||
Disk *int64 `json:"disk,omitempty"`
|
||||
DiskTotal *int64 `json:"disk_total,omitempty"`
|
||||
NetIn *int64 `json:"net_in,omitempty"`
|
||||
NetOut *int64 `json:"net_out,omitempty"`
|
||||
NetTotalUp *int64 `json:"net_total_up,omitempty"`
|
||||
NetTotalDown *int64 `json:"net_total_down,omitempty"`
|
||||
Process *int `json:"process,omitempty"`
|
||||
Connections *int `json:"connections,omitempty"`
|
||||
ConnectionsUdp *int `json:"connections_udp,omitempty"`
|
||||
}
|
||||
|
||||
// ProjectLoad returns the load_type projection for one record.
|
||||
func ProjectLoad(r models.Record, loadType string) LoadProjection {
|
||||
var p LoadProjection
|
||||
switch loadType {
|
||||
case "cpu":
|
||||
p.Cpu = ptr(r.Cpu)
|
||||
case "gpu":
|
||||
p.Gpu = ptr(r.Gpu)
|
||||
case "ram":
|
||||
p.Ram = ptr(r.Ram)
|
||||
p.RamTotal = ptr(r.RamTotal)
|
||||
case "swap":
|
||||
p.Swap = ptr(r.Swap)
|
||||
p.SwapTotal = ptr(r.SwapTotal)
|
||||
case "load":
|
||||
p.Load = ptr(r.Load)
|
||||
case "temp":
|
||||
p.Temp = ptr(r.Temp)
|
||||
case "disk":
|
||||
p.Disk = ptr(r.Disk)
|
||||
p.DiskTotal = ptr(r.DiskTotal)
|
||||
case "network":
|
||||
p.NetIn = ptr(r.NetIn)
|
||||
p.NetOut = ptr(r.NetOut)
|
||||
p.NetTotalUp = ptr(r.NetTotalUp)
|
||||
p.NetTotalDown = ptr(r.NetTotalDown)
|
||||
case "process":
|
||||
p.Process = ptr(r.Process)
|
||||
case "connections":
|
||||
p.Connections = ptr(r.Connections)
|
||||
p.ConnectionsUdp = ptr(r.ConnectionsUdp)
|
||||
default:
|
||||
p.Cpu = ptr(r.Cpu)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func ptr[T any](v T) *T { return &v }
|
||||
@@ -4,8 +4,8 @@ import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/komari-monitor/komari/database/metricstore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/komari-monitor/komari/internal/metricstore"
|
||||
)
|
||||
|
||||
// 历史监控数据已完全迁移到 metric store(默认 SQLite ./data/metrics.db,或配置的
|
||||
@@ -19,16 +19,77 @@ func DeleteAll() error {
|
||||
return metricstore.DeleteAllRecords(context.Background())
|
||||
}
|
||||
|
||||
// GetGPURecordsByClientAndTime 获取 GPU 记录数据。
|
||||
// QueryGPURecords returns GPU records using the caller's cancellation context.
|
||||
func QueryGPURecords(ctx context.Context, uuid string, start, end time.Time) ([]models.GPURecord, error) {
|
||||
return metricstore.GetGPURecordsByClientAndTime(ctx, uuid, start, end)
|
||||
}
|
||||
|
||||
// GetGPURecordsByClientAndTime is a compatibility-only wrapper without cancellation.
|
||||
// New request handlers should use QueryGPURecords.
|
||||
func GetGPURecordsByClientAndTime(uuid string, start, end time.Time) ([]models.GPURecord, error) {
|
||||
return metricstore.GetGPURecordsByClientAndTime(context.Background(), uuid, start, end)
|
||||
return QueryGPURecords(context.Background(), uuid, start, end)
|
||||
}
|
||||
|
||||
func GetRecordsByClientAndTime(uuid string, start, end time.Time) ([]models.Record, error) {
|
||||
return metricstore.GetRecordsByClientAndTime(context.Background(), uuid, start, end)
|
||||
// RecordQueryOptions narrows metric selection and the response point budget.
|
||||
type RecordQueryOptions = metricstore.RecordQueryOptions
|
||||
|
||||
// LoadTypeMetricNames maps the RPC load_type to the stored metrics that back
|
||||
// that field. An empty result means "all load metrics" (including temp and
|
||||
// unknown values, which stay on the compatible full-query path).
|
||||
func LoadTypeMetricNames(loadType string) []string {
|
||||
var names []string
|
||||
switch loadType {
|
||||
case "cpu":
|
||||
return []string{metricstore.MetricCPU}
|
||||
case "gpu":
|
||||
names = []string{metricstore.MetricGPU}
|
||||
case "ram":
|
||||
names = []string{metricstore.MetricRAM}
|
||||
case "swap":
|
||||
names = []string{metricstore.MetricSwap}
|
||||
case "load":
|
||||
names = []string{metricstore.MetricLoad}
|
||||
case "disk":
|
||||
names = []string{metricstore.MetricDisk}
|
||||
case "network":
|
||||
names = []string{metricstore.MetricNetIn, metricstore.MetricNetOut, metricstore.MetricNetTotalUp, metricstore.MetricNetTotalDown}
|
||||
case "process":
|
||||
names = []string{metricstore.MetricProcess}
|
||||
case "connections":
|
||||
names = []string{metricstore.MetricConnections, metricstore.MetricConnectionsUDP}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
// CPU is the common presence anchor: the previous implementation built one
|
||||
// record per timestamp where any load metric existed. Including CPU keeps
|
||||
// those timestamps (and zero values for a missing selected metric) without
|
||||
// going back to all fifteen metrics.
|
||||
return append(names, metricstore.MetricCPU)
|
||||
}
|
||||
|
||||
// GetRecordsByTime 获取所有客户端在时间范围内的记录。
|
||||
func GetRecordsByTime(start, end time.Time) ([]models.Record, error) {
|
||||
return metricstore.GetRecordsByTime(context.Background(), start, end)
|
||||
// QueryRecords returns one client's records, or all clients when uuid is empty.
|
||||
// Request handlers should use this context-aware entry point.
|
||||
func QueryRecords(ctx context.Context, uuid string, start, end time.Time, opts ...RecordQueryOptions) ([]models.Record, error) {
|
||||
if uuid == "" {
|
||||
return metricstore.GetRecordsByTime(ctx, start, end, opts...)
|
||||
}
|
||||
return metricstore.GetRecordsByClientAndTime(ctx, uuid, start, end, opts...)
|
||||
}
|
||||
|
||||
// GetRecordsByClientAndTime is a compatibility-only wrapper without cancellation.
|
||||
// New request handlers should use QueryRecords.
|
||||
func GetRecordsByClientAndTime(uuid string, start, end time.Time, opts ...RecordQueryOptions) ([]models.Record, error) {
|
||||
return metricstore.GetRecordsByClientAndTime(context.Background(), uuid, start, end, opts...)
|
||||
}
|
||||
|
||||
// GetRecordMetricMaxByClientAndTime is a compatibility-only wrapper without cancellation.
|
||||
// New request handlers should use metricstore.GetRecordMetricMaxByClientAndTime.
|
||||
func GetRecordMetricMaxByClientAndTime(uuid, metricName string, start, end time.Time) ([]models.Record, error) {
|
||||
return metricstore.GetRecordMetricMaxByClientAndTime(context.Background(), uuid, metricName, start, end)
|
||||
}
|
||||
|
||||
// GetRecordsByTime is a compatibility-only wrapper without cancellation.
|
||||
// New request handlers should use QueryRecords with an empty uuid.
|
||||
func GetRecordsByTime(start, end time.Time, opts ...RecordQueryOptions) ([]models.Record, error) {
|
||||
return metricstore.GetRecordsByTime(context.Background(), start, end, opts...)
|
||||
}
|
||||
+30
-38
@@ -2,11 +2,12 @@ package tasks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
"slices"
|
||||
"sort"
|
||||
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/database/metricstore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/komari-monitor/komari/internal/metricstore"
|
||||
"github.com/komari-monitor/komari/utils"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
@@ -27,17 +28,8 @@ func AddPingTask(clients []string, defaultOn bool, name string, target, task_typ
|
||||
if err := tx.Create(&task).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Append by id to avoid races between concurrent create requests.
|
||||
result := tx.Model(&models.PingTask{}).Where("id = ?", task.Id).Update("weight", int(task.Id))
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
|
||||
return nil
|
||||
return tx.Model(&models.PingTask{}).Where("id = ?", task.Id).Update("weight", int(task.Id)).Error
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
@@ -106,22 +98,39 @@ func GetAllPingTasks() ([]models.PingTask, error) {
|
||||
func GetPingTasksByClient(uuid string) []models.PingTask {
|
||||
db := dbcore.GetDBInstance()
|
||||
var tasks []models.PingTask
|
||||
if err := db.Where("clients LIKE ?", `%"`+uuid+`"%`).Find(&tasks).Error; err != nil {
|
||||
if err := db.Where("clients LIKE ?", `%"`+uuid+`"%`).Order("weight ASC").Order("id ASC").Find(&tasks).Error; err != nil {
|
||||
return nil
|
||||
}
|
||||
return tasks
|
||||
}
|
||||
|
||||
func UpdatePingTaskOrder(order map[uint]int) error {
|
||||
if len(order) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
db := dbcore.GetDBInstance()
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
for id, weight := range order {
|
||||
result := tx.Model(&models.PingTask{}).Where("id = ?", id).Update("weight", weight)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return gorm.ErrRecordNotFound
|
||||
// Validate all ids before changing any weights. The update response is
|
||||
// not a reliable existence check because some drivers report zero rows
|
||||
// when the new value equals the current value.
|
||||
ids := make([]uint, 0, len(order))
|
||||
for id := range order {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
|
||||
|
||||
var existing int64
|
||||
if err := tx.Model(&models.PingTask{}).Where("id IN ?", ids).Count(&existing).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if existing != int64(len(ids)) {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
|
||||
for _, id := range ids {
|
||||
if err := tx.Model(&models.PingTask{}).Where("id = ?", id).Update("weight", order[id]).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -158,27 +167,14 @@ func ReloadPingSchedule() error {
|
||||
|
||||
// AddDefaultOnClientUUID 在新客户端注册后,把该 UUID 追加到所有 default_on=true 的任务的 clients 中(去重)。
|
||||
func AddDefaultOnClientUUID(uuid string) error {
|
||||
if uuid == "" {
|
||||
return nil
|
||||
}
|
||||
db := dbcore.GetDBInstance()
|
||||
var tasks []models.PingTask
|
||||
if err := db.Where("all_clients = ?", true).Find(&tasks).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(tasks) == 0 {
|
||||
return nil
|
||||
}
|
||||
changed := false
|
||||
for _, task := range tasks {
|
||||
exists := false
|
||||
for _, c := range task.Clients {
|
||||
if c == uuid {
|
||||
exists = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if exists {
|
||||
if slices.Contains(task.Clients, uuid) {
|
||||
continue
|
||||
}
|
||||
next := append(models.StringArray{}, task.Clients...)
|
||||
@@ -193,7 +189,3 @@ func AddDefaultOnClientUUID(uuid string) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func GetPingRecords(uuid string, taskId int, start, end time.Time) ([]models.PingRecord, error) {
|
||||
return metricstore.GetPingRecords(context.Background(), uuid, taskId, start, end)
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package tasks
|
||||
|
||||
import (
|
||||
"math"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/komari-monitor/komari/internal/sampling"
|
||||
)
|
||||
|
||||
// PingClientSummary is the per-client aggregate shared by ping RPC adapters.
|
||||
type PingClientSummary struct {
|
||||
Total int
|
||||
Loss int
|
||||
Min int
|
||||
Max int
|
||||
LossPercent float64
|
||||
}
|
||||
|
||||
// PingTaskSummary is the per-task aggregate shared by ping RPC adapters.
|
||||
type PingTaskSummary struct {
|
||||
Total int
|
||||
Loss int
|
||||
Valid int
|
||||
Min int
|
||||
Max int
|
||||
Avg int
|
||||
Latest int
|
||||
P50 int
|
||||
P99 int
|
||||
LossPercent float64
|
||||
P99P50Ratio float64
|
||||
}
|
||||
|
||||
// PingSummary aggregates records once so legacy and public ping handlers only
|
||||
// differ in response shape.
|
||||
type PingSummary struct {
|
||||
Clients map[string]PingClientSummary
|
||||
Tasks map[uint]PingTaskSummary
|
||||
}
|
||||
|
||||
type pingClientAccumulator struct {
|
||||
total, loss, min, max int
|
||||
}
|
||||
|
||||
type pingTaskAccumulator struct {
|
||||
total, loss, min, max, sum, valid int
|
||||
latest int
|
||||
latestTime time.Time
|
||||
latencies []int
|
||||
}
|
||||
|
||||
// SummarizePingRecords computes client and task aggregates in one pass.
|
||||
func SummarizePingRecords(recs []models.PingRecord) PingSummary {
|
||||
clients := make(map[string]*pingClientAccumulator)
|
||||
taskMap := make(map[uint]*pingTaskAccumulator)
|
||||
for _, r := range recs {
|
||||
cs := clients[r.Client]
|
||||
if cs == nil {
|
||||
cs = &pingClientAccumulator{}
|
||||
clients[r.Client] = cs
|
||||
}
|
||||
cs.total++
|
||||
ts := taskMap[r.TaskId]
|
||||
if ts == nil {
|
||||
ts = &pingTaskAccumulator{latest: -1}
|
||||
taskMap[r.TaskId] = ts
|
||||
}
|
||||
ts.total++
|
||||
if r.Value < 0 {
|
||||
cs.loss++
|
||||
ts.loss++
|
||||
continue
|
||||
}
|
||||
if cs.min == 0 || r.Value < cs.min {
|
||||
cs.min = r.Value
|
||||
}
|
||||
if r.Value > cs.max {
|
||||
cs.max = r.Value
|
||||
}
|
||||
ts.valid++
|
||||
ts.sum += r.Value
|
||||
if ts.min == 0 || r.Value < ts.min {
|
||||
ts.min = r.Value
|
||||
}
|
||||
if r.Value > ts.max {
|
||||
ts.max = r.Value
|
||||
}
|
||||
if ts.latestTime.IsZero() || r.Time.After(ts.latestTime) {
|
||||
ts.latestTime = r.Time
|
||||
ts.latest = r.Value
|
||||
}
|
||||
ts.latencies = append(ts.latencies, r.Value)
|
||||
}
|
||||
|
||||
out := PingSummary{Clients: make(map[string]PingClientSummary, len(clients)), Tasks: make(map[uint]PingTaskSummary, len(taskMap))}
|
||||
for client, v := range clients {
|
||||
loss := 0.0
|
||||
if v.total > 0 {
|
||||
loss = float64(v.loss) / float64(v.total) * 100
|
||||
}
|
||||
out.Clients[client] = PingClientSummary{Total: v.total, Loss: v.loss, Min: v.min, Max: v.max, LossPercent: loss}
|
||||
}
|
||||
for id, v := range taskMap {
|
||||
loss := 0.0
|
||||
if v.total > 0 {
|
||||
loss = float64(v.loss) / float64(v.total) * 100
|
||||
}
|
||||
avg := 0
|
||||
if v.valid > 0 {
|
||||
avg = v.sum / v.valid
|
||||
}
|
||||
p50, p99 := pingPercentiles(v.latencies)
|
||||
ratio := 0.0
|
||||
if p50 > 0 && p99 >= p50 {
|
||||
adjustedBase := math.Max(math.Min(float64(p50), 50.0), 10.0)
|
||||
ratio = float64(p99-p50) / adjustedBase
|
||||
}
|
||||
out.Tasks[id] = PingTaskSummary{
|
||||
Total: v.total, Loss: v.loss, Valid: v.valid, Min: v.min, Max: v.max,
|
||||
Avg: avg, Latest: v.latest, P50: p50, P99: p99, LossPercent: loss, P99P50Ratio: ratio,
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// SamplePingRecordsByTask applies the legacy ping maxCount policy: allocate
|
||||
// points proportionally by task, sample each task evenly and return ascending
|
||||
// by time. Statistics must be computed from the unsampled records.
|
||||
func SamplePingRecordsByTask(recs []models.PingRecord, maxCount int) []models.PingRecord {
|
||||
if maxCount <= 0 || len(recs) <= maxCount {
|
||||
return recs
|
||||
}
|
||||
groups := make(map[uint][]models.PingRecord)
|
||||
for _, r := range recs {
|
||||
groups[r.TaskId] = append(groups[r.TaskId], r)
|
||||
}
|
||||
meta := make([]sampling.Group[uint], 0, len(groups))
|
||||
for id, points := range groups {
|
||||
sort.Slice(points, func(i, j int) bool { return points[i].Time.Before(points[j].Time) })
|
||||
groups[id] = points
|
||||
meta = append(meta, sampling.Group[uint]{Key: id, Length: len(points)})
|
||||
}
|
||||
targets := sampling.AllocateTargets(meta, maxCount)
|
||||
out := make([]models.PingRecord, 0, maxCount)
|
||||
for id, points := range groups {
|
||||
out = append(out, sampling.SampleEvenly(points, targets[id])...)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Time.Before(out[j].Time) })
|
||||
return out
|
||||
}
|
||||
|
||||
func pingPercentiles(values []int) (int, int) {
|
||||
if len(values) == 0 {
|
||||
return 0, 0
|
||||
}
|
||||
sort.Ints(values)
|
||||
return pingPercentile(values, 0.50), pingPercentile(values, 0.99)
|
||||
}
|
||||
|
||||
func pingPercentile(values []int, percentile float64) int {
|
||||
if len(values) == 0 {
|
||||
return 0
|
||||
}
|
||||
if percentile <= 0 {
|
||||
return values[0]
|
||||
}
|
||||
if percentile >= 1 {
|
||||
return values[len(values)-1]
|
||||
}
|
||||
pos := float64(len(values)-1) * percentile
|
||||
lo := int(math.Floor(pos))
|
||||
hi := int(math.Ceil(pos))
|
||||
if lo == hi {
|
||||
return values[lo]
|
||||
}
|
||||
frac := pos - float64(lo)
|
||||
return int(math.Round(float64(values[lo]) + (float64(values[hi])-float64(values[lo]))*frac))
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
|
||||
func CreateTask(taskId string, clients []string, command string) error {
|
||||
db := dbcore.GetDBInstance()
|
||||
// Create a new task in the database with clients as JSON array
|
||||
task := models.Task{
|
||||
TaskId: taskId,
|
||||
Clients: models.StringArray(clients),
|
||||
@@ -29,10 +28,10 @@ func CreateTask(taskId string, clients []string, command string) error {
|
||||
CreatedAt: time.Now().UTC(),
|
||||
})
|
||||
}
|
||||
if len(taskResults) > 0 {
|
||||
return db.Create(&taskResults).Error
|
||||
if len(taskResults) == 0 {
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
return db.Create(&taskResults).Error
|
||||
}
|
||||
func GetTaskByTaskId(taskId string) (*models.Task, error) {
|
||||
var task models.Task
|
||||
|
||||
@@ -66,3 +66,55 @@ func TestGetAllPingTasksOrdersByWeightThenID(t *testing.T) {
|
||||
t.Fatalf("ping task order = %#v, want ids [%d %d %d]", ordered, tasks[1].Id, tasks[2].Id, tasks[0].Id)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdatePingTaskOrderAllowsUnchangedWeights(t *testing.T) {
|
||||
flags.DatabaseType = flags.DatabaseTypeSQLite
|
||||
flags.DatabaseFile = "file:ping_task_update_order?mode=memory&cache=shared"
|
||||
db := dbcore.GetDBInstance()
|
||||
|
||||
items := []models.PingTask{
|
||||
{Name: "first", Weight: 0, Type: "icmp", Target: "first.example", Interval: 60},
|
||||
{Name: "second", Weight: 1, Type: "icmp", Target: "second.example", Interval: 60},
|
||||
}
|
||||
if err := db.Create(&items).Error; err != nil {
|
||||
t.Fatalf("create ping tasks: %v", err)
|
||||
}
|
||||
|
||||
if err := UpdatePingTaskOrder(map[uint]int{
|
||||
items[0].Id: 0,
|
||||
items[1].Id: 2,
|
||||
}); err != nil {
|
||||
t.Fatalf("update ping task order: %v", err)
|
||||
}
|
||||
|
||||
var first, second models.PingTask
|
||||
if err := db.First(&first, items[0].Id).Error; err != nil {
|
||||
t.Fatalf("load first ping task: %v", err)
|
||||
}
|
||||
if err := db.First(&second, items[1].Id).Error; err != nil {
|
||||
t.Fatalf("load second ping task: %v", err)
|
||||
}
|
||||
if first.Weight != 0 || second.Weight != 2 {
|
||||
t.Fatalf("weights = [%d, %d], want [0, 2]", first.Weight, second.Weight)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPingTasksByClientOrdersByWeightThenID(t *testing.T) {
|
||||
flags.DatabaseType = flags.DatabaseTypeSQLite
|
||||
flags.DatabaseFile = "file:ping_task_client_order?mode=memory&cache=shared"
|
||||
db := dbcore.GetDBInstance()
|
||||
|
||||
items := []models.PingTask{
|
||||
{Name: "third", Weight: 2, Clients: models.StringArray{"node-a"}, Type: "icmp", Target: "third.example", Interval: 60},
|
||||
{Name: "first", Weight: 0, Clients: models.StringArray{"node-a"}, Type: "icmp", Target: "first.example", Interval: 60},
|
||||
{Name: "other", Weight: 1, Clients: models.StringArray{"node-b"}, Type: "icmp", Target: "other.example", Interval: 60},
|
||||
}
|
||||
if err := db.Create(&items).Error; err != nil {
|
||||
t.Fatalf("create ping tasks: %v", err)
|
||||
}
|
||||
|
||||
ordered := GetPingTasksByClient("node-a")
|
||||
if len(ordered) != 2 || ordered[0].Id != items[1].Id || ordered[1].Id != items[0].Id {
|
||||
t.Fatalf("ping task order = %#v, want ids [%d %d]", ordered, items[1].Id, items[0].Id)
|
||||
}
|
||||
}
|
||||
+31
-53
@@ -3,16 +3,17 @@ package database
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
logger "github.com/komari-monitor/komari/utils/log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/database/metricstore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/komari-monitor/komari/internal/config"
|
||||
"github.com/komari-monitor/komari/internal/managedconfig"
|
||||
"github.com/komari-monitor/komari/internal/metricstore"
|
||||
logger "github.com/komari-monitor/komari/utils/log"
|
||||
"github.com/komari-monitor/komari/web/public"
|
||||
)
|
||||
|
||||
func GetPublicInfo() (map[string]interface{}, error) {
|
||||
@@ -69,58 +70,20 @@ func GetPublicInfo() (map[string]interface{}, error) {
|
||||
if err != nil {
|
||||
logger.Infof("database", "%v", err)
|
||||
}
|
||||
// Try to load theme declaration file and merge defaults for managed configuration
|
||||
// Theme declarations live in ./data/theme/<short>/komari-theme.json
|
||||
if cst.Theme != "" && cst.Theme != "default" {
|
||||
themeConfigPath := filepath.Join("./data/theme", cst.Theme, "komari-theme.json")
|
||||
if _, err := os.Stat(themeConfigPath); err == nil {
|
||||
b, err := os.ReadFile(themeConfigPath)
|
||||
if err == nil {
|
||||
var themeDecl struct {
|
||||
Configuration struct {
|
||||
Type string `json:"type"`
|
||||
Data []models.ManagedThemeConfigurationItem `json:"data"`
|
||||
} `json:"configuration"`
|
||||
}
|
||||
if err := json.Unmarshal(b, &themeDecl); err == nil {
|
||||
if themeDecl.Configuration.Type == "managed" {
|
||||
for _, item := range themeDecl.Configuration.Data {
|
||||
if item.Key == "" {
|
||||
continue
|
||||
}
|
||||
// missing
|
||||
if _, exists := tc_data[item.Key]; !exists {
|
||||
var def any = item.Default
|
||||
// select
|
||||
if item.Type == "select" {
|
||||
if def == nil || def == "" {
|
||||
if item.Options != "" {
|
||||
opts := strings.Split(item.Options, ",")
|
||||
if len(opts) > 0 {
|
||||
def = strings.TrimSpace(opts[0])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// number->0, string->"", switch->false
|
||||
if def == nil {
|
||||
switch item.Type {
|
||||
case "number":
|
||||
def = 0
|
||||
case "switch":
|
||||
def = false
|
||||
default:
|
||||
def = ""
|
||||
}
|
||||
}
|
||||
tc_data[item.Key] = def
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
items := themeConfigurationItems(cst.Theme)
|
||||
if cst.Theme != "default" {
|
||||
for _, item := range items {
|
||||
if item.Key == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := tc_data[item.Key]; !exists {
|
||||
tc_data[item.Key] = managedconfig.DefaultValue(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := managedconfig.ResolveForOutput(tc_data, items); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return gin.H{
|
||||
"sitename": cst.Sitename,
|
||||
@@ -135,8 +98,23 @@ func GetPublicInfo() (map[string]interface{}, error) {
|
||||
"record_preserve_time": retention.MaxDays * 24,
|
||||
"ping_record_preserve_time": retention.MaxDays * 24,
|
||||
"private_site": cst.PrivateSite,
|
||||
"visitor_audit_enabled": cst.VisitorAuditEnabled,
|
||||
"theme": cst.Theme,
|
||||
"theme_settings": tc_data,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func themeConfigurationItems(short string) []models.ManagedThemeConfigurationItem {
|
||||
var manifest models.Theme
|
||||
if short == "default" {
|
||||
data, err := public.PublicFS.ReadFile("defaultTheme/komari-theme.json")
|
||||
if err != nil || json.Unmarshal(data, &manifest) != nil {
|
||||
return nil
|
||||
}
|
||||
} else {
|
||||
data, err := os.ReadFile(filepath.Join("./data/theme", short, "komari-theme.json"))
|
||||
if err != nil || json.Unmarshal(data, &manifest) != nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return managedconfig.Items(manifest.Configuration)
|
||||
}
|
||||
@@ -3,33 +3,33 @@ module github.com/komari-monitor/komari
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/dop251/goja v0.0.0-20251008123653-cf18d89f3cf6
|
||||
github.com/dop251/goja_nodejs v0.0.0-20251015164255-5e94316bedaf
|
||||
github.com/Calcium-Ion/moejs v0.1.0-alpha.9
|
||||
github.com/gin-gonic/gin v1.10.0
|
||||
github.com/go-sql-driver/mysql v1.10.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/jackc/pgx/v5 v5.10.0
|
||||
github.com/klauspost/compress v1.17.11
|
||||
github.com/mattn/go-sqlite3 v1.14.27
|
||||
github.com/oschwald/maxminddb-golang v1.13.1
|
||||
github.com/patrickmn/go-cache v2.1.0+incompatible
|
||||
github.com/pquerna/otp v1.5.0
|
||||
github.com/spf13/cobra v1.9.1
|
||||
github.com/stretchr/testify v1.11.1
|
||||
golang.org/x/image v0.35.0
|
||||
github.com/stretchr/testify v1.12.1
|
||||
golang.org/x/sys v0.33.0
|
||||
gorm.io/driver/sqlite v1.5.7
|
||||
gorm.io/gorm v1.26.1
|
||||
)
|
||||
|
||||
require golang.org/x/sys v0.33.0 // indirect
|
||||
require go.yaml.in/yaml/v3 v3.0.5 // indirect
|
||||
|
||||
// Misuse of ServerConfig.PublicKeyCallback may cause authorization bypass in golang.org/x/crypto #1
|
||||
// golang.org/x/crypto Vulnerable to Denial of Service (DoS) via Slow or Incomplete Key Exchange #3
|
||||
require golang.org/x/crypto v0.39.0 // indirect
|
||||
require golang.org/x/crypto v0.39.0
|
||||
|
||||
// HTTP Proxy bypass using IPv6 Zone IDs in golang.org/x/net #2
|
||||
// golang.org/x/net vulnerable to Cross-site Scripting #4
|
||||
require golang.org/x/net v0.41.0 // indirect
|
||||
require golang.org/x/net v0.41.0
|
||||
|
||||
require (
|
||||
filippo.io/edwards25519 v1.2.0 // indirect
|
||||
@@ -37,17 +37,13 @@ require (
|
||||
github.com/bytedance/sonic v1.13.2 // indirect
|
||||
github.com/bytedance/sonic/loader v0.2.4 // indirect
|
||||
github.com/cloudwego/base64x v0.1.5 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/dlclark/regexp2 v1.11.4 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
|
||||
github.com/gin-contrib/sse v1.0.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.26.0 // indirect
|
||||
github.com/go-sourcemap/sourcemap v2.1.4+incompatible // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/google/go-cmp v0.7.0 // indirect
|
||||
github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
@@ -62,14 +58,13 @@ require (
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/rogpeppe/go-internal v1.8.0 // indirect
|
||||
github.com/spf13/pflag v1.0.6 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||
golang.org/x/arch v0.15.0 // indirect
|
||||
golang.org/x/sync v0.19.0
|
||||
golang.org/x/text v0.33.0 // indirect
|
||||
golang.org/x/text v0.33.0
|
||||
google.golang.org/protobuf v1.36.6 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
@@ -1,7 +1,7 @@
|
||||
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
|
||||
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
||||
github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0=
|
||||
github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ=
|
||||
github.com/Calcium-Ion/moejs v0.1.0-alpha.9 h1:WY1ebChegtafhPjHSQx3Q+gfnrjqYwHewHuO9k+v7e0=
|
||||
github.com/Calcium-Ion/moejs v0.1.0-alpha.9/go.mod h1:9YqHj36EV7pzV/vlEYSw49f3oggeJFdmqcjM9TmDpHs=
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||
github.com/bytedance/sonic v1.13.2 h1:8/H1FempDZqC4VqjptGo14QQlJx8VdZJegxs6wwfqpQ=
|
||||
@@ -17,12 +17,6 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
|
||||
github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/dop251/goja v0.0.0-20251008123653-cf18d89f3cf6 h1:6dE1TmjqkY6tehR4A67gDNhvDtuZ54ocu7ab4K9o540=
|
||||
github.com/dop251/goja v0.0.0-20251008123653-cf18d89f3cf6/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
|
||||
github.com/dop251/goja_nodejs v0.0.0-20251015164255-5e94316bedaf h1:gbmvliZnCut4NjaPSNOQlfqBoZ9C5Dpf72mHMMYhgVE=
|
||||
github.com/dop251/goja_nodejs v0.0.0-20251015164255-5e94316bedaf/go.mod h1:Tb7Xxye4LX7cT3i8YLvmPMGCV92IOi4CDZvm/V8ylc0=
|
||||
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
|
||||
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
|
||||
github.com/gin-contrib/sse v1.0.0 h1:y3bT1mUWUxDpW4JLQg/HnTqV4rozuW4tC9eFKTxYI9E=
|
||||
@@ -37,8 +31,6 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k=
|
||||
github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
|
||||
github.com/go-sourcemap/sourcemap v2.1.4+incompatible h1:a+iTbH5auLKxaNwQFg0B+TCYl6lbukKPc7b5x0n1s6Q=
|
||||
github.com/go-sourcemap/sourcemap v2.1.4+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
|
||||
github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxoWCWfw=
|
||||
github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
@@ -46,8 +38,6 @@ github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PU
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8 h1:FKHo8hFI3A+7w0aUQuYXQ+6EN5stWmeY/AZqtM8xk9k=
|
||||
github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8/go.mod h1:K1liHPHnj73Fdn/EKuT8nrFqBihUSKXoLYU0BuatOYo=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
@@ -68,6 +58,8 @@ github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
|
||||
github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
|
||||
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
@@ -97,7 +89,6 @@ github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTK
|
||||
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
|
||||
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
|
||||
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
|
||||
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
|
||||
@@ -119,18 +110,18 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
|
||||
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
||||
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
|
||||
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
|
||||
golang.org/x/arch v0.15.0 h1:QtOrQd0bTUnhNVNndMpLHNWrDmYzZ2KDqSrEymqInZw=
|
||||
golang.org/x/arch v0.15.0/go.mod h1:JmwW7aLIoRUKgaTzhkiEFxvcEiQGyOg9BMonBJUS7EE=
|
||||
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
|
||||
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
|
||||
golang.org/x/image v0.35.0 h1:LKjiHdgMtO8z7Fh18nGY6KDcoEtVfsgLDPeLyguqb7I=
|
||||
golang.org/x/image v0.35.0/go.mod h1:MwPLTVgvxSASsxdLzKrl8BRFuyqMyGhLwmC+TO1Sybk=
|
||||
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
|
||||
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
@@ -147,8 +138,6 @@ gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
+1060
-224
File diff suppressed because it is too large.
Load diff
@@ -16,7 +16,7 @@ type Settings struct {
|
||||
AutoDiscoveryKey string `json:"auto_discovery_key" default:""` // 自动发现密钥
|
||||
ScriptDomain string `json:"script_domain" default:""` // 自定义脚本域名
|
||||
SendIpAddrToGuest bool `json:"send_ip_addr_to_guest" default:"false"` // 是否向访客页面发送 IP 地址,默认 false
|
||||
VisitorAuditEnabled bool `json:"visitor_audit_enabled" default:"false"` // 是否允许公开访客事件写入审计日志,默认 false
|
||||
SSRFProtectionEnabled bool `json:"ssrf_protection_enabled" default:"false"` // 是否启用 SSRF 防护,默认关闭
|
||||
EulaAccepted bool `json:"eula_accepted" default:"false"`
|
||||
BaseScriptsURLKey string `json:"base_scripts_url" default:""`
|
||||
// GeoIP 配置
|
||||
@@ -54,7 +54,7 @@ const (
|
||||
AutoDiscoveryKeyKey = "auto_discovery_key"
|
||||
ScriptDomainKey = "script_domain"
|
||||
SendIpAddrToGuestKey = "send_ip_addr_to_guest"
|
||||
VisitorAuditEnabledKey = "visitor_audit_enabled"
|
||||
SSRFProtectionEnabledKey = "ssrf_protection_enabled"
|
||||
EulaAcceptedKey = "eula_accepted"
|
||||
BaseScriptsURLKey = "base_scripts_url"
|
||||
GeoIpEnabledKey = "geo_ip_enabled"
|
||||
@@ -73,6 +73,6 @@ const (
|
||||
LoginNotificationKey = "login_notification"
|
||||
TrafficLimitPercentageKey = "traffic_limit_percentage"
|
||||
UpdatedAtKey = "updated_at"
|
||||
XtermjsSettingsKey = "xtermjs_settings"
|
||||
ThemeMarketSourcesKey = "theme_market_sources"
|
||||
PluginMarketSourcesKey = "plugin_market_sources"
|
||||
)
|
||||
@@ -0,0 +1,144 @@
|
||||
// Package managedconfig implements the shared managed-configuration behavior
|
||||
// used by themes and plugins.
|
||||
package managedconfig
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
)
|
||||
|
||||
const (
|
||||
TypeNodes = "nodes"
|
||||
TypePingTasks = "pingtasks"
|
||||
)
|
||||
|
||||
// Items returns the declared managed configuration items. An omitted type is
|
||||
// managed, matching the theme configuration default.
|
||||
func Items(configuration models.Configuration) []models.ManagedThemeConfigurationItem {
|
||||
if configuration.Type != "" && !strings.EqualFold(configuration.Type, models.ThemeConfigurationManaged) {
|
||||
return nil
|
||||
}
|
||||
raw, err := json.Marshal(configuration.Data)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var items []models.ManagedThemeConfigurationItem
|
||||
if err := json.Unmarshal(raw, &items); err != nil {
|
||||
return nil
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func DefaultValue(item models.ManagedThemeConfigurationItem) any {
|
||||
value := item.Default
|
||||
if item.Type == "select" && (value == nil || value == "") && item.Options != "" {
|
||||
options := strings.Split(item.Options, ",")
|
||||
if len(options) > 0 {
|
||||
return strings.TrimSpace(options[0])
|
||||
}
|
||||
}
|
||||
if value != nil {
|
||||
return value
|
||||
}
|
||||
switch item.Type {
|
||||
case "number":
|
||||
return float64(0)
|
||||
case "switch":
|
||||
return false
|
||||
case TypeNodes, TypePingTasks:
|
||||
return "[]"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// ResolveForOutput decodes declared selectors, drops deleted references, and
|
||||
// returns typed arrays suitable for public theme settings and plugin config.
|
||||
func ResolveForOutput(values map[string]any, items []models.ManagedThemeConfigurationItem) error {
|
||||
hasNodes := false
|
||||
hasPingTasks := false
|
||||
for _, item := range items {
|
||||
switch item.Type {
|
||||
case TypeNodes:
|
||||
hasNodes = true
|
||||
case TypePingTasks:
|
||||
hasPingTasks = true
|
||||
}
|
||||
}
|
||||
|
||||
db := dbcore.GetDBInstance()
|
||||
liveNodes := map[string]struct{}{}
|
||||
if hasNodes {
|
||||
var nodes []models.Client
|
||||
if err := db.Select("uuid").Find(&nodes).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for _, node := range nodes {
|
||||
liveNodes[node.UUID] = struct{}{}
|
||||
}
|
||||
}
|
||||
livePingTasks := map[uint]struct{}{}
|
||||
if hasPingTasks {
|
||||
var tasks []models.PingTask
|
||||
if err := db.Select("id").Find(&tasks).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for _, task := range tasks {
|
||||
livePingTasks[task.Id] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
for _, item := range items {
|
||||
if item.Key == "" {
|
||||
continue
|
||||
}
|
||||
switch item.Type {
|
||||
case TypeNodes:
|
||||
selected := NodeIDs(values[item.Key])
|
||||
filtered := make([]string, 0, len(selected))
|
||||
for _, id := range selected {
|
||||
if _, ok := liveNodes[id]; ok {
|
||||
filtered = append(filtered, id)
|
||||
}
|
||||
}
|
||||
values[item.Key] = filtered
|
||||
case TypePingTasks:
|
||||
selected := PingTaskIDs(values[item.Key])
|
||||
filtered := make([]uint, 0, len(selected))
|
||||
for _, id := range selected {
|
||||
if _, ok := livePingTasks[id]; ok {
|
||||
filtered = append(filtered, id)
|
||||
}
|
||||
}
|
||||
values[item.Key] = filtered
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func NodeIDs(value any) []string {
|
||||
raw, ok := value.(string)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
var ids []string
|
||||
if json.Unmarshal([]byte(raw), &ids) != nil {
|
||||
return nil
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func PingTaskIDs(value any) []uint {
|
||||
raw, ok := value.(string)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
var ids []uint
|
||||
if json.Unmarshal([]byte(raw), &ids) != nil {
|
||||
return nil
|
||||
}
|
||||
return ids
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package metricstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
func Compact(ctx context.Context, now time.Time) (int, error) {
|
||||
if !compactOperations.TryAcquire() {
|
||||
return 0, ErrCompactInProgress
|
||||
}
|
||||
defer compactOperations.Release()
|
||||
if err := storeOperations.AcquireShared(ctx); err != nil {
|
||||
return 0, fmt.Errorf("wait for metric store operation before compaction: %w", err)
|
||||
}
|
||||
defer storeOperations.ReleaseShared()
|
||||
|
||||
storeMu.RLock()
|
||||
defer storeMu.RUnlock()
|
||||
activeStore := store
|
||||
if activeStore == nil {
|
||||
return 0, fmt.Errorf("metric store not initialized")
|
||||
}
|
||||
total, err := activeStore.Flush(ctx, now)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("flush closed metric minutes: %w", err)
|
||||
}
|
||||
coarse, err := activeStore.FlushCoarse(ctx, now)
|
||||
if err != nil {
|
||||
return total, fmt.Errorf("seal closed metric rollups: %w", err)
|
||||
}
|
||||
return total + coarse, nil
|
||||
}
|
||||
|
||||
// CleanupExpired applies retention in a separate, low-frequency operation.
|
||||
// It intentionally shares Compact's gate so delete-heavy maintenance never
|
||||
// overlaps minute or coarse-rollup persistence.
|
||||
func CleanupExpired(ctx context.Context, now time.Time) (int64, error) {
|
||||
if !compactOperations.TryAcquire() {
|
||||
return 0, ErrCompactInProgress
|
||||
}
|
||||
defer compactOperations.Release()
|
||||
if err := storeOperations.AcquireShared(ctx); err != nil {
|
||||
return 0, fmt.Errorf("wait for metric store operation before retention cleanup: %w", err)
|
||||
}
|
||||
defer storeOperations.ReleaseShared()
|
||||
|
||||
storeMu.RLock()
|
||||
defer storeMu.RUnlock()
|
||||
activeStore := store
|
||||
if activeStore == nil {
|
||||
return 0, fmt.Errorf("metric store not initialized")
|
||||
}
|
||||
deleted, err := activeStore.CleanupExpired(ctx, now)
|
||||
if err != nil {
|
||||
return deleted, fmt.Errorf("clean up expired metric data: %w", err)
|
||||
}
|
||||
return deleted, nil
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
package metricstore
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/komari-monitor/komari/pkg/metric"
|
||||
)
|
||||
|
||||
const (
|
||||
// DefaultRollupRawRetention documents the fixed in-memory exact-sample
|
||||
// window. Samples older than one minute are losslessly byte-encoded;
|
||||
// older history is served by the persisted rollup ladder.
|
||||
DefaultRollupRawRetention = 10 * time.Minute
|
||||
DefaultRollupFinestTier = time.Minute
|
||||
defaultRollupPointLimit = 600
|
||||
|
||||
defaultRollupMinuteRetentionMinutes = defaultRollupPointLimit
|
||||
defaultRollupFiveMinuteRetentionMinutes = 5 * defaultRollupPointLimit
|
||||
defaultRollupHourRetentionHours = defaultRollupPointLimit
|
||||
defaultRollupDayRetentionDays = 100 * 365
|
||||
)
|
||||
|
||||
// MetricStoreConfig 保存 metric store 配置。
|
||||
//
|
||||
// 注意:metric store 现在始终启用(旧的 metric_store_enabled 开关已废弃)。
|
||||
// 未显式配置时默认使用 SQLite(./data/metrics.db)。
|
||||
type MetricStoreConfig struct {
|
||||
Driver string `json:"metric_db_driver" default:"sqlite"` // 数据库类型: sqlite, mysql, postgresql
|
||||
DSN string `json:"metric_db_dsn" default:"./data/metrics.db"` // 数据库连接串
|
||||
TablePrefix string `json:"metric_table_prefix" default:"metric_"` // 表名前缀
|
||||
MaxOpenConns int `json:"metric_max_open_conns" default:"25"` // 最大连接数
|
||||
MaxIdleConns int `json:"metric_max_idle_conns" default:"5"` // 最大空闲连接数
|
||||
// RollupMinuteRetentionMinutes controls the persisted 1-minute bucket window.
|
||||
RollupMinuteRetentionMinutes int `json:"metric_rollup_minute_retention_minutes" default:"600"`
|
||||
// RollupFiveMinuteRetentionMinutes controls the persisted 5-minute bucket window.
|
||||
RollupFiveMinuteRetentionMinutes int `json:"metric_rollup_five_minute_retention_minutes" default:"3000"`
|
||||
// RollupHourRetentionHours controls the persisted 1-hour bucket window.
|
||||
RollupHourRetentionHours int `json:"metric_rollup_hour_retention_hours" default:"600"`
|
||||
}
|
||||
|
||||
// MetricStoreConfigKeys 配置键
|
||||
const (
|
||||
MetricDBDriverKey = "metric_db_driver"
|
||||
MetricDBDSNKey = "metric_db_dsn"
|
||||
MetricTablePrefixKey = "metric_table_prefix"
|
||||
MetricMaxOpenConnsKey = "metric_max_open_conns"
|
||||
MetricMaxIdleConnsKey = "metric_max_idle_conns"
|
||||
MetricRollupMinuteRetentionMinutesKey = "metric_rollup_minute_retention_minutes"
|
||||
MetricRollupFiveMinuteRetentionMinutesKey = "metric_rollup_five_minute_retention_minutes"
|
||||
MetricRollupHourRetentionHoursKey = "metric_rollup_hour_retention_hours"
|
||||
// MigrationTargetKey 记录上一次成功完成手动迁移的目标指纹(driver+dsn),
|
||||
// 用于在下一次管理员手动迁移时推断默认源库。
|
||||
MigrationTargetKey = "metric_migration_target"
|
||||
)
|
||||
|
||||
func targetFingerprint(cfg *MetricStoreConfig) string {
|
||||
driver := ResolveDriverFromConfig(cfg.Driver, cfg.DSN)
|
||||
dsn := strings.TrimSpace(cfg.DSN)
|
||||
return fmt.Sprintf("%s|%s", driver, dsn)
|
||||
}
|
||||
|
||||
// buildMetricConfig 根据 MetricStoreConfig 构造底层 metric.Config。
|
||||
// autoMigrate 控制是否在 Open 时自动建表:正式初始化/热加载时为 true,
|
||||
// 仅做连接测试时为 false(不写入 schema,避免对目标库产生副作用)。
|
||||
func buildMetricConfig(cfg *MetricStoreConfig, autoMigrate bool) (metric.Config, error) {
|
||||
driver := ResolveDriverFromConfig(cfg.Driver, cfg.DSN)
|
||||
|
||||
tablePrefix := cfg.TablePrefix
|
||||
if tablePrefix == "" {
|
||||
tablePrefix = "metric_"
|
||||
}
|
||||
opts := []metric.Option{
|
||||
metric.WithTablePrefix(tablePrefix),
|
||||
metric.WithAutoMigrate(autoMigrate),
|
||||
}
|
||||
policy, err := rollupPolicyFromConfig(cfg)
|
||||
if err != nil {
|
||||
return metric.Config{}, err
|
||||
}
|
||||
opts = append(opts, metric.WithRollupPolicy(policy))
|
||||
|
||||
switch driver {
|
||||
case metric.DriverSQLite:
|
||||
dsn := cfg.DSN
|
||||
if dsn == "" || dsn == "./data/metrics.db" {
|
||||
// 注意:刻意不使用 cache=shared。SQLite 共享缓存模式使用表级锁,
|
||||
// 当一个连接持有读锁、另一个连接尝试写入时会立即返回
|
||||
// SQLITE_LOCKED("database table is locked"),且 busy_timeout
|
||||
// 对共享缓存的表级锁无效,迁移期间与前台查询/实时写入并发时必然报错。
|
||||
// _txlock=immediate 让写事务开始即获取写锁,避免锁升级死锁。
|
||||
dsn = "file:./data/metrics.db?mode=rwc&_txlock=immediate"
|
||||
} else {
|
||||
// 用户自定义 DSN 时,剥离 cache=shared,避免上述表级锁问题。
|
||||
dsn = stripSharedCache(dsn)
|
||||
}
|
||||
// SQLite 串行化写入:固定单写连接以避免 "database is locked" 竞争,
|
||||
// 同时启用独立的 WAL 只读连接池提升前台查询并发(写仍走单主连接)。
|
||||
// 这里刻意忽略 cfg.MaxOpenConns/MaxIdleConns —— 对 SQLite 而言多写连接
|
||||
// 只会引入锁竞争而非提升吞吐。
|
||||
opts = append(opts, metric.WithMaxOpenConns(1), metric.WithMaxIdleConns(1))
|
||||
opts = append(opts, metric.WithSQLiteReadPool(2))
|
||||
return metric.SQLite(dsn, opts...), nil
|
||||
case metric.DriverMySQL:
|
||||
opts = append(opts,
|
||||
metric.WithMaxOpenConns(cfg.MaxOpenConns),
|
||||
metric.WithMaxIdleConns(cfg.MaxIdleConns),
|
||||
)
|
||||
return metric.MySQL(cfg.DSN, opts...), nil
|
||||
case metric.DriverPostgreSQL:
|
||||
opts = append(opts,
|
||||
metric.WithMaxOpenConns(cfg.MaxOpenConns),
|
||||
metric.WithMaxIdleConns(cfg.MaxIdleConns),
|
||||
)
|
||||
return metric.PostgreSQL(cfg.DSN, opts...), nil
|
||||
default:
|
||||
return metric.Config{}, fmt.Errorf("unsupported metric database driver: %s", cfg.Driver)
|
||||
}
|
||||
}
|
||||
|
||||
func defaultRollupPolicy() metric.RollupPolicy {
|
||||
return rollupPolicyFromValues(
|
||||
defaultRollupMinuteRetentionMinutes,
|
||||
defaultRollupFiveMinuteRetentionMinutes,
|
||||
defaultRollupHourRetentionHours,
|
||||
)
|
||||
}
|
||||
|
||||
func rollupPolicyFromConfig(cfg *MetricStoreConfig) (metric.RollupPolicy, error) {
|
||||
minuteRetention := cfg.RollupMinuteRetentionMinutes
|
||||
fiveMinuteRetention := cfg.RollupFiveMinuteRetentionMinutes
|
||||
hourRetention := cfg.RollupHourRetentionHours
|
||||
// Configs constructed by older callers do not have the new fields. Treat
|
||||
// their zero values as omitted so recovery and migration remain compatible.
|
||||
if minuteRetention == 0 {
|
||||
minuteRetention = defaultRollupMinuteRetentionMinutes
|
||||
}
|
||||
if fiveMinuteRetention == 0 {
|
||||
fiveMinuteRetention = defaultRollupFiveMinuteRetentionMinutes
|
||||
}
|
||||
if hourRetention == 0 {
|
||||
hourRetention = defaultRollupHourRetentionHours
|
||||
}
|
||||
if minuteRetention < 0 || fiveMinuteRetention < 0 || hourRetention < 0 {
|
||||
return metric.RollupPolicy{}, fmt.Errorf("metric rollup retention values must be positive integers")
|
||||
}
|
||||
|
||||
minuteDuration, err := rollupDuration(minuteRetention, time.Minute)
|
||||
if err != nil {
|
||||
return metric.RollupPolicy{}, err
|
||||
}
|
||||
fiveMinuteDuration, err := rollupDuration(fiveMinuteRetention, time.Minute)
|
||||
if err != nil {
|
||||
return metric.RollupPolicy{}, err
|
||||
}
|
||||
hourDuration, err := rollupDuration(hourRetention, time.Hour)
|
||||
if err != nil {
|
||||
return metric.RollupPolicy{}, err
|
||||
}
|
||||
|
||||
policy := rollupPolicyFromDurations(minuteDuration, fiveMinuteDuration, hourDuration)
|
||||
if err := policy.Validate(); err != nil {
|
||||
return metric.RollupPolicy{}, fmt.Errorf("invalid metric rollup retention policy: %w", err)
|
||||
}
|
||||
return policy, nil
|
||||
}
|
||||
|
||||
func rollupPolicyFromValues(minuteRetentionMinutes, fiveMinuteRetentionMinutes, hourRetentionHours int) metric.RollupPolicy {
|
||||
return rollupPolicyFromDurations(
|
||||
time.Duration(minuteRetentionMinutes)*time.Minute,
|
||||
time.Duration(fiveMinuteRetentionMinutes)*time.Minute,
|
||||
time.Duration(hourRetentionHours)*time.Hour,
|
||||
)
|
||||
}
|
||||
|
||||
func rollupPolicyFromDurations(minuteRetention, fiveMinuteRetention, hourRetention time.Duration) metric.RollupPolicy {
|
||||
return metric.RollupPolicy{
|
||||
RawRetention: DefaultRollupRawRetention,
|
||||
Tiers: []metric.RollupTier{
|
||||
{Interval: time.Minute, Retention: minuteRetention},
|
||||
{Interval: 5 * time.Minute, Retention: fiveMinuteRetention},
|
||||
{Interval: time.Hour, Retention: hourRetention},
|
||||
// Daily buckets form the terminal tier. They remain available until
|
||||
// the metric's own retention policy removes them.
|
||||
{Interval: 24 * time.Hour, Retention: time.Duration(defaultRollupDayRetentionDays) * 24 * time.Hour},
|
||||
},
|
||||
Compression: 30,
|
||||
}
|
||||
}
|
||||
|
||||
func rollupDuration(value int, unit time.Duration) (time.Duration, error) {
|
||||
maxDurationValue := int64((time.Duration(1<<63 - 1)) / unit)
|
||||
if value <= 0 || int64(value) > maxDurationValue {
|
||||
return 0, fmt.Errorf("metric rollup retention value must be a positive duration")
|
||||
}
|
||||
return time.Duration(value) * unit, nil
|
||||
}
|
||||
|
||||
// ResolveDriverFromConfig 根据 DSN 自动推断 metrics 数据库类型;当 DSN 不能可靠
|
||||
// 识别时回退到旧配置中的 driver,以兼容已有配置和非常规 DSN。
|
||||
func ResolveDriverFromConfig(configuredDriver, dsn string) metric.Driver {
|
||||
if driver, ok := InferDriverFromDSN(dsn); ok {
|
||||
return driver
|
||||
}
|
||||
|
||||
switch driver := metric.Driver(strings.ToLower(strings.TrimSpace(configuredDriver))); driver {
|
||||
case metric.DriverSQLite, metric.DriverMySQL, metric.DriverPostgreSQL:
|
||||
return driver
|
||||
default:
|
||||
return metric.DriverSQLite
|
||||
}
|
||||
}
|
||||
|
||||
// InferDriverFromDSN 尽量根据常见 DSN 格式推断数据库类型。
|
||||
// 返回 ok=false 表示格式不够明确,调用方应使用已有配置作为兜底。
|
||||
func InferDriverFromDSN(dsn string) (metric.Driver, bool) {
|
||||
raw := strings.TrimSpace(dsn)
|
||||
if raw == "" {
|
||||
return metric.DriverSQLite, true
|
||||
}
|
||||
lower := strings.ToLower(raw)
|
||||
|
||||
// PostgreSQL URL DSN: postgres://... 或 postgresql://...
|
||||
if strings.HasPrefix(lower, "postgres://") || strings.HasPrefix(lower, "postgresql://") {
|
||||
return metric.DriverPostgreSQL, true
|
||||
}
|
||||
|
||||
// SQLite 常见文件/内存 DSN。
|
||||
if raw == ":memory:" || strings.HasPrefix(lower, "file:") || strings.HasPrefix(lower, "sqlite://") || strings.HasPrefix(lower, "sqlite3://") {
|
||||
return metric.DriverSQLite, true
|
||||
}
|
||||
|
||||
// MySQL URL(虽然 go-sql-driver/mysql 原生 DSN 通常不是 URL,但这里用于给出
|
||||
// 类型推断;连接测试仍会校验 DSN 是否可被驱动接受)。
|
||||
if strings.HasPrefix(lower, "mysql://") {
|
||||
return metric.DriverMySQL, true
|
||||
}
|
||||
|
||||
// PostgreSQL 关键字/值 DSN: host=... user=... dbname=...
|
||||
if looksLikePostgreSQLKeyValueDSN(lower) {
|
||||
return metric.DriverPostgreSQL, true
|
||||
}
|
||||
|
||||
// go-sql-driver/mysql DSN: user:pass@tcp(host:3306)/db、user@unix(...)/db、user:pass@/db 等。
|
||||
if looksLikeMySQLDSN(lower) {
|
||||
return metric.DriverMySQL, true
|
||||
}
|
||||
|
||||
// SQLite 路径:./data/metrics.db、/var/lib/metrics.sqlite3、metrics.sqlite 等。
|
||||
if looksLikeSQLitePath(lower) {
|
||||
return metric.DriverSQLite, true
|
||||
}
|
||||
|
||||
return "", false
|
||||
}
|
||||
|
||||
func looksLikePostgreSQLKeyValueDSN(lower string) bool {
|
||||
if !strings.Contains(lower, "=") || strings.Contains(lower, "://") {
|
||||
return false
|
||||
}
|
||||
keys := []string{"host=", "user=", "password=", "dbname=", "port=", "sslmode="}
|
||||
matched := 0
|
||||
for _, key := range keys {
|
||||
if strings.Contains(lower, key) {
|
||||
matched++
|
||||
}
|
||||
}
|
||||
// dbname= 基本是 PostgreSQL libpq DSN 的强特征;否则至少匹配两个常见键。
|
||||
return strings.Contains(lower, "dbname=") || matched >= 2
|
||||
}
|
||||
|
||||
func looksLikeMySQLDSN(lower string) bool {
|
||||
if strings.Contains(lower, "://") || strings.Contains(lower, " ") {
|
||||
return false
|
||||
}
|
||||
if strings.Contains(lower, "@tcp(") || strings.Contains(lower, "@unix(") || strings.Contains(lower, "@/") {
|
||||
return true
|
||||
}
|
||||
// user:pass@host/db、user@host/db 这类虽然不是推荐格式,但也明显偏 MySQL。
|
||||
return strings.Contains(lower, "@") && strings.Contains(lower, "/")
|
||||
}
|
||||
|
||||
func looksLikeSQLitePath(lower string) bool {
|
||||
path := lower
|
||||
if idx := strings.IndexAny(path, "?"); idx >= 0 {
|
||||
path = path[:idx]
|
||||
}
|
||||
return strings.HasSuffix(path, ".db") || strings.HasSuffix(path, ".sqlite") || strings.HasSuffix(path, ".sqlite3")
|
||||
}
|
||||
|
||||
// stripSharedCache 从 SQLite DSN 中移除 cache=shared 参数,避免共享缓存模式下的
|
||||
// 表级锁(SQLITE_LOCKED "database table is locked")。其它参数保持不变。
|
||||
func stripSharedCache(dsn string) string {
|
||||
if !strings.Contains(dsn, "cache=shared") {
|
||||
return dsn
|
||||
}
|
||||
idx := strings.Index(dsn, "?")
|
||||
if idx < 0 {
|
||||
return dsn
|
||||
}
|
||||
base := dsn[:idx]
|
||||
query := dsn[idx+1:]
|
||||
parts := strings.Split(query, "&")
|
||||
kept := parts[:0]
|
||||
for _, p := range parts {
|
||||
if p == "cache=shared" {
|
||||
continue
|
||||
}
|
||||
kept = append(kept, p)
|
||||
}
|
||||
if len(kept) == 0 {
|
||||
return base
|
||||
}
|
||||
return base + "?" + strings.Join(kept, "&")
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package metricstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/komari-monitor/komari/pkg/metric"
|
||||
)
|
||||
|
||||
const defaultBuiltinMetricRetentionDays = 1
|
||||
|
||||
// RetentionSummary is the compatibility view of all persisted metric policies.
|
||||
type RetentionSummary struct {
|
||||
AllPositive bool
|
||||
MaxDays int
|
||||
}
|
||||
|
||||
// GetRetentionSummary aggregates the active store's metric definitions. An
|
||||
// empty definition set is not considered record-enabled.
|
||||
func GetRetentionSummary(ctx context.Context) (RetentionSummary, error) {
|
||||
s := GetStore()
|
||||
if s == nil {
|
||||
return RetentionSummary{}, fmt.Errorf("metric store not initialized")
|
||||
}
|
||||
defs, err := s.ListMetrics(ctx)
|
||||
if err != nil {
|
||||
return RetentionSummary{}, err
|
||||
}
|
||||
return summarizeRetentionDefinitions(defs), nil
|
||||
}
|
||||
|
||||
func summarizeRetentionDefinitions(defs []metric.Definition) RetentionSummary {
|
||||
if len(defs) == 0 {
|
||||
return RetentionSummary{}
|
||||
}
|
||||
summary := RetentionSummary{AllPositive: true}
|
||||
for _, def := range defs {
|
||||
if def.RetentionDays <= 0 {
|
||||
summary.AllPositive = false
|
||||
}
|
||||
if def.RetentionDays > summary.MaxDays {
|
||||
summary.MaxDays = def.RetentionDays
|
||||
}
|
||||
}
|
||||
return summary
|
||||
}
|
||||
|
||||
// createMetricDefinitions creates built-in definitions with explicit policies.
|
||||
func createMetricDefinitions(ctx context.Context, s *metric.Store) error {
|
||||
return createMetricDefinitionsWithDefaultRetention(ctx, s, defaultBuiltinMetricRetentionDays)
|
||||
}
|
||||
|
||||
// EnsureBuiltinMetricDefinitions registers definitions for the server's
|
||||
// built-in report and ping writers before a standalone Store receives points.
|
||||
func EnsureBuiltinMetricDefinitions(ctx context.Context, s *metric.Store) error {
|
||||
return createMetricDefinitions(ctx, s)
|
||||
}
|
||||
|
||||
func createMetricDefinitionsWithDefaultRetention(ctx context.Context, s *metric.Store, defaultRetentionDays int) error {
|
||||
if defaultRetentionDays < defaultBuiltinMetricRetentionDays {
|
||||
defaultRetentionDays = defaultBuiltinMetricRetentionDays
|
||||
}
|
||||
definitions := []metric.Definition{
|
||||
{Name: MetricCPU, Type: metric.TypeGauge, Unit: "%", Description: "CPU usage percentage", RetentionDays: defaultRetentionDays},
|
||||
{Name: MetricGPU, Type: metric.TypeGauge, Unit: "%", Description: "GPU usage percentage", RetentionDays: defaultRetentionDays},
|
||||
{Name: MetricGPUDeviceUsage, Type: metric.TypeGauge, Unit: "%", Description: "Per-device GPU utilization", RetentionDays: defaultRetentionDays},
|
||||
{Name: MetricGPUMem, Type: metric.TypeGauge, Unit: "bytes", Description: "GPU memory used", RetentionDays: defaultRetentionDays},
|
||||
{Name: MetricGPUMemTotal, Type: metric.TypeGauge, Unit: "bytes", Description: "GPU memory total", RetentionDays: defaultRetentionDays},
|
||||
{Name: MetricGPUTemp, Type: metric.TypeGauge, Unit: "°C", Description: "GPU temperature", RetentionDays: defaultRetentionDays},
|
||||
{Name: MetricRAM, Type: metric.TypeGauge, Unit: "bytes", Description: "RAM used", RetentionDays: defaultRetentionDays},
|
||||
{Name: MetricSwap, Type: metric.TypeGauge, Unit: "bytes", Description: "Swap used", RetentionDays: defaultRetentionDays},
|
||||
{Name: MetricLoad, Type: metric.TypeGauge, Unit: "", Description: "System load average", RetentionDays: defaultRetentionDays},
|
||||
{Name: MetricDisk, Type: metric.TypeGauge, Unit: "bytes", Description: "Disk used", RetentionDays: defaultRetentionDays},
|
||||
{Name: MetricNetIn, Type: metric.TypeGauge, Unit: "bytes/s", Description: "Network in rate", RetentionDays: defaultRetentionDays},
|
||||
{Name: MetricNetOut, Type: metric.TypeGauge, Unit: "bytes/s", Description: "Network out rate", RetentionDays: defaultRetentionDays},
|
||||
{Name: MetricNetTotalUp, Type: metric.TypeCounter, Unit: "bytes", Description: "Network total upload", RetentionDays: defaultRetentionDays},
|
||||
{Name: MetricNetTotalDown, Type: metric.TypeCounter, Unit: "bytes", Description: "Network total download", RetentionDays: defaultRetentionDays},
|
||||
{Name: MetricTrafficUp, Type: metric.TypeGauge, Unit: "bytes", Description: "Traffic upload delta", RetentionDays: defaultRetentionDays},
|
||||
{Name: MetricTrafficDown, Type: metric.TypeGauge, Unit: "bytes", Description: "Traffic download delta", RetentionDays: defaultRetentionDays},
|
||||
{Name: MetricProcess, Type: metric.TypeGauge, Unit: "count", Description: "Process count", RetentionDays: defaultRetentionDays},
|
||||
{Name: MetricConnections, Type: metric.TypeGauge, Unit: "count", Description: "TCP connections", RetentionDays: defaultRetentionDays},
|
||||
{Name: MetricConnectionsUDP, Type: metric.TypeGauge, Unit: "count", Description: "UDP connections", RetentionDays: defaultRetentionDays},
|
||||
{Name: MetricPingLatency, Type: metric.TypeGauge, Unit: "ms", Description: "Ping latency", RetentionDays: defaultRetentionDays},
|
||||
{Name: MetricPingLoss, Type: metric.TypeGauge, Unit: "ratio", Description: "Ping packet loss indicator", RetentionDays: defaultRetentionDays},
|
||||
}
|
||||
|
||||
for _, def := range definitions {
|
||||
existing, err := s.GetMetric(ctx, def.Name)
|
||||
if err != nil && !errors.Is(err, metric.ErrNotFound) {
|
||||
return fmt.Errorf("failed to get metric %s: %w", def.Name, err)
|
||||
}
|
||||
if err == nil {
|
||||
if existing.RetentionDays == 0 {
|
||||
continue
|
||||
}
|
||||
def.RetentionDays = existing.RetentionDays
|
||||
}
|
||||
if err := s.UpsertMetric(ctx, def); err != nil {
|
||||
return fmt.Errorf("failed to create metric %s: %w", def.Name, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package metricstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
logger "github.com/komari-monitor/komari/utils/log"
|
||||
|
||||
"github.com/komari-monitor/komari/pkg/metric"
|
||||
)
|
||||
|
||||
// farFuture 返回一个足够远的未来时间,用于以 DeleteBefore 语义清空某指标的全部数据。
|
||||
func farFuture() time.Time {
|
||||
return time.Now().UTC().Add(24 * 365 * time.Hour)
|
||||
}
|
||||
|
||||
// DeleteAllRecords 删除所有负载/系统类记录(保留指标定义,不含 ping)。
|
||||
func DeleteAllRecords(ctx context.Context) error {
|
||||
s := GetStore()
|
||||
if s == nil {
|
||||
return fmt.Errorf("metric store not enabled")
|
||||
}
|
||||
|
||||
for _, metricName := range recordMetricNames {
|
||||
if _, err := s.DeleteBefore(ctx, metricName, farFuture()); err != nil {
|
||||
logger.Errorf("metricstore", "Failed to delete metric %s: %v", metricName, err)
|
||||
}
|
||||
}
|
||||
clearReportTrafficStates()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteAllPingRecords 删除全部 ping 记录(保留指标定义)。
|
||||
func DeleteAllPingRecords(ctx context.Context) error {
|
||||
s := GetStore()
|
||||
if s == nil {
|
||||
return fmt.Errorf("metric store not enabled")
|
||||
}
|
||||
for _, metricName := range pingMetricNames {
|
||||
if _, err := s.DeleteBefore(ctx, metricName, farFuture()); err != nil {
|
||||
return fmt.Errorf("failed to delete ping records: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeletePingRecordsByTask 删除指定任务(task_id)的全部 ping 记录。
|
||||
func DeletePingRecordsByTask(ctx context.Context, taskIDs []uint) error {
|
||||
s := GetStore()
|
||||
if s == nil {
|
||||
return fmt.Errorf("metric store not enabled")
|
||||
}
|
||||
for _, id := range taskIDs {
|
||||
for _, metricName := range pingMetricNames {
|
||||
if _, err := s.DeleteSeries(ctx, metric.Query{
|
||||
MetricName: metricName,
|
||||
Tags: map[string]string{"task_id": fmt.Sprintf("%d", id)},
|
||||
}); err != nil {
|
||||
return fmt.Errorf("failed to delete ping records for task %d: %w", id, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteEntity 删除指定 agent 在所有指标下的历史数据。
|
||||
func DeleteEntity(ctx context.Context, entityID string) error {
|
||||
s := GetStore()
|
||||
if s == nil {
|
||||
return fmt.Errorf("metric store not enabled")
|
||||
}
|
||||
if _, err := s.DeleteEntity(ctx, entityID); err != nil {
|
||||
return fmt.Errorf("failed to delete metric records for entity %s: %w", entityID, err)
|
||||
}
|
||||
deleteReportTrafficState(entityID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteEntityAsync clears one agent's metric history without delaying the
|
||||
// client deletion response.
|
||||
func DeleteEntityAsync(entityID string) {
|
||||
go func() {
|
||||
if err := DeleteEntity(context.Background(), entityID); err != nil {
|
||||
logger.Errorf("metricstore", "Failed to delete metric records for entity %s: %v", entityID, err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// DeleteMetricDataAsync clears disabled metric history without delaying an
|
||||
// admin retention-policy update response.
|
||||
func DeleteMetricDataAsync(metricName string) {
|
||||
go func() {
|
||||
s := GetStore()
|
||||
if s == nil {
|
||||
logger.Errorf("metricstore", "Failed to delete disabled metric %s: metric store not enabled", metricName)
|
||||
return
|
||||
}
|
||||
if _, err := s.DeleteMetricDataIfDisabled(context.Background(), metricName); err != nil {
|
||||
logger.Errorf("metricstore", "Failed to delete disabled metric %s: %v", metricName, err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
package metricstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/komari-monitor/komari/pkg/metric"
|
||||
)
|
||||
|
||||
// RecordQueryOptions narrows the record reconstruction work. MetricNames
|
||||
// limits which stored load metrics are queried; empty means all load metrics.
|
||||
// MaxPoints is a total response-point budget; it is applied before aggregation,
|
||||
// so an all-client query does not materialize thousands of points per client
|
||||
// only to sample them afterwards.
|
||||
type RecordQueryOptions struct {
|
||||
MetricNames []string
|
||||
MaxPoints int
|
||||
}
|
||||
|
||||
// GetRecordsByClientAndTime 从 metric store 查询记录并重构为 models.Record。
|
||||
// opts 为空表示兼容旧行为;有值时用于指标选择和点数预算。
|
||||
func GetRecordsByClientAndTime(ctx context.Context, clientUUID string, start, end time.Time, opts ...RecordQueryOptions) ([]models.Record, error) {
|
||||
s := GetStore()
|
||||
if s == nil {
|
||||
return nil, fmt.Errorf("metric store not enabled")
|
||||
}
|
||||
|
||||
return getRecordsFromSeries(ctx, s, clientUUID, start, end, false, firstRecordOptions(opts))
|
||||
}
|
||||
|
||||
func firstRecordOptions(opts []RecordQueryOptions) RecordQueryOptions {
|
||||
if len(opts) == 0 {
|
||||
return RecordQueryOptions{}
|
||||
}
|
||||
return opts[0]
|
||||
}
|
||||
|
||||
// GetRecordMetricMaxByClientAndTime 查询单项监控指标在各时间桶内的最大值。
|
||||
func GetRecordMetricMaxByClientAndTime(ctx context.Context, clientUUID, recordMetric string, start, end time.Time) ([]models.Record, error) {
|
||||
s := GetStore()
|
||||
if s == nil {
|
||||
return nil, fmt.Errorf("metric store not enabled")
|
||||
}
|
||||
|
||||
return getRecordMetricMaxByClientAndTimeFromSeries(ctx, s, clientUUID, recordMetric, start, end)
|
||||
}
|
||||
|
||||
// GetRecordsByTime 从 metric store 查询所有客户端在时间范围内的记录。
|
||||
// opts 为空表示兼容旧行为;有值时用于指标选择和总点数预算。
|
||||
func GetRecordsByTime(ctx context.Context, start, end time.Time, opts ...RecordQueryOptions) ([]models.Record, error) {
|
||||
s := GetStore()
|
||||
if s == nil {
|
||||
return nil, fmt.Errorf("metric store not enabled")
|
||||
}
|
||||
|
||||
// Query all entities together. Entity-only grouping retains the old per-client
|
||||
// aggregation across tags without repeating the tier scan for every client.
|
||||
return getRecordsFromSeries(ctx, s, "", start, end, true, firstRecordOptions(opts))
|
||||
}
|
||||
|
||||
type recordSeriesKey struct {
|
||||
client string
|
||||
ts int64
|
||||
}
|
||||
|
||||
func getRecordsFromSeries(ctx context.Context, s *metric.Store, clientUUID string, start, end time.Time, allEntities bool, opts RecordQueryOptions) ([]models.Record, error) {
|
||||
metricNames := opts.MetricNames
|
||||
if len(metricNames) == 0 {
|
||||
metricNames = loadRecordMetricNames
|
||||
}
|
||||
specs := make([]MetricSpec, 0, len(metricNames))
|
||||
for _, name := range metricNames {
|
||||
specs = append(specs, MetricSpec{Name: name, Aggregations: []metric.Aggregation{recordMetricAggregation(name)}})
|
||||
}
|
||||
entities := EntityScope{All: allEntities}
|
||||
grouping := GroupMerged
|
||||
if allEntities {
|
||||
grouping = GroupEntity
|
||||
} else {
|
||||
entities.IDs = []string{clientUUID}
|
||||
}
|
||||
rounding := RoundFloor
|
||||
if opts.MaxPoints > 0 {
|
||||
rounding = RoundCeil
|
||||
}
|
||||
loaded, err := QueryMetrics(ctx, MetricsQuery{
|
||||
Store: s, Mode: QuerySeries, Source: SourceRollup,
|
||||
OmitTags: true, OmitLabels: true, CompactDefinitions: true,
|
||||
Entities: entities, Specs: specs, Start: start, End: end,
|
||||
Order: metric.OrderAsc, Grouping: grouping,
|
||||
Budget: PointBudget{PerSeries: 500, Total: opts.MaxPoints},
|
||||
Rounding: rounding, AllowMissing: true,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query metrics: %w", err)
|
||||
}
|
||||
return assembleRecordsFromSeries(loaded.Values, clientUUID), nil
|
||||
}
|
||||
|
||||
// assembleRecordsFromSeries unions metric buckets without allocating a record per key.
|
||||
func assembleRecordsFromSeries(values map[string]map[metric.Aggregation][]metric.AggregatePoint, clientUUID string) []models.Record {
|
||||
// Aligned metrics normally share buckets: the largest selected metric is a
|
||||
// conservative capacity estimate, not the sum of the metric fanout. Missing
|
||||
// buckets can grow the union beyond it (and duplicate seconds can shrink it).
|
||||
capacity := 0
|
||||
for name, aggregations := range values {
|
||||
if n := len(aggregations[recordMetricAggregation(name)]); n > capacity {
|
||||
capacity = n
|
||||
}
|
||||
}
|
||||
records := make([]models.Record, 0, capacity)
|
||||
recordIndex := make(map[recordSeriesKey]int, capacity)
|
||||
for metricName, aggMap := range values {
|
||||
agg := recordMetricAggregation(metricName)
|
||||
points, ok := aggMap[agg]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for _, point := range points {
|
||||
entityID := point.EntityID
|
||||
if entityID == "" {
|
||||
entityID = clientUUID
|
||||
}
|
||||
key := recordSeriesKey{client: entityID, ts: point.Bucket.Unix()}
|
||||
index, exists := recordIndex[key]
|
||||
if !exists {
|
||||
index = len(records)
|
||||
recordIndex[key] = index
|
||||
records = append(records, models.Record{
|
||||
Client: entityID,
|
||||
Time: point.Bucket.UTC(),
|
||||
})
|
||||
}
|
||||
// Resolve the address after append: the backing array may have moved.
|
||||
applyRecordMetricValue(&records[index], metricName, point.Value)
|
||||
}
|
||||
}
|
||||
|
||||
sortRecords(records)
|
||||
return records
|
||||
}
|
||||
|
||||
func getRecordMetricMaxByClientAndTimeFromSeries(ctx context.Context, s *metric.Store, clientUUID, recordMetric string, start, end time.Time) ([]models.Record, error) {
|
||||
metricName, ok := metricNameForRecordField(recordMetric)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unsupported record metric %q", recordMetric)
|
||||
}
|
||||
|
||||
loaded, err := QueryMetrics(ctx, MetricsQuery{
|
||||
Store: s, Mode: QuerySeries, Source: SourceRollup,
|
||||
OmitTags: true, OmitLabels: true, CompactDefinitions: true,
|
||||
Entities: EntityScope{IDs: []string{clientUUID}},
|
||||
Specs: []MetricSpec{{Name: metricName, Aggregations: []metric.Aggregation{metric.AggMax}}},
|
||||
Start: start, End: end, Order: metric.OrderAsc, Grouping: GroupMerged,
|
||||
Budget: PointBudget{PerSeries: 500}, Rounding: RoundFloor, AllowMissing: true,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query metric %s: %w", metricName, err)
|
||||
}
|
||||
|
||||
points := loaded.Values[metricName][metric.AggMax]
|
||||
records := make([]models.Record, 0, len(points))
|
||||
for _, point := range points {
|
||||
entityID := point.EntityID
|
||||
if entityID == "" {
|
||||
entityID = clientUUID
|
||||
}
|
||||
record := models.Record{Client: entityID, Time: point.Bucket.UTC()}
|
||||
applyRecordMetricValue(&record, metricName, point.Value)
|
||||
records = append(records, record)
|
||||
}
|
||||
return records, nil
|
||||
}
|
||||
|
||||
func recordMetricAggregation(metricName string) metric.Aggregation {
|
||||
switch metricName {
|
||||
case MetricTrafficUp, MetricTrafficDown:
|
||||
return metric.AggSum
|
||||
case MetricNetTotalUp, MetricNetTotalDown:
|
||||
return metric.AggLast
|
||||
default:
|
||||
return metric.AggAvg
|
||||
}
|
||||
}
|
||||
|
||||
func applyRecordMetricValue(rec *models.Record, metricName string, value float64) {
|
||||
switch metricName {
|
||||
case MetricCPU:
|
||||
rec.Cpu = float32(value)
|
||||
case MetricGPU:
|
||||
rec.Gpu = float32(value)
|
||||
case MetricRAM:
|
||||
rec.Ram = int64(value)
|
||||
case MetricSwap:
|
||||
rec.Swap = int64(value)
|
||||
case MetricLoad:
|
||||
rec.Load = float32(value)
|
||||
case MetricDisk:
|
||||
rec.Disk = int64(value)
|
||||
case MetricNetIn:
|
||||
rec.NetIn = int64(value)
|
||||
case MetricNetOut:
|
||||
rec.NetOut = int64(value)
|
||||
case MetricNetTotalUp:
|
||||
rec.NetTotalUp = int64(value)
|
||||
case MetricNetTotalDown:
|
||||
rec.NetTotalDown = int64(value)
|
||||
case MetricTrafficUp:
|
||||
rec.TrafficUp = int64(value)
|
||||
case MetricTrafficDown:
|
||||
rec.TrafficDown = int64(value)
|
||||
case MetricProcess:
|
||||
rec.Process = int(value)
|
||||
case MetricConnections:
|
||||
rec.Connections = int(value)
|
||||
case MetricConnectionsUDP:
|
||||
rec.ConnectionsUdp = int(value)
|
||||
}
|
||||
}
|
||||
|
||||
func sortRecords(records []models.Record) {
|
||||
sort.Slice(records, func(i, j int) bool {
|
||||
if records[i].Client != records[j].Client {
|
||||
return records[i].Client < records[j].Client
|
||||
}
|
||||
return records[i].Time.Before(records[j].Time)
|
||||
})
|
||||
}
|
||||
|
||||
// GetGPURecordsByClientAndTime 从 metric store 查询 GPU 记录
|
||||
func GetGPURecordsByClientAndTime(ctx context.Context, clientUUID string, start, end time.Time) ([]models.GPURecord, error) {
|
||||
s := GetStore()
|
||||
if s == nil {
|
||||
return nil, fmt.Errorf("metric store not enabled")
|
||||
}
|
||||
|
||||
// 查询 GPU 相关指标(每设备利用率使用独立指标 gpu.device.usage)
|
||||
gpuMetrics := []string{MetricGPUDeviceUsage, MetricGPUMem, MetricGPUMemTotal, MetricGPUTemp}
|
||||
|
||||
// 按设备索引和时间组织数据
|
||||
type gpuKey struct {
|
||||
deviceIndex int
|
||||
timestamp int64
|
||||
}
|
||||
recordMap := make(map[gpuKey]*models.GPURecord)
|
||||
|
||||
specs := make([]MetricSpec, 0, len(gpuMetrics))
|
||||
for _, name := range gpuMetrics {
|
||||
specs = append(specs, MetricSpec{Name: name})
|
||||
}
|
||||
loaded, err := QueryMetrics(ctx, MetricsQuery{
|
||||
Store: s, Mode: QueryPoints, Source: SourceRaw,
|
||||
OmitLabels: true, CompactDefinitions: true,
|
||||
Entities: EntityScope{IDs: []string{clientUUID}}, Specs: specs,
|
||||
Start: start, End: end, Order: metric.OrderAsc, AllowMissing: true,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query GPU metrics: %w", err)
|
||||
}
|
||||
for _, metricName := range gpuMetrics {
|
||||
points := loaded.Raw[metricName]
|
||||
|
||||
for _, p := range points {
|
||||
deviceIndex := 0
|
||||
deviceName := ""
|
||||
if idx, ok := p.Tags["device_index"]; ok {
|
||||
fmt.Sscanf(idx, "%d", &deviceIndex)
|
||||
}
|
||||
if name, ok := p.Tags["device_name"]; ok {
|
||||
deviceName = name
|
||||
}
|
||||
|
||||
key := gpuKey{deviceIndex: deviceIndex, timestamp: p.Timestamp.Unix()}
|
||||
if recordMap[key] == nil {
|
||||
recordMap[key] = &models.GPURecord{
|
||||
Client: clientUUID,
|
||||
Time: p.Timestamp.UTC(),
|
||||
DeviceIndex: deviceIndex,
|
||||
DeviceName: deviceName,
|
||||
}
|
||||
}
|
||||
rec := recordMap[key]
|
||||
|
||||
switch metricName {
|
||||
case MetricGPUDeviceUsage:
|
||||
rec.Utilization = float32(p.Value)
|
||||
case MetricGPUMem:
|
||||
rec.MemUsed = int64(p.Value)
|
||||
case MetricGPUMemTotal:
|
||||
rec.MemTotal = int64(p.Value)
|
||||
case MetricGPUTemp:
|
||||
rec.Temperature = int(p.Value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 转换为切片
|
||||
records := make([]models.GPURecord, 0, len(recordMap))
|
||||
for _, rec := range recordMap {
|
||||
records = append(records, *rec)
|
||||
}
|
||||
|
||||
return records, nil
|
||||
}
|
||||
@@ -62,18 +62,12 @@ func InspectStorage(ctx context.Context) (StorageInfo, error) {
|
||||
// It takes the exclusive operation lock, so a table/file rewrite cannot run
|
||||
// concurrently with report writes or compaction.
|
||||
func ReclaimSpace(ctx context.Context) (MaintenanceResult, error) {
|
||||
if !storeOperations.TryAcquire() {
|
||||
storeMu.RLock()
|
||||
defer storeMu.RUnlock()
|
||||
if store == nil {
|
||||
return MaintenanceResult{}, ErrStoreNotInitialized
|
||||
}
|
||||
return MaintenanceResult{
|
||||
Driver: store.Driver(),
|
||||
Action: store.MaintenanceAction(),
|
||||
BeforeSizeError: ErrStoreBusy,
|
||||
AfterSizeError: ErrStoreBusy,
|
||||
}, ErrStoreBusy
|
||||
// Reclamation is intentionally non-cancellable. It waits for all active
|
||||
// operations, then keeps the store gate until digest conversion, VACUUM,
|
||||
// and the final checkpoint have completed.
|
||||
_ = ctx
|
||||
if err := storeOperations.Acquire(context.Background()); err != nil {
|
||||
return MaintenanceResult{}, err
|
||||
}
|
||||
defer storeOperations.Release()
|
||||
|
||||
@@ -88,8 +82,33 @@ func ReclaimSpace(ctx context.Context) (MaintenanceResult, error) {
|
||||
Driver: activeStore.Driver(),
|
||||
Action: activeStore.MaintenanceAction(),
|
||||
}
|
||||
result.Before, result.BeforeSizeError = activeStore.StorageSize(ctx)
|
||||
maintenanceErr := activeStore.ReclaimSpace(ctx)
|
||||
result.After, result.AfterSizeError = activeStore.StorageSize(ctx)
|
||||
maintenanceCtx := context.Background()
|
||||
result.Before, result.BeforeSizeError = activeStore.StorageSize(maintenanceCtx)
|
||||
maintenanceErr := deleteUndefinedMetrics(maintenanceCtx, activeStore)
|
||||
if maintenanceErr == nil {
|
||||
maintenanceErr = activeStore.ReclaimSpace(maintenanceCtx)
|
||||
}
|
||||
result.After, result.AfterSizeError = activeStore.StorageSize(maintenanceCtx)
|
||||
return result, maintenanceErr
|
||||
}
|
||||
|
||||
func deleteUndefinedMetrics(ctx context.Context, s *metric.Store) error {
|
||||
definitions, err := s.ListMetrics(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("list metric definitions before reclaim: %w", err)
|
||||
}
|
||||
|
||||
builtin := make(map[string]struct{}, len(builtinMetricNames))
|
||||
for _, name := range builtinMetricNames {
|
||||
builtin[name] = struct{}{}
|
||||
}
|
||||
for _, definition := range definitions {
|
||||
if _, ok := builtin[definition.Name]; ok {
|
||||
continue
|
||||
}
|
||||
if err := s.DeleteMetric(ctx, definition.Name); err != nil {
|
||||
return fmt.Errorf("delete undefined metric %q before reclaim: %w", definition.Name, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
+29
-59
@@ -2,9 +2,7 @@ package metricstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -42,55 +40,7 @@ func TestInspectAndReclaimStorage(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryMetricWALCheckpointDefersBusyReader(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
path := filepath.Join(t.TempDir(), "checkpoint.db")
|
||||
s, err := metric.Open(ctx, metric.SQLite(path, metric.WithSQLiteWALAutoCheckpoint(1_000_000)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = s.Close() })
|
||||
if err := s.CreateMetric(ctx, metric.Definition{Name: "before", Type: metric.TypeGauge, RetentionDays: 1}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
reader, err := sql.Open("sqlite3", path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = reader.Close() })
|
||||
tx, err := reader.BeginTx(ctx, &sql.TxOptions{ReadOnly: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var definitions int
|
||||
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM metric_definitions`).Scan(&definitions); err != nil {
|
||||
_ = tx.Rollback()
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.CreateMetric(ctx, metric.Definition{Name: "after", Type: metric.TypeGauge, RetentionDays: 1}); err != nil {
|
||||
_ = tx.Rollback()
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
previousPending := checkpointPending
|
||||
checkpointPending = true
|
||||
t.Cleanup(func() { checkpointPending = previousPending })
|
||||
retryMetricWALCheckpoint(ctx, s)
|
||||
if !checkpointPending {
|
||||
_ = tx.Rollback()
|
||||
t.Fatal("busy reader unexpectedly cleared pending checkpoint")
|
||||
}
|
||||
if err := tx.Rollback(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
retryMetricWALCheckpoint(ctx, s)
|
||||
if checkpointPending {
|
||||
t.Fatal("checkpoint remained pending after reader released")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReclaimSpaceReportsBusyStore(t *testing.T) {
|
||||
func TestReclaimSpaceWaitsForStore(t *testing.T) {
|
||||
s, err := metric.Open(context.Background(), metric.SQLite(":memory:"))
|
||||
if err != nil {
|
||||
t.Fatalf("open metric store: %v", err)
|
||||
@@ -100,17 +50,34 @@ func TestReclaimSpaceReportsBusyStore(t *testing.T) {
|
||||
if err := storeOperations.AcquireShared(context.Background()); err != nil {
|
||||
t.Fatalf("acquire shared store operation gate: %v", err)
|
||||
}
|
||||
defer storeOperations.ReleaseShared()
|
||||
|
||||
result, err := ReclaimSpace(context.Background())
|
||||
if !errors.Is(err, ErrStoreBusy) {
|
||||
t.Fatalf("reclaim error = %v, want %v", err, ErrStoreBusy)
|
||||
done := make(chan struct{})
|
||||
var result MaintenanceResult
|
||||
var reclaimErr error
|
||||
canceledCtx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
go func() {
|
||||
result, reclaimErr = ReclaimSpace(canceledCtx)
|
||||
close(done)
|
||||
}()
|
||||
select {
|
||||
case <-done:
|
||||
t.Fatal("reclaim returned while another operation held the gate")
|
||||
case <-time.After(25 * time.Millisecond):
|
||||
}
|
||||
storeOperations.ReleaseShared()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("reclaim did not resume after the gate was released")
|
||||
}
|
||||
if reclaimErr != nil {
|
||||
t.Fatalf("reclaim error: %v", reclaimErr)
|
||||
}
|
||||
if result.Driver != metric.DriverSQLite || result.Action != metric.MaintenanceVacuum {
|
||||
t.Fatalf("busy result lost store metadata: %#v", result)
|
||||
t.Fatalf("reclaim result lost store metadata: %#v", result)
|
||||
}
|
||||
if !errors.Is(result.BeforeSizeError, ErrStoreBusy) || !errors.Is(result.AfterSizeError, ErrStoreBusy) {
|
||||
t.Fatalf("busy result should mark both measurements unavailable: %#v", result)
|
||||
if result.BeforeSizeError != nil || result.AfterSizeError != nil {
|
||||
t.Fatalf("unexpected size errors: before=%v after=%v", result.BeforeSizeError, result.AfterSizeError)
|
||||
}
|
||||
compactCtx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
@@ -124,6 +91,9 @@ func TestReclaimSpaceReportsBusyStore(t *testing.T) {
|
||||
if _, err := Compact(context.Background(), time.Now()); !errors.Is(err, ErrCompactInProgress) {
|
||||
t.Fatalf("overlapping compact error = %v, want %v", err, ErrCompactInProgress)
|
||||
}
|
||||
if _, err := CleanupExpired(context.Background(), time.Now()); !errors.Is(err, ErrCompactInProgress) {
|
||||
t.Fatalf("overlapping retention cleanup error = %v, want %v", err, ErrCompactInProgress)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectStorageRequiresInitializedStore(t *testing.T) {
|
||||
@@ -32,13 +32,6 @@ var loadRecordMetricNames = []string{
|
||||
MetricProcess, MetricConnections, MetricConnectionsUDP,
|
||||
}
|
||||
|
||||
var obsoleteBuiltinMetricNames = []string{
|
||||
"memory.total",
|
||||
"swap.total",
|
||||
"temperature",
|
||||
"disk.total",
|
||||
}
|
||||
|
||||
// gpuDeviceRecordMetricNames are stored separately from the entity-level GPU
|
||||
// average and are included when deleting all system records.
|
||||
var gpuDeviceRecordMetricNames = []string{
|
||||
@@ -50,6 +43,45 @@ var recordMetricNames = joinMetricNames(loadRecordMetricNames, gpuDeviceRecordMe
|
||||
// Ping has an independent retention and cleanup boundary.
|
||||
var pingMetricNames = []string{MetricPingLatency, MetricPingLoss}
|
||||
|
||||
var builtinMetricNames = joinMetricNames(recordMetricNames, pingMetricNames)
|
||||
|
||||
func metricNameForRecordField(name string) (string, bool) {
|
||||
switch name {
|
||||
case "cpu":
|
||||
return MetricCPU, true
|
||||
case "gpu":
|
||||
return MetricGPU, true
|
||||
case "ram":
|
||||
return MetricRAM, true
|
||||
case "swap":
|
||||
return MetricSwap, true
|
||||
case "load":
|
||||
return MetricLoad, true
|
||||
case "disk":
|
||||
return MetricDisk, true
|
||||
case "net_in", "netin":
|
||||
return MetricNetIn, true
|
||||
case "net_out", "netout":
|
||||
return MetricNetOut, true
|
||||
case "net_total_up":
|
||||
return MetricNetTotalUp, true
|
||||
case "net_total_down":
|
||||
return MetricNetTotalDown, true
|
||||
case "traffic_up":
|
||||
return MetricTrafficUp, true
|
||||
case "traffic_down":
|
||||
return MetricTrafficDown, true
|
||||
case "process":
|
||||
return MetricProcess, true
|
||||
case "connections":
|
||||
return MetricConnections, true
|
||||
case "connections_udp":
|
||||
return MetricConnectionsUDP, true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
func joinMetricNames(groups ...[]string) []string {
|
||||
total := 0
|
||||
for _, group := range groups {
|
||||
+145
-64
@@ -3,14 +3,13 @@ package metricstore
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/komari-monitor/komari/pkg/metric"
|
||||
v1 "github.com/komari-monitor/komari/protocol/v1"
|
||||
v2 "github.com/komari-monitor/komari/protocol/v2"
|
||||
)
|
||||
|
||||
func TestDefaultRollupPolicy(t *testing.T) {
|
||||
@@ -72,6 +71,92 @@ func TestBuildMetricConfigLeavesFinalRetentionToMetricDefinition(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMetricConfigUsesCustomRollupRetention(t *testing.T) {
|
||||
cfg, err := buildMetricConfig(&MetricStoreConfig{
|
||||
Driver: "sqlite",
|
||||
DSN: ":memory:",
|
||||
RollupMinuteRetentionMinutes: 30,
|
||||
RollupFiveMinuteRetentionMinutes: 150,
|
||||
RollupHourRetentionHours: 300,
|
||||
}, false)
|
||||
if err != nil {
|
||||
t.Fatalf("build metric config: %v", err)
|
||||
}
|
||||
|
||||
want := []time.Duration{30 * time.Minute, 150 * time.Minute, 300 * time.Hour}
|
||||
if len(cfg.RollupPolicy.Tiers) != 4 {
|
||||
t.Fatalf("tier count = %d, want 4", len(cfg.RollupPolicy.Tiers))
|
||||
}
|
||||
for i, retention := range want {
|
||||
if cfg.RollupPolicy.Tiers[i].Retention != retention {
|
||||
t.Fatalf("tier %d retention = %s, want %s", i, cfg.RollupPolicy.Tiers[i].Retention, retention)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMetricConfigRejectsInvalidRollupRetention(t *testing.T) {
|
||||
tests := []MetricStoreConfig{
|
||||
{
|
||||
Driver: "sqlite",
|
||||
DSN: ":memory:",
|
||||
RollupMinuteRetentionMinutes: -1,
|
||||
},
|
||||
{
|
||||
Driver: "sqlite",
|
||||
DSN: ":memory:",
|
||||
RollupMinuteRetentionMinutes: 120,
|
||||
RollupFiveMinuteRetentionMinutes: 60,
|
||||
RollupHourRetentionHours: 600,
|
||||
},
|
||||
{
|
||||
Driver: "sqlite",
|
||||
DSN: ":memory:",
|
||||
RollupMinuteRetentionMinutes: 30,
|
||||
RollupFiveMinuteRetentionMinutes: 150,
|
||||
RollupHourRetentionHours: 1,
|
||||
},
|
||||
}
|
||||
for i, cfg := range tests {
|
||||
if _, err := buildMetricConfig(&cfg, false); err == nil {
|
||||
t.Fatalf("case %d: expected invalid rollup retention error", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMetricConfigDefaultsOmittedRollupRetention(t *testing.T) {
|
||||
cfg, err := buildMetricConfig(&MetricStoreConfig{Driver: "sqlite", DSN: ":memory:"}, false)
|
||||
if err != nil {
|
||||
t.Fatalf("build metric config: %v", err)
|
||||
}
|
||||
if got, want := cfg.RollupPolicy.Tiers[0].Retention, 600*time.Minute; got != want {
|
||||
t.Fatalf("minute retention = %s, want %s", got, want)
|
||||
}
|
||||
if got, want := cfg.RollupPolicy.Tiers[1].Retention, 3000*time.Minute; got != want {
|
||||
t.Fatalf("five-minute retention = %s, want %s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigFromFingerprintPreservesRollupRetention(t *testing.T) {
|
||||
base := &MetricStoreConfig{
|
||||
TablePrefix: "metrics_",
|
||||
MaxOpenConns: 11,
|
||||
MaxIdleConns: 4,
|
||||
RollupMinuteRetentionMinutes: 30,
|
||||
RollupFiveMinuteRetentionMinutes: 150,
|
||||
RollupHourRetentionHours: 300,
|
||||
}
|
||||
|
||||
got, err := configFromFingerprint("mysql|user:password@tcp(host:3306)/metrics", base)
|
||||
if err != nil {
|
||||
t.Fatalf("config from fingerprint: %v", err)
|
||||
}
|
||||
if got.RollupMinuteRetentionMinutes != base.RollupMinuteRetentionMinutes ||
|
||||
got.RollupFiveMinuteRetentionMinutes != base.RollupFiveMinuteRetentionMinutes ||
|
||||
got.RollupHourRetentionHours != base.RollupHourRetentionHours {
|
||||
t.Fatalf("rollup retention was not preserved: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMetricConfigAlwaysEnablesDownsampling(t *testing.T) {
|
||||
cfg, err := buildMetricConfig(&MetricStoreConfig{
|
||||
Driver: "sqlite",
|
||||
@@ -197,7 +282,7 @@ func TestCreateMetricDefinitionsUsesExplicitRetentionAndPreservesOverrides(t *te
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateMetricDefinitionsRemovesObsoleteMetrics(t *testing.T) {
|
||||
func TestCreateMetricDefinitionsKeepsExistingMetrics(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s, err := metric.Open(ctx, metric.SQLite(":memory:", metric.WithMaxOpenConns(1)))
|
||||
if err != nil {
|
||||
@@ -213,15 +298,19 @@ func TestCreateMetricDefinitionsRemovesObsoleteMetrics(t *testing.T) {
|
||||
if err := createMetricDefinitions(ctx, s); err != nil {
|
||||
t.Fatalf("refresh built-in definitions: %v", err)
|
||||
}
|
||||
if _, err := s.GetMetric(ctx, "memory.total"); !errors.Is(err, metric.ErrNotFound) {
|
||||
t.Fatalf("obsolete definition error = %v, want ErrNotFound", err)
|
||||
definition, err := s.GetMetric(ctx, "memory.total")
|
||||
if err != nil {
|
||||
t.Fatalf("existing definition was removed: %v", err)
|
||||
}
|
||||
if definition.RetentionDays != 1 {
|
||||
t.Fatalf("existing retention = %d, want 1", definition.RetentionDays)
|
||||
}
|
||||
points, err := s.Query(ctx, metric.Query{MetricName: "memory.total", EntityID: "node-a", Start: time.Now().UTC().Add(-time.Hour), End: time.Now().UTC().Add(time.Hour)})
|
||||
if err != nil {
|
||||
t.Fatalf("query obsolete points: %v", err)
|
||||
t.Fatalf("query existing points: %v", err)
|
||||
}
|
||||
if len(points) != 0 {
|
||||
t.Fatalf("obsolete points remain: %#v", points)
|
||||
if len(points) != 1 || points[0].Value != 1024 {
|
||||
t.Fatalf("existing points were removed: %#v", points)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -353,14 +442,11 @@ func TestCompactCleansPointsOutsideFixedRawWindow(t *testing.T) {
|
||||
|
||||
storeMu.Lock()
|
||||
oldStore := store
|
||||
oldCompactAt := compactAt
|
||||
store = s
|
||||
compactAt = 0
|
||||
storeMu.Unlock()
|
||||
defer func() {
|
||||
storeMu.Lock()
|
||||
store = oldStore
|
||||
compactAt = oldCompactAt
|
||||
storeMu.Unlock()
|
||||
_ = s.Close()
|
||||
}()
|
||||
@@ -382,7 +468,7 @@ func TestCompactCleansPointsOutsideFixedRawWindow(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompactContinuesAfterOneMetricFails(t *testing.T) {
|
||||
func TestRetentionCleanupReportsDeleteFailure(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
dsn := filepath.Join(t.TempDir(), "compact.db")
|
||||
s, err := metric.Open(ctx, metric.SQLite(dsn,
|
||||
@@ -423,19 +509,16 @@ func TestCompactContinuesAfterOneMetricFails(t *testing.T) {
|
||||
|
||||
storeMu.Lock()
|
||||
previousStore := store
|
||||
previousCompactAt := compactAt
|
||||
store = s
|
||||
compactAt = 0
|
||||
storeMu.Unlock()
|
||||
t.Cleanup(func() {
|
||||
storeMu.Lock()
|
||||
store = previousStore
|
||||
compactAt = previousCompactAt
|
||||
storeMu.Unlock()
|
||||
})
|
||||
|
||||
if _, err := Compact(ctx, now); err == nil {
|
||||
t.Fatal("expected malformed metric to fail compaction")
|
||||
if _, err := CleanupExpired(ctx, now); err == nil {
|
||||
t.Fatal("expected retention cleanup to report the forced delete failure")
|
||||
}
|
||||
points, err := s.Query(ctx, metric.Query{
|
||||
MetricName: "b.healthy",
|
||||
@@ -447,44 +530,7 @@ func TestCompactContinuesAfterOneMetricFails(t *testing.T) {
|
||||
t.Fatalf("query healthy rollups: %v", err)
|
||||
}
|
||||
if len(points) != 0 {
|
||||
t.Fatalf("healthy metric was blocked by another metric failure: %#v", points)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompactKeepsRotatingCursorAfterFullCycle(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s, err := metric.Open(ctx, metric.SQLite(":memory:",
|
||||
metric.WithMaxOpenConns(1),
|
||||
metric.WithRollupPolicy(defaultRollupPolicy()),
|
||||
))
|
||||
if err != nil {
|
||||
t.Fatalf("open metric store: %v", err)
|
||||
}
|
||||
for _, name := range []string{"a.metric", "b.metric", "c.metric"} {
|
||||
if err := s.UpsertMetric(ctx, metric.Definition{Name: name, Type: metric.TypeGauge}); err != nil {
|
||||
t.Fatalf("upsert metric %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
storeMu.Lock()
|
||||
oldStore := store
|
||||
oldCompactAt := compactAt
|
||||
store = s
|
||||
compactAt = 1
|
||||
storeMu.Unlock()
|
||||
defer func() {
|
||||
storeMu.Lock()
|
||||
store = oldStore
|
||||
compactAt = oldCompactAt
|
||||
storeMu.Unlock()
|
||||
_ = s.Close()
|
||||
}()
|
||||
|
||||
if _, err := Compact(ctx, time.Date(2026, 7, 11, 12, 0, 0, 0, time.UTC)); err != nil {
|
||||
t.Fatalf("compact: %v", err)
|
||||
}
|
||||
if compactAt != 1 {
|
||||
t.Fatalf("compact cursor = %d, want 1 after a complete rotated cycle", compactAt)
|
||||
t.Fatalf("retention cleanup unexpectedly changed healthy metric data: %#v", points)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -503,14 +549,11 @@ func TestGetRecordsByClientAndTimeReadsRollupsAfterRawCompaction(t *testing.T) {
|
||||
|
||||
storeMu.Lock()
|
||||
oldStore := store
|
||||
oldCompactAt := compactAt
|
||||
store = s
|
||||
compactAt = 0
|
||||
storeMu.Unlock()
|
||||
defer func() {
|
||||
storeMu.Lock()
|
||||
store = oldStore
|
||||
compactAt = oldCompactAt
|
||||
storeMu.Unlock()
|
||||
_ = s.Close()
|
||||
}()
|
||||
@@ -529,15 +572,15 @@ func TestGetRecordsByClientAndTimeReadsRollupsAfterRawCompaction(t *testing.T) {
|
||||
Connections: 321,
|
||||
ConnectionsUdp: 12,
|
||||
}
|
||||
if _, err := WriteReport(ctx, v1.Report{
|
||||
if _, err := WriteReport(ctx, v2.Report{
|
||||
UUID: rec.Client,
|
||||
UpdatedAt: ts,
|
||||
CPU: v1.CPUReport{Usage: float64(rec.Cpu)},
|
||||
Ram: v1.RamReport{Used: rec.Ram, Total: rec.RamTotal},
|
||||
Load: v1.LoadReport{Load1: float64(rec.Load)},
|
||||
Disk: v1.DiskReport{Used: rec.Disk, Total: rec.DiskTotal},
|
||||
CPU: v2.CPUReport{Usage: float64(rec.Cpu)},
|
||||
Ram: v2.RamReport{Used: rec.Ram, Total: rec.RamTotal},
|
||||
Load: v2.LoadReport{Load1: float64(rec.Load)},
|
||||
Disk: v2.DiskReport{Used: rec.Disk, Total: rec.DiskTotal},
|
||||
Process: rec.Process,
|
||||
Connections: v1.ConnectionsReport{
|
||||
Connections: v2.ConnectionsReport{
|
||||
TCP: rec.Connections,
|
||||
UDP: rec.ConnectionsUdp,
|
||||
},
|
||||
@@ -566,3 +609,41 @@ func TestGetRecordsByClientAndTimeReadsRollupsAfterRawCompaction(t *testing.T) {
|
||||
t.Fatalf("all-client records were not reconstructed from rollup: %#v", all)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRecordMetricMaxByClientAndTimeQueriesOnlySelectedMetric(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s, err := metric.Open(ctx, metric.SQLite(":memory:",
|
||||
metric.WithMaxOpenConns(1),
|
||||
metric.WithRollupPolicy(defaultRollupPolicy()),
|
||||
))
|
||||
if err != nil {
|
||||
t.Fatalf("open metric store: %v", err)
|
||||
}
|
||||
defer s.Close()
|
||||
if err := createMetricDefinitions(ctx, s); err != nil {
|
||||
t.Fatalf("create metric definitions: %v", err)
|
||||
}
|
||||
|
||||
base := time.Now().UTC().Truncate(time.Minute).Add(-time.Minute)
|
||||
if err := s.WriteBatch(ctx, []metric.Point{
|
||||
{MetricName: MetricCPU, EntityID: "node-a", Timestamp: base.Add(10 * time.Second), Value: 10},
|
||||
{MetricName: MetricCPU, EntityID: "node-a", Timestamp: base.Add(20 * time.Second), Value: 90},
|
||||
{MetricName: MetricRAM, EntityID: "node-a", Timestamp: base.Add(20 * time.Second), Value: 123456},
|
||||
}); err != nil {
|
||||
t.Fatalf("write metric points: %v", err)
|
||||
}
|
||||
|
||||
got, err := getRecordMetricMaxByClientAndTimeFromSeries(ctx, s, "node-a", "cpu", base, base.Add(time.Minute))
|
||||
if err != nil {
|
||||
t.Fatalf("get CPU max records: %v", err)
|
||||
}
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("record count = %d, want 1: %#v", len(got), got)
|
||||
}
|
||||
if got[0].Cpu != 90 {
|
||||
t.Fatalf("CPU max = %v, want 90", got[0].Cpu)
|
||||
}
|
||||
if got[0].Ram != 0 {
|
||||
t.Fatalf("unselected RAM value = %d, want 0", got[0].Ram)
|
||||
}
|
||||
}
|
||||
@@ -25,11 +25,14 @@ func configFromFingerprint(fingerprint string, base *MetricStoreConfig) (*Metric
|
||||
return nil, fmt.Errorf("empty driver in target fingerprint: %q", fingerprint)
|
||||
}
|
||||
return &MetricStoreConfig{
|
||||
Driver: driver,
|
||||
DSN: dsn,
|
||||
TablePrefix: base.TablePrefix,
|
||||
MaxOpenConns: base.MaxOpenConns,
|
||||
MaxIdleConns: base.MaxIdleConns,
|
||||
Driver: driver,
|
||||
DSN: dsn,
|
||||
TablePrefix: base.TablePrefix,
|
||||
MaxOpenConns: base.MaxOpenConns,
|
||||
MaxIdleConns: base.MaxIdleConns,
|
||||
RollupMinuteRetentionMinutes: base.RollupMinuteRetentionMinutes,
|
||||
RollupFiveMinuteRetentionMinutes: base.RollupFiveMinuteRetentionMinutes,
|
||||
RollupHourRetentionHours: base.RollupHourRetentionHours,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -52,33 +55,6 @@ func defaultSQLiteFingerprint() string {
|
||||
return targetFingerprint(&MetricStoreConfig{Driver: "sqlite", DSN: "./data/metrics.db"})
|
||||
}
|
||||
|
||||
// migrateFromPreviousStore 打开由 prevFingerprint 指定的上一个 metrics 目标库作为源,
|
||||
// 把其中的全部指标定义和持久化 rollup 搬运到当前目标 dst(例如 SQLite
|
||||
// metrics.db → MySQL/PostgreSQL)。
|
||||
//
|
||||
// 该过程幂等:dst 以 (series, resolution, labels, bucket) upsert 写入,中断后重启可安全重跑。
|
||||
func migrateFromPreviousStore(prevFingerprint string, cfg *MetricStoreConfig, dst *metric.Store) error {
|
||||
prevCfg, err := configFromFingerprint(prevFingerprint, cfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse previous metrics target %q: %w", prevFingerprint, err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
src, err := openSourceStore(ctx, prevCfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open previous metrics store (%s): %w", prevFingerprint, err)
|
||||
}
|
||||
defer src.Close()
|
||||
|
||||
logger.Infof("metricstore", "Migrating metrics from previous store %s to current target...", prevFingerprint)
|
||||
total, err := MigrateBetweenStores(ctx, src, dst)
|
||||
if err != nil {
|
||||
return fmt.Errorf("store-to-store metrics migration failed: %w", err)
|
||||
}
|
||||
logger.Infof("metricstore", "Store-to-store metrics migration completed (%d rollups)", total)
|
||||
return nil
|
||||
}
|
||||
|
||||
// storeMigrationObserver 在 store-to-store 迁移过程中接收进度回调。
|
||||
// - currentMetric:当前正在搬运的指标名。
|
||||
// - metricIndex:该指标在全部指标中的序号(0 起),即已完成的指标数。
|
||||
@@ -86,19 +62,6 @@ func migrateFromPreviousStore(prevFingerprint string, cfg *MetricStoreConfig, ds
|
||||
// - addedPoints:本次新写入目标库的 rollup 行数(用于外部累计)。
|
||||
type storeMigrationObserver func(currentMetric string, metricIndex, totalMetrics int, addedPoints int64)
|
||||
|
||||
// MigrateBetweenStores 把 src metric store 的全部指标定义与持久化 rollup 搬运到 dst。
|
||||
//
|
||||
// 用于 metrics 后端切换(例如默认 SQLite metrics.db → MySQL/PostgreSQL):
|
||||
// - 先在 dst 建立/更新全部指标定义;
|
||||
// - 再按指标流式读取源库 rollup 并分批写入 dst。
|
||||
//
|
||||
// rollup 在 dst 以 (series, resolution, labels, bucket) upsert 写入,因此整个
|
||||
// 过程可安全重试。返回搬运的 rollup 行数。
|
||||
func MigrateBetweenStores(ctx context.Context, src, dst *metric.Store) (int64, error) {
|
||||
return migrateBetweenStores(ctx, src, dst, nil)
|
||||
}
|
||||
|
||||
// migrateBetweenStores 是 MigrateBetweenStores 的内部实现,额外接受一个可选的进度
|
||||
// 观察者 observe(为 nil 时行为与旧版本完全一致)。WebUI/API 手动触发的迁移传入
|
||||
// 回调以实时更新进度。
|
||||
func migrateBetweenStores(ctx context.Context, src, dst *metric.Store, observe storeMigrationObserver) (int64, error) {
|
||||
File renamed without changes.
@@ -0,0 +1,113 @@
|
||||
package metricstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/komari-monitor/komari/pkg/metric"
|
||||
)
|
||||
|
||||
// WritePingRecord 将 ping 记录写入 metric store
|
||||
func WritePingRecord(ctx context.Context, rec models.PingRecord) error {
|
||||
s := GetStore()
|
||||
if s == nil {
|
||||
return fmt.Errorf("metric store not enabled")
|
||||
}
|
||||
|
||||
reportBatcherMu.Lock()
|
||||
worker := reportBatcher
|
||||
reportBatcherMu.Unlock()
|
||||
if worker != nil {
|
||||
return worker.enqueuePing(ctx, rec)
|
||||
}
|
||||
return writePingRecords(ctx, []models.PingRecord{rec})
|
||||
}
|
||||
|
||||
func writePingRecords(ctx context.Context, records []models.PingRecord) error {
|
||||
s := GetStore()
|
||||
if s == nil {
|
||||
return fmt.Errorf("metric store not enabled")
|
||||
}
|
||||
if len(records) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
points := make([]metric.Point, 0, len(records)*2)
|
||||
for _, rec := range records {
|
||||
tags := map[string]string{"task_id": fmt.Sprintf("%d", rec.TaskId)}
|
||||
loss := 0.0
|
||||
if rec.Value < 0 {
|
||||
loss = 1
|
||||
}
|
||||
points = append(points,
|
||||
metric.Point{
|
||||
MetricName: MetricPingLatency,
|
||||
EntityID: rec.Client,
|
||||
Timestamp: rec.Time,
|
||||
Value: float64(rec.Value),
|
||||
Tags: tags,
|
||||
},
|
||||
metric.Point{
|
||||
MetricName: MetricPingLoss,
|
||||
EntityID: rec.Client,
|
||||
Timestamp: rec.Time,
|
||||
Value: loss,
|
||||
Tags: tags,
|
||||
},
|
||||
)
|
||||
}
|
||||
return s.WriteBatch(ctx, points)
|
||||
}
|
||||
|
||||
// GetPingRecords returns legacy-shaped ping records, newest first.
|
||||
func GetPingRecords(ctx context.Context, clientUUID string, taskID int, start, end time.Time) ([]models.PingRecord, error) {
|
||||
s := GetStore()
|
||||
if s == nil {
|
||||
return nil, fmt.Errorf("metric store not enabled")
|
||||
}
|
||||
|
||||
query := MetricsQuery{
|
||||
Store: s, Mode: QuerySeries, Source: SourceRollup,
|
||||
CompactDefinitions: true,
|
||||
Entities: EntityScope{All: clientUUID == ""},
|
||||
Specs: []MetricSpec{{Name: MetricPingLatency, Aggregations: []metric.Aggregation{metric.AggLast}}},
|
||||
Start: start, End: end, Order: metric.OrderAsc, Grouping: GroupSeries,
|
||||
Budget: PointBudget{PerSeries: 4000}, Rounding: RoundFloor, AllowMissing: true,
|
||||
}
|
||||
if clientUUID != "" {
|
||||
query.Entities.IDs = []string{clientUUID}
|
||||
}
|
||||
if taskID >= 0 {
|
||||
query.Tags = map[string]string{"task_id": fmt.Sprintf("%d", taskID)}
|
||||
}
|
||||
loaded, err := QueryMetrics(ctx, query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
points := loaded.Values[MetricPingLatency][metric.AggLast]
|
||||
records := make([]models.PingRecord, 0, len(points))
|
||||
for _, p := range points {
|
||||
taskIDVal := uint(0)
|
||||
if tid, ok := p.Tags["task_id"]; ok {
|
||||
var t uint64
|
||||
fmt.Sscanf(tid, "%d", &t)
|
||||
taskIDVal = uint(t)
|
||||
}
|
||||
|
||||
records = append(records, models.PingRecord{
|
||||
Client: p.EntityID,
|
||||
TaskId: taskIDVal,
|
||||
Time: p.Bucket.UTC(),
|
||||
Value: int(p.Value),
|
||||
})
|
||||
}
|
||||
sort.Slice(records, func(i, j int) bool {
|
||||
return records[i].Time.After(records[j].Time)
|
||||
})
|
||||
|
||||
return records, nil
|
||||
}
|
||||
@@ -0,0 +1,766 @@
|
||||
package metricstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/komari-monitor/komari/pkg/metric"
|
||||
)
|
||||
|
||||
// QueryMode selects exact samples, adaptive series, or exclusive-boundary lookups.
|
||||
type QueryMode string
|
||||
|
||||
const (
|
||||
QueryPoints QueryMode = "points"
|
||||
QuerySeries QueryMode = "series"
|
||||
QueryLatestBefore QueryMode = "latest_before"
|
||||
)
|
||||
|
||||
type QuerySource string
|
||||
|
||||
const (
|
||||
SourceAuto QuerySource = "auto"
|
||||
SourceRaw QuerySource = "raw"
|
||||
SourceRollup QuerySource = "rollup"
|
||||
)
|
||||
|
||||
type QueryGrouping string
|
||||
|
||||
const (
|
||||
GroupMerged QueryGrouping = "merged"
|
||||
GroupEntity QueryGrouping = "entity"
|
||||
GroupSeries QueryGrouping = "series"
|
||||
)
|
||||
|
||||
type IntervalRounding string
|
||||
|
||||
const (
|
||||
RoundFloor IntervalRounding = "floor"
|
||||
RoundCeil IntervalRounding = "ceil"
|
||||
)
|
||||
|
||||
// EntityScope deliberately distinguishes an empty authorized set from all entities.
|
||||
// All and IDs are mutually exclusive. IDs are deduplicated without modifying callers.
|
||||
type EntityScope struct {
|
||||
All bool
|
||||
IDs []string
|
||||
}
|
||||
type MetricSpec struct {
|
||||
Name string
|
||||
Aggregations []metric.Aggregation
|
||||
// Positive intervals are explicit and are preserved, even for historical data.
|
||||
Interval time.Duration
|
||||
// Zero inherits Budget.PerSeries, then 500. -1 disables adaptive downsampling:
|
||||
// a zero Interval then selects the finest compatible tier, not a whole-window bucket.
|
||||
MaxPoints int
|
||||
}
|
||||
|
||||
// PointBudget controls adaptive interval planning, not a raw-row SQL LIMIT.
|
||||
// Total divides the point target across entities (metrics may be columns of one
|
||||
// legacy record); it is not a hard cap across metrics, aggregations or tag variants.
|
||||
// Raw queries and explicit intervals retain their exact historical semantics.
|
||||
// Response adapters can impose their existing final sampling policy separately.
|
||||
type PointBudget struct {
|
||||
Total int
|
||||
PerSeries int
|
||||
}
|
||||
type FillPolicy struct {
|
||||
Gaps bool
|
||||
PingNegativeAsNull bool
|
||||
}
|
||||
|
||||
// QueryProjection controls retained result representations. The default keeps all
|
||||
// source data for compatibility; chart requires Fill and retains only Filled.
|
||||
type QueryProjection string
|
||||
|
||||
const (
|
||||
ProjectionFull QueryProjection = "full"
|
||||
ProjectionChart QueryProjection = "chart"
|
||||
)
|
||||
|
||||
type MetricsQuery struct {
|
||||
// Output omissions are opt-in; filtering and aggregation identity are unchanged.
|
||||
OmitTags bool
|
||||
OmitLabels bool
|
||||
CompactDefinitions bool
|
||||
Projection QueryProjection
|
||||
Store *metric.Store
|
||||
Mode QueryMode
|
||||
Source QuerySource
|
||||
Entities EntityScope
|
||||
Specs []MetricSpec
|
||||
Start, End time.Time
|
||||
Tags map[string]string
|
||||
Order metric.Order
|
||||
Grouping QueryGrouping
|
||||
Budget PointBudget
|
||||
Rounding IntervalRounding
|
||||
Fill FillPolicy
|
||||
AllowMissing bool
|
||||
Now time.Time
|
||||
}
|
||||
type MetricsResult struct {
|
||||
Definitions map[string]metric.Definition
|
||||
Raw map[string][]metric.Point
|
||||
Values map[string]map[metric.Aggregation][]metric.AggregatePoint
|
||||
Intervals map[string]time.Duration
|
||||
IsRaw bool
|
||||
Filled []MetricSeries
|
||||
// Latest is keyed by metric name, then entity; missing samples are absent.
|
||||
Latest map[string]map[string]metric.Point
|
||||
}
|
||||
type MetricSeries struct {
|
||||
MetricName, EntityID string
|
||||
Aggregation metric.Aggregation
|
||||
Tags map[string]string
|
||||
Interval time.Duration
|
||||
Points []MetricValue
|
||||
}
|
||||
type MetricValue struct {
|
||||
Time time.Time
|
||||
Value *float64
|
||||
Count int
|
||||
Tags, Labels map[string]string
|
||||
}
|
||||
|
||||
// DownsampleInterval returns a standard interval that keeps rangeDuration
|
||||
// within about maxPoints buckets, never finer than one second. roundUp is used
|
||||
// by point budgets: flooring can exceed the budget, while compatibility queries
|
||||
// preserve the historical floor behavior.
|
||||
func DownsampleInterval(rangeDuration time.Duration, maxPoints int, roundUp bool) time.Duration {
|
||||
if maxPoints < 1 {
|
||||
maxPoints = 1
|
||||
}
|
||||
points := time.Duration(maxPoints)
|
||||
interval := (rangeDuration + points - 1) / points
|
||||
if interval < time.Second {
|
||||
return time.Second
|
||||
}
|
||||
if roundUp {
|
||||
return metric.CeilStandardInterval(interval)
|
||||
}
|
||||
return metric.FloorStandardInterval(interval)
|
||||
}
|
||||
|
||||
// QueryMetrics is the shared query policy boundary. Raw and aggregate slices are
|
||||
// transferred directly from batch primitives; nullable chart data is allocated only
|
||||
// when explicitly requested by Fill. All work uses ctx and one captured clock.
|
||||
func QueryMetrics(ctx context.Context, req MetricsQuery) (MetricsResult, error) {
|
||||
if ctx == nil {
|
||||
return MetricsResult{}, fmt.Errorf("%w: context is required", metric.ErrInvalidArgument)
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return MetricsResult{}, err
|
||||
}
|
||||
result := MetricsResult{Definitions: map[string]metric.Definition{}, Raw: map[string][]metric.Point{}, Values: map[string]map[metric.Aggregation][]metric.AggregatePoint{}, Intervals: map[string]time.Duration{}, Latest: map[string]map[string]metric.Point{}}
|
||||
if req.Mode == "" {
|
||||
req.Mode = QuerySeries
|
||||
}
|
||||
if req.Source == "" {
|
||||
req.Source = SourceAuto
|
||||
}
|
||||
if req.Grouping == "" {
|
||||
req.Grouping = GroupMerged
|
||||
}
|
||||
if req.Order == "" {
|
||||
req.Order = metric.OrderAsc
|
||||
}
|
||||
if req.Rounding == "" {
|
||||
req.Rounding = RoundFloor
|
||||
}
|
||||
if err := validateMetricsQuery(req); err != nil {
|
||||
return result, err
|
||||
}
|
||||
now := req.Now
|
||||
if now.IsZero() {
|
||||
now = time.Now()
|
||||
}
|
||||
now = now.UTC()
|
||||
req.Start, req.End = req.Start.UTC(), req.End.UTC()
|
||||
req.Entities.IDs = uniqueQueryIDs(req.Entities.IDs)
|
||||
// Never let the primitive's empty entity filter broaden an empty authorized set.
|
||||
|
||||
s := req.Store
|
||||
if s == nil {
|
||||
s = GetStore()
|
||||
}
|
||||
if s == nil {
|
||||
return result, fmt.Errorf("metric store not enabled")
|
||||
}
|
||||
names := make([]string, 0, len(req.Specs))
|
||||
for _, spec := range req.Specs {
|
||||
names = append(names, spec.Name)
|
||||
}
|
||||
loadDefinitions := s.GetMetrics
|
||||
if req.CompactDefinitions {
|
||||
loadDefinitions = s.GetMetricsCompact
|
||||
}
|
||||
if !req.Entities.All && len(req.Entities.IDs) == 0 {
|
||||
definitions, err := loadDefinitions(ctx, names)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
result.Definitions = definitions
|
||||
result.IsRaw = req.Source == SourceRaw || (req.Source == SourceAuto && (req.Mode == QueryPoints || UsesRawWindow(req.Start, req.End, now)))
|
||||
if err := checkQueryDefinitions(names, definitions, req.AllowMissing); err != nil {
|
||||
return result, err
|
||||
}
|
||||
return result, ctx.Err()
|
||||
}
|
||||
if req.Mode == QueryLatestBefore {
|
||||
definitions, err := loadDefinitions(ctx, names)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
result.Definitions = definitions
|
||||
if err := checkQueryDefinitions(names, definitions, req.AllowMissing); err != nil {
|
||||
return result, err
|
||||
}
|
||||
for _, name := range names {
|
||||
if _, ok := definitions[name]; !ok {
|
||||
continue
|
||||
}
|
||||
ids := req.Entities.IDs
|
||||
|
||||
for _, id := range ids {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return result, err
|
||||
}
|
||||
point, found, err := s.LatestBefore(ctx, name, id, req.End)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
if found {
|
||||
if result.Latest[name] == nil {
|
||||
result.Latest[name] = map[string]metric.Point{}
|
||||
}
|
||||
result.Latest[name][id] = point
|
||||
}
|
||||
}
|
||||
}
|
||||
return result, ctx.Err()
|
||||
}
|
||||
result.IsRaw = req.Source == SourceRaw || (req.Source == SourceAuto && (req.Mode == QueryPoints || UsesRawWindow(req.Start, req.End, now)))
|
||||
if result.IsRaw {
|
||||
definitions, err := loadDefinitions(ctx, names)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
result.Definitions = definitions
|
||||
if err := checkQueryDefinitions(names, definitions, req.AllowMissing); err != nil {
|
||||
return result, err
|
||||
}
|
||||
known := make([]string, 0, len(names))
|
||||
for _, name := range names {
|
||||
if _, ok := definitions[name]; ok {
|
||||
known = append(known, name)
|
||||
result.Intervals[name] = 0
|
||||
}
|
||||
}
|
||||
if len(known) > 0 {
|
||||
result.Raw, err = s.QueryBatch(ctx, metric.BatchQuery{MetricNames: known, EntityIDs: req.Entities.IDs, Start: req.Start, End: req.End, Tags: req.Tags, Order: req.Order, OmitTags: req.OmitTags, OmitLabels: req.OmitLabels, IncludeSeriesIdentity: req.Fill.Gaps || req.Fill.PingNegativeAsNull})
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
}
|
||||
} else {
|
||||
entityCount := len(req.Entities.IDs)
|
||||
if needsTotalEntityCount(req) {
|
||||
if req.Entities.All {
|
||||
count, err := s.CountEntities(ctx, names)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
entityCount = count
|
||||
}
|
||||
// Explicit IDs are a conservative dictionary-count upper bound; never
|
||||
// scan every metric/tier simply to plan a response budget.
|
||||
}
|
||||
if entityCount < 1 {
|
||||
entityCount = 1
|
||||
}
|
||||
specs := make([]metric.BatchSeriesSpec, 0, len(req.Specs))
|
||||
for _, spec := range req.Specs {
|
||||
interval := spec.Interval
|
||||
if interval == 0 {
|
||||
points := spec.MaxPoints
|
||||
if points == 0 {
|
||||
points = req.Budget.PerSeries
|
||||
}
|
||||
if points == 0 {
|
||||
points = 500
|
||||
}
|
||||
if points == 500 && spec.MaxPoints == 0 && req.Budget.PerSeries == 0 && req.Budget.Total == -1 {
|
||||
points = -1
|
||||
}
|
||||
interval = time.Second
|
||||
if points != -1 {
|
||||
if req.Budget.Total > 0 {
|
||||
budget := req.Budget.Total / entityCount
|
||||
if budget < 1 {
|
||||
budget = 1
|
||||
}
|
||||
if budget < points {
|
||||
points = budget
|
||||
}
|
||||
}
|
||||
interval = DownsampleInterval(req.End.Sub(req.Start), points, req.Rounding == RoundCeil)
|
||||
}
|
||||
interval = s.CompatibleSeriesInterval(req.Start, now, interval)
|
||||
if interval <= 0 {
|
||||
interval = time.Second
|
||||
}
|
||||
}
|
||||
aggregations := spec.Aggregations
|
||||
if len(aggregations) == 0 {
|
||||
aggregations = []metric.Aggregation{metric.AggAvg}
|
||||
}
|
||||
specs = append(specs, metric.BatchSeriesSpec{MetricName: spec.Name, Aggregations: aggregations, Interval: interval, PreserveSeries: req.Grouping == GroupSeries, GroupByEntity: req.Grouping == GroupEntity})
|
||||
result.Intervals[spec.Name] = interval
|
||||
}
|
||||
batch, err := s.SeriesBatch(ctx, metric.BatchSeriesQuery{Specs: specs, EntityIDs: req.Entities.IDs, Start: req.Start, End: req.End, Tags: req.Tags, Order: req.Order, OmitTags: req.OmitTags, CompactDefinitions: req.CompactDefinitions, IncludeSeriesIdentity: req.Fill.Gaps || req.Fill.PingNegativeAsNull}, now)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
result.Definitions, result.Values = batch.Definitions, batch.Values
|
||||
// SeriesBatch already loads metadata. Validate its result rather than issue a
|
||||
// second metadata query; unknown metrics otherwise look like empty series.
|
||||
if err := checkQueryDefinitions(names, batch.Definitions, req.AllowMissing); err != nil {
|
||||
return result, err
|
||||
}
|
||||
for _, name := range names {
|
||||
if _, ok := batch.Definitions[name]; !ok {
|
||||
delete(result.Intervals, name)
|
||||
delete(result.Values, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
if req.Fill.Gaps || req.Fill.PingNegativeAsNull {
|
||||
filled, err := buildQuerySeriesOwned(ctx, req, result, req.Projection == ProjectionChart)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
result.Filled = filled
|
||||
if req.Projection == ProjectionChart {
|
||||
result.Raw, result.Values = nil, nil
|
||||
}
|
||||
}
|
||||
return result, ctx.Err()
|
||||
}
|
||||
|
||||
// needsTotalEntityCount excludes explicit intervals and unlimited specs: neither
|
||||
// consumes the total adaptive budget. Keep its precedence aligned with planning.
|
||||
func needsTotalEntityCount(req MetricsQuery) bool {
|
||||
if req.Budget.Total <= 0 {
|
||||
return false
|
||||
}
|
||||
for _, spec := range req.Specs {
|
||||
if spec.Interval != 0 {
|
||||
continue
|
||||
}
|
||||
points := spec.MaxPoints
|
||||
if points == 0 {
|
||||
points = req.Budget.PerSeries
|
||||
}
|
||||
if points != -1 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func validateMetricsQuery(req MetricsQuery) error {
|
||||
invalid := func(message string) error { return fmt.Errorf("%w: %s", metric.ErrInvalidArgument, message) }
|
||||
if req.OmitTags && (req.Fill.Gaps || req.Fill.PingNegativeAsNull) {
|
||||
return invalid("omitting tags with filled output is unsupported: series identity must be preserved")
|
||||
}
|
||||
if req.Mode == QueryLatestBefore && (req.OmitTags || req.OmitLabels) {
|
||||
return invalid("latest-before output omissions are unsupported")
|
||||
}
|
||||
switch req.Mode {
|
||||
case QueryPoints, QuerySeries, QueryLatestBefore:
|
||||
default:
|
||||
return invalid("unsupported query mode")
|
||||
}
|
||||
switch req.Source {
|
||||
case SourceAuto, SourceRaw, SourceRollup:
|
||||
default:
|
||||
return invalid("unsupported query source")
|
||||
}
|
||||
switch req.Grouping {
|
||||
case GroupMerged, GroupEntity, GroupSeries:
|
||||
default:
|
||||
return invalid("unsupported query grouping")
|
||||
}
|
||||
switch req.Projection {
|
||||
case "", ProjectionFull:
|
||||
case ProjectionChart:
|
||||
if req.Mode == QueryLatestBefore || (!req.Fill.Gaps && !req.Fill.PingNegativeAsNull) {
|
||||
return invalid("chart projection requires fill and does not support latest-before")
|
||||
}
|
||||
default:
|
||||
return invalid("unsupported query projection")
|
||||
}
|
||||
switch req.Rounding {
|
||||
case RoundFloor, RoundCeil:
|
||||
default:
|
||||
return invalid("unsupported interval rounding")
|
||||
}
|
||||
if req.Order != metric.OrderAsc && req.Order != metric.OrderDesc {
|
||||
return invalid("unsupported query order")
|
||||
}
|
||||
if req.Entities.All && len(req.Entities.IDs) > 0 {
|
||||
return invalid("all entities and explicit IDs are mutually exclusive")
|
||||
}
|
||||
for _, id := range req.Entities.IDs {
|
||||
if strings.TrimSpace(id) == "" {
|
||||
return invalid("entity ID cannot be empty")
|
||||
}
|
||||
}
|
||||
if req.End.IsZero() {
|
||||
return invalid("end time is required")
|
||||
}
|
||||
if req.Mode != QueryLatestBefore && (req.Start.IsZero() || req.End.Before(req.Start)) {
|
||||
return invalid("valid start and end times are required")
|
||||
}
|
||||
if req.Mode == QueryLatestBefore && (len(req.Tags) > 0 || req.Source != SourceAuto) {
|
||||
return invalid("latest-before requires auto source and does not support tag filters")
|
||||
}
|
||||
if req.Mode == QueryPoints && req.Source == SourceRollup {
|
||||
return invalid("points mode requires raw or auto source")
|
||||
}
|
||||
if req.Mode == QueryLatestBefore && req.Entities.All {
|
||||
return invalid("latest-before requires explicit entity IDs")
|
||||
}
|
||||
if req.Budget.Total < -1 || req.Budget.PerSeries < -1 {
|
||||
return invalid("invalid point budget")
|
||||
}
|
||||
if len(req.Specs) == 0 {
|
||||
return invalid("at least one metric specification is required")
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, spec := range req.Specs {
|
||||
if strings.TrimSpace(spec.Name) == "" || seen[spec.Name] {
|
||||
return invalid("metric names must be nonempty and unique")
|
||||
}
|
||||
seen[spec.Name] = true
|
||||
if spec.Interval < 0 || spec.MaxPoints < -1 {
|
||||
return invalid("invalid metric interval or point budget")
|
||||
}
|
||||
for _, agg := range spec.Aggregations {
|
||||
// Reuse primitive aggregation validation even when the selected source is raw.
|
||||
if err := (metric.AggregateQuery{Query: metric.Query{MetricName: spec.Name, Start: req.End, End: req.End}, Aggregation: agg, Interval: time.Second}).Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UsesRawWindow selects the public adaptive raw window; its end boundary is strict.
|
||||
func UsesRawWindow(start, end, now time.Time) bool {
|
||||
return end.Sub(start) <= 10*time.Minute && end.After(now.Add(-10*time.Minute))
|
||||
}
|
||||
|
||||
func uniqueQueryIDs(ids []string) []string {
|
||||
out := make([]string, 0, len(ids))
|
||||
seen := map[string]bool{}
|
||||
for _, id := range ids {
|
||||
if !seen[id] {
|
||||
out = append(out, id)
|
||||
seen[id] = true
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
func checkQueryDefinitions(names []string, definitions map[string]metric.Definition, allowMissing bool) error {
|
||||
if !allowMissing {
|
||||
for _, name := range names {
|
||||
if _, ok := definitions[name]; !ok {
|
||||
return fmt.Errorf("%w: metric %q", metric.ErrNotFound, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// querySeriesKey consumes storage's immutable, collision-free output identity.
|
||||
// Manually constructed points (and low-level queries without the opt-in) retain
|
||||
// the old canonical fallback. Neither grouping nor ordering uses a hash.
|
||||
func querySeriesKey(entity string, tags map[string]string, identity *metric.SeriesIdentity) string {
|
||||
if key := identity.Key(); key != "" {
|
||||
return key
|
||||
}
|
||||
return metric.NewSeriesIdentity(entity, tags).Key()
|
||||
}
|
||||
|
||||
func buildQuerySeries(ctx context.Context, req MetricsQuery, result MetricsResult) ([]MetricSeries, error) {
|
||||
return buildQuerySeriesOwned(ctx, req, result, false)
|
||||
}
|
||||
|
||||
// detach is only valid for QueryMetrics-owned source maps. Release each source
|
||||
// array after conversion, before allocating gaps or processing the next metric.
|
||||
func buildQuerySeriesOwned(ctx context.Context, req MetricsQuery, result MetricsResult, detach bool) ([]MetricSeries, error) {
|
||||
out := []MetricSeries{}
|
||||
for _, spec := range req.Specs {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, ok := result.Definitions[spec.Name]; !ok {
|
||||
continue
|
||||
}
|
||||
aggregations := spec.Aggregations
|
||||
if result.IsRaw {
|
||||
aggregations = []metric.Aggregation{""}
|
||||
} else if len(aggregations) == 0 {
|
||||
aggregations = []metric.Aggregation{metric.AggAvg}
|
||||
}
|
||||
for aggIndex, agg := range aggregations {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var seenEntities map[string]bool
|
||||
if len(req.Entities.IDs) > 0 && (result.IsRaw || req.Grouping != GroupMerged) {
|
||||
seenEntities = make(map[string]bool, len(req.Entities.IDs))
|
||||
}
|
||||
groups := map[string]*MetricSeries{}
|
||||
add := func(entity string, tags map[string]string, identity *metric.SeriesIdentity, value MetricValue) {
|
||||
// Exact samples always preserve identity; aggregate identity is determined by
|
||||
// the requested primitive grouping and must not be collapsed again here.
|
||||
key := querySeriesKey(entity, tags, identity)
|
||||
series := groups[key]
|
||||
if series == nil {
|
||||
series = &MetricSeries{MetricName: spec.Name, EntityID: entity, Aggregation: agg, Tags: tags, Interval: result.Intervals[spec.Name]}
|
||||
groups[key] = series
|
||||
if seenEntities != nil {
|
||||
seenEntities[entity] = true
|
||||
}
|
||||
}
|
||||
if req.Fill.PingNegativeAsNull && (spec.Name == MetricPingLatency || spec.Name == MetricPingLoss) && value.Value != nil && *value.Value == -1 {
|
||||
value.Value = nil
|
||||
}
|
||||
series.Points = append(series.Points, value)
|
||||
}
|
||||
if result.IsRaw {
|
||||
for i, p := range result.Raw[spec.Name] {
|
||||
if i%256 == 0 {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
v := p.Value
|
||||
add(p.EntityID, p.Tags, p.SeriesIdentity, MetricValue{Time: p.Timestamp, Value: &v, Count: 1, Tags: p.Tags, Labels: p.Labels})
|
||||
}
|
||||
} else {
|
||||
for i, p := range result.Values[spec.Name][agg] {
|
||||
if i%256 == 0 {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
v := p.Value
|
||||
add(p.EntityID, p.Tags, p.SeriesIdentity, MetricValue{Time: p.Bucket, Value: &v, Count: p.Count, Tags: p.Tags})
|
||||
}
|
||||
}
|
||||
if detach {
|
||||
if result.IsRaw {
|
||||
delete(result.Raw, spec.Name)
|
||||
} else {
|
||||
// Duplicate aggregation specs historically produce duplicate series.
|
||||
needed := false
|
||||
for _, next := range aggregations[aggIndex+1:] {
|
||||
needed = needed || next == agg
|
||||
}
|
||||
if !needed {
|
||||
delete(result.Values[spec.Name], agg)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Seed missing entities directly; do not scan all groups for each ID or
|
||||
// allocate a placeholder point only to discard it.
|
||||
seed := func(id string) {
|
||||
groups[querySeriesKey(id, req.Tags, nil)] = &MetricSeries{MetricName: spec.Name, EntityID: id, Aggregation: agg, Tags: req.Tags, Interval: result.Intervals[spec.Name]}
|
||||
if seenEntities != nil {
|
||||
seenEntities[id] = true
|
||||
}
|
||||
}
|
||||
if result.IsRaw || req.Grouping != GroupMerged {
|
||||
for i, id := range req.Entities.IDs {
|
||||
if i%256 == 0 {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if !seenEntities[id] {
|
||||
seed(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(groups) == 0 {
|
||||
ids := req.Entities.IDs
|
||||
if len(ids) == 0 || (!result.IsRaw && req.Grouping == GroupMerged) {
|
||||
ids = []string{""}
|
||||
}
|
||||
for _, id := range ids {
|
||||
seed(id)
|
||||
}
|
||||
}
|
||||
|
||||
keys := make([]string, 0, len(groups))
|
||||
for key := range groups {
|
||||
if len(keys)%256 == 0 {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
keys = append(keys, key)
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, key := range keys {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
series := *groups[key]
|
||||
sort.SliceStable(series.Points, func(i, j int) bool { return series.Points[i].Time.Before(series.Points[j].Time) })
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if req.Fill.Gaps {
|
||||
var err error
|
||||
series, err = fillMetricSeriesOwned(ctx, series, req.Start, req.End)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if req.Order == metric.OrderDesc {
|
||||
for i, j := 0, len(series.Points)-1; i < j; i, j = i+1, j-1 {
|
||||
if i%256 == 0 {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
series.Points[i], series.Points[j] = series.Points[j], series.Points[i]
|
||||
}
|
||||
}
|
||||
out = append(out, series)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out, ctx.Err()
|
||||
}
|
||||
|
||||
// FillMetricSeries matches the public chart's adaptive lower-quartile gap policy.
|
||||
func FillMetricSeries(series MetricSeries, start, end time.Time) MetricSeries {
|
||||
filled, _ := fillMetricSeries(context.Background(), series, start, end)
|
||||
return filled
|
||||
}
|
||||
|
||||
func fillMetricSeries(ctx context.Context, series MetricSeries, start, end time.Time) (MetricSeries, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return series, err
|
||||
}
|
||||
// Public callers retain ownership, including spare capacity. Never write there.
|
||||
series.Points = append([]MetricValue(nil), series.Points...)
|
||||
return fillMetricSeriesOwned(ctx, series, start, end)
|
||||
}
|
||||
|
||||
// fillMetricSeriesOwned inserts gaps backwards into exclusively owned storage.
|
||||
// Dense series reuse their point array; sparse series grow only when capacity
|
||||
// cannot accommodate the exact number of gaps, rather than always copying into
|
||||
// a second full point array. The public wrapper above remains non-mutating.
|
||||
func fillMetricSeriesOwned(ctx context.Context, series MetricSeries, start, end time.Time) (MetricSeries, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return series, err
|
||||
}
|
||||
deltas := make([]time.Duration, 0, len(series.Points))
|
||||
for i := 1; i < len(series.Points); i++ {
|
||||
if i%256 == 0 {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return series, err
|
||||
}
|
||||
}
|
||||
if d := series.Points[i].Time.Sub(series.Points[i-1].Time); d > 0 {
|
||||
deltas = append(deltas, d)
|
||||
}
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return series, err
|
||||
}
|
||||
expected := series.Interval
|
||||
if len(deltas) >= 2 {
|
||||
sort.Slice(deltas, func(i, j int) bool { return deltas[i] < deltas[j] })
|
||||
if observed := deltas[(len(deltas)-1)/4]; observed > expected {
|
||||
expected = observed
|
||||
}
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return series, err
|
||||
}
|
||||
if expected > 0 {
|
||||
series.Interval = expected
|
||||
}
|
||||
null := func(at time.Time) MetricValue { return MetricValue{Time: at.UTC(), Tags: series.Tags} }
|
||||
n := len(series.Points)
|
||||
if n == 0 {
|
||||
series.Points = append(series.Points, null(start), null(end))
|
||||
return series, ctx.Err()
|
||||
}
|
||||
leading := start.Before(series.Points[0].Time)
|
||||
gap := func(i int) bool {
|
||||
return i > 0 && expected > 0 && series.Points[i-1].Value != nil && series.Points[i].Value != nil && series.Points[i].Time.Sub(series.Points[i-1].Time) > expected+expected/2
|
||||
}
|
||||
extra := 0
|
||||
if leading {
|
||||
extra++
|
||||
}
|
||||
for i := 1; i < n; i++ {
|
||||
if i%256 == 0 {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return series, err
|
||||
}
|
||||
}
|
||||
if gap(i) {
|
||||
extra++
|
||||
}
|
||||
}
|
||||
if extra == 0 {
|
||||
return series, ctx.Err()
|
||||
}
|
||||
if cap(series.Points) < n+extra {
|
||||
points := make([]MetricValue, n+extra)
|
||||
copy(points, series.Points)
|
||||
series.Points = points
|
||||
} else {
|
||||
series.Points = series.Points[:n+extra]
|
||||
}
|
||||
write := n + extra - 1
|
||||
for i := n - 1; i >= 0; i-- {
|
||||
if i%256 == 0 {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return series, err
|
||||
}
|
||||
}
|
||||
// Decide before moving: write may equal i, but never precedes it.
|
||||
insert := gap(i)
|
||||
var at time.Time
|
||||
if insert {
|
||||
at = series.Points[i-1].Time.Add(expected)
|
||||
}
|
||||
series.Points[write] = series.Points[i]
|
||||
write--
|
||||
if insert {
|
||||
series.Points[write] = null(at)
|
||||
write--
|
||||
}
|
||||
}
|
||||
if leading {
|
||||
series.Points[0] = null(start)
|
||||
}
|
||||
return series, ctx.Err()
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package metricstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
|
||||
"github.com/komari-monitor/komari/pkg/metric"
|
||||
)
|
||||
|
||||
// QueryContext runs a raw read query against the active metric store. The
|
||||
// caller must invoke the returned release function after closing rows.
|
||||
func QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, metric.Driver, func(), error) {
|
||||
return QueryForDriver(ctx, func(metric.Driver) (string, error) {
|
||||
return query, nil
|
||||
}, args...)
|
||||
}
|
||||
|
||||
// QueryForDriver runs a raw read query whose SQL depends on the active store's
|
||||
// driver. The caller must invoke the returned release function after closing rows.
|
||||
func QueryForDriver(ctx context.Context, query func(metric.Driver) (string, error), args ...any) (*sql.Rows, metric.Driver, func(), error) {
|
||||
if err := storeOperations.AcquireShared(ctx); err != nil {
|
||||
return nil, "", nil, fmt.Errorf("wait for metric store operations before query: %w", err)
|
||||
}
|
||||
|
||||
storeMu.RLock()
|
||||
activeStore := store
|
||||
storeMu.RUnlock()
|
||||
if activeStore == nil {
|
||||
storeOperations.ReleaseShared()
|
||||
return nil, "", nil, ErrStoreNotInitialized
|
||||
}
|
||||
|
||||
statement, err := query(activeStore.Driver())
|
||||
if err != nil {
|
||||
storeOperations.ReleaseShared()
|
||||
return nil, "", nil, err
|
||||
}
|
||||
rows, err := activeStore.QueryContext(ctx, statement, args...)
|
||||
if err != nil {
|
||||
storeOperations.ReleaseShared()
|
||||
return nil, "", nil, err
|
||||
}
|
||||
return rows, activeStore.Driver(), storeOperations.ReleaseShared, nil
|
||||
}
|
||||
|
||||
// ExecContext runs a raw statement against the active metric store.
|
||||
func ExecContext(ctx context.Context, query string, args ...any) (sql.Result, metric.Driver, error) {
|
||||
if err := storeOperations.Acquire(ctx); err != nil {
|
||||
return nil, "", fmt.Errorf("wait for metric store operations before execution: %w", err)
|
||||
}
|
||||
defer storeOperations.Release()
|
||||
|
||||
storeMu.RLock()
|
||||
activeStore := store
|
||||
storeMu.RUnlock()
|
||||
if activeStore == nil {
|
||||
return nil, "", ErrStoreNotInitialized
|
||||
}
|
||||
|
||||
result, err := activeStore.ExecContext(ctx, query, args...)
|
||||
return result, activeStore.Driver(), err
|
||||
}
|
||||
File renamed without changes.
File renamed without changes.
@@ -4,14 +4,14 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
logger "github.com/komari-monitor/komari/utils/log"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
logger "github.com/komari-monitor/komari/utils/log"
|
||||
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/komari-monitor/komari/pkg/metric"
|
||||
v1 "github.com/komari-monitor/komari/protocol/v1"
|
||||
v2 "github.com/komari-monitor/komari/protocol/v2"
|
||||
)
|
||||
|
||||
type reportTrafficState struct {
|
||||
@@ -25,17 +25,25 @@ type reportTrafficValues struct {
|
||||
timestamp time.Time
|
||||
hasUp bool
|
||||
totalUp int64
|
||||
totalUpAt time.Time
|
||||
hasDown bool
|
||||
totalDown int64
|
||||
totalDownAt time.Time
|
||||
}
|
||||
|
||||
var reportTrafficStates sync.Map
|
||||
var (
|
||||
reportTrafficStates sync.Map
|
||||
reportWriteMu sync.Mutex
|
||||
)
|
||||
|
||||
const (
|
||||
reportBatchInterval = 3 * time.Second
|
||||
reportBatchQueueSize = 4096
|
||||
reportBatchQueueSize = 512
|
||||
pingBatchQueueSize = 4096
|
||||
pingBatchMaxRecords = 512
|
||||
reportBatchWriteTimeout = 10 * time.Second
|
||||
// Longer gaps have no reliable per-bucket traffic attribution.
|
||||
maxTrafficReportGap = 15 * time.Minute
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -57,7 +65,7 @@ type reportBatchRequest struct {
|
||||
|
||||
type reportBatchWorker struct {
|
||||
mu sync.Mutex
|
||||
queue chan v1.Report
|
||||
queue chan v2.Report
|
||||
pingQueue chan models.PingRecord
|
||||
requests chan reportBatchRequest
|
||||
done chan struct{}
|
||||
@@ -73,8 +81,8 @@ func StartReportBatcher() {
|
||||
return
|
||||
}
|
||||
worker := &reportBatchWorker{
|
||||
queue: make(chan v1.Report, reportBatchQueueSize),
|
||||
pingQueue: make(chan models.PingRecord, reportBatchQueueSize),
|
||||
queue: make(chan v2.Report, reportBatchQueueSize),
|
||||
pingQueue: make(chan models.PingRecord, pingBatchQueueSize),
|
||||
requests: make(chan reportBatchRequest, 1),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
@@ -150,16 +158,16 @@ func FlushReportBatch(ctx context.Context) error {
|
||||
// WriteReport persists one agent report and adds it to in-memory minute
|
||||
// summaries using the same server receive time. Traffic deltas remain summable
|
||||
// after rollup.
|
||||
func WriteReport(ctx context.Context, report v1.Report) (v1.Report, error) {
|
||||
func WriteReport(ctx context.Context, report v2.Report) (v2.Report, error) {
|
||||
if report.UUID == "" {
|
||||
return v1.Report{}, fmt.Errorf("report UUID is required")
|
||||
return v2.Report{}, fmt.Errorf("report UUID is required")
|
||||
}
|
||||
if report.UpdatedAt.IsZero() {
|
||||
return v1.Report{}, fmt.Errorf("report receive time is required")
|
||||
return v2.Report{}, fmt.Errorf("report receive time is required")
|
||||
}
|
||||
report.UpdatedAt = report.UpdatedAt.UTC()
|
||||
if GetStore() == nil {
|
||||
return v1.Report{}, fmt.Errorf("metric store not enabled")
|
||||
return v2.Report{}, fmt.Errorf("metric store not enabled")
|
||||
}
|
||||
|
||||
reportBatcherMu.Lock()
|
||||
@@ -167,19 +175,19 @@ func WriteReport(ctx context.Context, report v1.Report) (v1.Report, error) {
|
||||
reportBatcherMu.Unlock()
|
||||
if worker != nil {
|
||||
if err := worker.enqueue(ctx, report); err != nil {
|
||||
return v1.Report{}, err
|
||||
return v2.Report{}, err
|
||||
}
|
||||
return report, nil
|
||||
}
|
||||
|
||||
saved, err := writeReportBatch(ctx, []v1.Report{report})
|
||||
saved, err := writeReportBatch(ctx, []v2.Report{report})
|
||||
if err != nil {
|
||||
return v1.Report{}, err
|
||||
return v2.Report{}, err
|
||||
}
|
||||
return saved[0], nil
|
||||
}
|
||||
|
||||
func (w *reportBatchWorker) enqueue(ctx context.Context, report v1.Report) error {
|
||||
func (w *reportBatchWorker) enqueue(ctx context.Context, report v2.Report) error {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
if w.stopping {
|
||||
@@ -200,13 +208,13 @@ func (w *reportBatchWorker) run() {
|
||||
ticker := time.NewTicker(reportBatchInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
var pending []v1.Report
|
||||
var pending []v2.Report
|
||||
var pendingPings []models.PingRecord
|
||||
for {
|
||||
select {
|
||||
case request := <-w.requests:
|
||||
pending = append(pending, drainReportQueue(w.queue, reportBatchQueueSize)...)
|
||||
pendingPings = append(pendingPings, drainPingQueue(w.pingQueue, reportBatchQueueSize)...)
|
||||
pending = append(pending, drainQueue(w.queue, reportBatchQueueSize)...)
|
||||
pendingPings = append(pendingPings, drainQueue(w.pingQueue, pingBatchQueueSize)...)
|
||||
err := errors.Join(
|
||||
writePendingReports(request.ctx, &pending),
|
||||
writePendingPingRecords(request.ctx, &pendingPings),
|
||||
@@ -221,11 +229,11 @@ func (w *reportBatchWorker) run() {
|
||||
}
|
||||
request.done <- err
|
||||
case <-ticker.C:
|
||||
pendingPings = append(pendingPings, drainPingQueue(w.pingQueue, reportBatchQueueSize)...)
|
||||
pendingPings = append(pendingPings, drainQueue(w.pingQueue, pingBatchQueueSize)...)
|
||||
if err := writePendingPingRecords(context.Background(), &pendingPings); err != nil {
|
||||
logger.Errorf("metricstore", "failed to flush ping batch: %v", err)
|
||||
}
|
||||
pending = append(pending, drainReportQueue(w.queue, reportBatchQueueSize)...)
|
||||
pending = append(pending, drainQueue(w.queue, reportBatchQueueSize)...)
|
||||
if err := writePendingReports(context.Background(), &pending); err != nil {
|
||||
logger.Errorf("metricstore", "failed to flush metric report batch: %v", err)
|
||||
}
|
||||
@@ -250,55 +258,36 @@ func (w *reportBatchWorker) enqueuePing(ctx context.Context, record models.PingR
|
||||
}
|
||||
}
|
||||
|
||||
func drainReportQueue(queue <-chan v1.Report, limit int) []v1.Report {
|
||||
if limit <= 0 {
|
||||
// drainQueue takes up to limit queued items without blocking.
|
||||
func drainQueue[T any](queue <-chan T, limit int) []T {
|
||||
n := min(len(queue), limit)
|
||||
if n <= 0 {
|
||||
return nil
|
||||
}
|
||||
reports := make([]v1.Report, 0, limit)
|
||||
for len(reports) < limit {
|
||||
items := make([]T, 0, n)
|
||||
for len(items) < limit {
|
||||
select {
|
||||
case report := <-queue:
|
||||
reports = append(reports, report)
|
||||
case item := <-queue:
|
||||
items = append(items, item)
|
||||
default:
|
||||
return reports
|
||||
return items
|
||||
}
|
||||
}
|
||||
return reports
|
||||
return items
|
||||
}
|
||||
|
||||
func drainPingQueue(queue <-chan models.PingRecord, limit int) []models.PingRecord {
|
||||
if limit <= 0 {
|
||||
return nil
|
||||
}
|
||||
records := make([]models.PingRecord, 0, limit)
|
||||
for len(records) < limit {
|
||||
select {
|
||||
case record := <-queue:
|
||||
records = append(records, record)
|
||||
default:
|
||||
return records
|
||||
}
|
||||
}
|
||||
return records
|
||||
}
|
||||
|
||||
func writePendingReports(ctx context.Context, pending *[]v1.Report) error {
|
||||
// writePendingReports commits all pending reports as one batch and keeps them
|
||||
// for the next attempt when the write fails.
|
||||
func writePendingReports(ctx context.Context, pending *[]v2.Report) error {
|
||||
if len(*pending) == 0 {
|
||||
return nil
|
||||
}
|
||||
batchSize := len(*pending)
|
||||
for len(*pending) > 0 {
|
||||
if batchSize > len(*pending) {
|
||||
batchSize = len(*pending)
|
||||
}
|
||||
writeCtx, cancel := context.WithTimeout(ctx, reportBatchWriteTimeout)
|
||||
_, err := writeReportBatch(writeCtx, (*pending)[:batchSize])
|
||||
cancel()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*pending = (*pending)[batchSize:]
|
||||
writeCtx, cancel := context.WithTimeout(ctx, reportBatchWriteTimeout)
|
||||
defer cancel()
|
||||
if _, err := writeReportBatch(writeCtx, *pending); err != nil {
|
||||
return err
|
||||
}
|
||||
*pending = (*pending)[:0]
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -319,7 +308,7 @@ func writePendingPingRecords(ctx context.Context, pending *[]models.PingRecord)
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeReportBatch(ctx context.Context, reports []v1.Report) ([]v1.Report, error) {
|
||||
func writeReportBatch(ctx context.Context, reports []v2.Report) ([]v2.Report, error) {
|
||||
if len(reports) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -328,12 +317,17 @@ func writeReportBatch(ctx context.Context, reports []v1.Report) ([]v1.Report, er
|
||||
}
|
||||
defer storeOperations.ReleaseShared()
|
||||
|
||||
// Serialize baseline reads and commits, including direct writes without
|
||||
// the background batcher.
|
||||
reportWriteMu.Lock()
|
||||
defer reportWriteMu.Unlock()
|
||||
|
||||
s := GetStore()
|
||||
if s == nil {
|
||||
return nil, fmt.Errorf("metric store not enabled")
|
||||
}
|
||||
|
||||
prepared := make([]v1.Report, len(reports))
|
||||
prepared := make([]v2.Report, len(reports))
|
||||
copy(prepared, reports)
|
||||
points := make([]metric.Point, 0, len(reports)*20)
|
||||
pendingStates := make(map[*reportTrafficState]reportTrafficValues)
|
||||
@@ -347,43 +341,50 @@ func writeReportBatch(ctx context.Context, reports []v1.Report) ([]v1.Report, er
|
||||
state.mu.Unlock()
|
||||
}
|
||||
if !values.initialized {
|
||||
totalUp, hasUp, err := latestReportCounter(ctx, s, MetricNetTotalUp, report.UUID, report.UpdatedAt)
|
||||
if err == nil {
|
||||
values.totalUp = totalUp
|
||||
values.hasUp = hasUp
|
||||
} else if ctx.Err() == nil {
|
||||
totalUp, hasUp, err := latestReportPoint(ctx, s, MetricNetTotalUp, report.UUID, report.UpdatedAt)
|
||||
if err != nil {
|
||||
logger.Errorf("metricstore", "failed to restore previous upload counter for %s: %v", report.UUID, err)
|
||||
} else {
|
||||
values.totalUp = int64(totalUp.Value)
|
||||
values.totalUpAt = totalUp.Timestamp
|
||||
values.hasUp = hasUp
|
||||
}
|
||||
totalDown, hasDown, err := latestReportCounter(ctx, s, MetricNetTotalDown, report.UUID, report.UpdatedAt)
|
||||
if err == nil {
|
||||
values.totalDown = totalDown
|
||||
values.hasDown = hasDown
|
||||
} else if ctx.Err() == nil {
|
||||
totalDown, hasDown, err := latestReportPoint(ctx, s, MetricNetTotalDown, report.UUID, report.UpdatedAt)
|
||||
if err != nil {
|
||||
logger.Errorf("metricstore", "failed to restore previous download counter for %s: %v", report.UUID, err)
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
} else {
|
||||
values.totalDown = int64(totalDown.Value)
|
||||
values.totalDownAt = totalDown.Timestamp
|
||||
values.hasDown = hasDown
|
||||
}
|
||||
values.initialized = true
|
||||
// Keep only the restored baseline on failure. WriteBatch may have
|
||||
// accepted raw samples before a rollup flush fails, so restoring
|
||||
// again on retry could otherwise read this uncommitted batch.
|
||||
state.mu.Lock()
|
||||
state.reportTrafficValues = values
|
||||
state.mu.Unlock()
|
||||
}
|
||||
|
||||
if !values.timestamp.IsZero() && !report.UpdatedAt.After(values.timestamp) {
|
||||
report.UpdatedAt = values.timestamp.Add(time.Millisecond)
|
||||
}
|
||||
trafficUp := int64(0)
|
||||
if values.hasUp {
|
||||
if values.hasUp && !values.totalUpAt.IsZero() && report.UpdatedAt.Sub(values.totalUpAt) <= maxTrafficReportGap {
|
||||
trafficUp = TrafficCounterDelta(report.Network.TotalUp, values.totalUp)
|
||||
}
|
||||
trafficDown := int64(0)
|
||||
if values.hasDown {
|
||||
if values.hasDown && !values.totalDownAt.IsZero() && report.UpdatedAt.Sub(values.totalDownAt) <= maxTrafficReportGap {
|
||||
trafficDown = TrafficCounterDelta(report.Network.TotalDown, values.totalDown)
|
||||
}
|
||||
points = append(points, reportMetricPoints(report, trafficUp, trafficDown)...)
|
||||
values.timestamp = report.UpdatedAt
|
||||
values.hasUp = true
|
||||
values.totalUp = report.Network.TotalUp
|
||||
values.totalUpAt = report.UpdatedAt
|
||||
values.hasDown = true
|
||||
values.totalDown = report.Network.TotalDown
|
||||
values.totalDownAt = report.UpdatedAt
|
||||
pendingStates[state] = values
|
||||
prepared[i] = report
|
||||
}
|
||||
@@ -391,115 +392,14 @@ func writeReportBatch(ctx context.Context, reports []v1.Report) ([]v1.Report, er
|
||||
if err := s.WriteBatch(ctx, points); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Advance counters and timestamps only after success. Retried batches
|
||||
// must replace the same samples, not manufacture resets or new timestamps.
|
||||
for state, values := range pendingStates {
|
||||
state.mu.Lock()
|
||||
state.reportTrafficValues = values
|
||||
state.mu.Unlock()
|
||||
}
|
||||
|
||||
return prepared, nil
|
||||
}
|
||||
|
||||
func reportMetricPoints(report v1.Report, trafficUp, trafficDown int64) []metric.Point {
|
||||
entityID := report.UUID
|
||||
ts := report.UpdatedAt
|
||||
points := []metric.Point{
|
||||
{MetricName: MetricCPU, EntityID: entityID, Timestamp: ts, Value: report.CPU.Usage},
|
||||
{MetricName: MetricRAM, EntityID: entityID, Timestamp: ts, Value: float64(report.Ram.Used)},
|
||||
{MetricName: MetricSwap, EntityID: entityID, Timestamp: ts, Value: float64(report.Swap.Used)},
|
||||
{MetricName: MetricLoad, EntityID: entityID, Timestamp: ts, Value: report.Load.Load1},
|
||||
{MetricName: MetricDisk, EntityID: entityID, Timestamp: ts, Value: float64(report.Disk.Used)},
|
||||
{MetricName: MetricNetIn, EntityID: entityID, Timestamp: ts, Value: float64(report.Network.Down)},
|
||||
{MetricName: MetricNetOut, EntityID: entityID, Timestamp: ts, Value: float64(report.Network.Up)},
|
||||
{MetricName: MetricNetTotalUp, EntityID: entityID, Timestamp: ts, Value: float64(report.Network.TotalUp)},
|
||||
{MetricName: MetricNetTotalDown, EntityID: entityID, Timestamp: ts, Value: float64(report.Network.TotalDown)},
|
||||
{MetricName: MetricTrafficUp, EntityID: entityID, Timestamp: ts, Value: float64(trafficUp)},
|
||||
{MetricName: MetricTrafficDown, EntityID: entityID, Timestamp: ts, Value: float64(trafficDown)},
|
||||
{MetricName: MetricProcess, EntityID: entityID, Timestamp: ts, Value: float64(report.Process)},
|
||||
{MetricName: MetricConnections, EntityID: entityID, Timestamp: ts, Value: float64(report.Connections.TCP)},
|
||||
{MetricName: MetricConnectionsUDP, EntityID: entityID, Timestamp: ts, Value: float64(report.Connections.UDP)},
|
||||
}
|
||||
if report.GPU == nil {
|
||||
return points
|
||||
}
|
||||
points = append(points, metric.Point{MetricName: MetricGPU, EntityID: entityID, Timestamp: ts, Value: report.GPU.AverageUsage})
|
||||
for deviceIndex, gpu := range report.GPU.DetailedInfo {
|
||||
tags := map[string]string{
|
||||
"device_index": strconv.Itoa(deviceIndex),
|
||||
"device_name": gpu.Name,
|
||||
}
|
||||
points = append(points,
|
||||
metric.Point{MetricName: MetricGPUMem, EntityID: entityID, Timestamp: ts, Value: float64(gpu.MemoryUsed), Tags: tags},
|
||||
metric.Point{MetricName: MetricGPUMemTotal, EntityID: entityID, Timestamp: ts, Value: float64(gpu.MemoryTotal), Tags: tags},
|
||||
metric.Point{MetricName: MetricGPUDeviceUsage, EntityID: entityID, Timestamp: ts, Value: gpu.Utilization, Tags: tags},
|
||||
metric.Point{MetricName: MetricGPUTemp, EntityID: entityID, Timestamp: ts, Value: float64(gpu.Temperature), Tags: tags},
|
||||
)
|
||||
}
|
||||
return points
|
||||
}
|
||||
|
||||
func latestReportCounter(ctx context.Context, s *metric.Store, metricName, entityID string, before time.Time) (int64, bool, error) {
|
||||
point, ok, err := s.LatestBefore(ctx, metricName, entityID, before)
|
||||
if err != nil {
|
||||
return 0, false, err
|
||||
}
|
||||
if !ok {
|
||||
return 0, false, nil
|
||||
}
|
||||
return int64(point.Value), true, nil
|
||||
}
|
||||
|
||||
// GetLatestTrafficBefore returns the latest retained upload/download counters
|
||||
// before a boundary, transparently reading raw points or rollup summaries.
|
||||
func GetLatestTrafficBefore(ctx context.Context, entityIDs []string, before time.Time) (map[string]models.Record, error) {
|
||||
s := GetStore()
|
||||
if s == nil {
|
||||
return nil, fmt.Errorf("metric store not enabled")
|
||||
}
|
||||
result := make(map[string]models.Record, len(entityIDs))
|
||||
for _, entityID := range entityIDs {
|
||||
if entityID == "" {
|
||||
continue
|
||||
}
|
||||
up, hasUp, err := latestReportCounter(ctx, s, MetricNetTotalUp, entityID, before)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
down, hasDown, err := latestReportCounter(ctx, s, MetricNetTotalDown, entityID, before)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !hasUp && !hasDown {
|
||||
continue
|
||||
}
|
||||
result[entityID] = models.Record{
|
||||
Client: entityID,
|
||||
Time: before.UTC().Add(-time.Nanosecond),
|
||||
NetTotalUp: up,
|
||||
NetTotalDown: down,
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// TrafficCounterDelta returns a reset-aware increase between two cumulative
|
||||
// traffic counters. After a reset, the current counter is the new increase.
|
||||
func TrafficCounterDelta(current, previous int64) int64 {
|
||||
if current < 0 || previous < 0 {
|
||||
return 0
|
||||
}
|
||||
if current >= previous {
|
||||
return current - previous
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
func deleteReportTrafficState(entityID string) {
|
||||
reportTrafficStates.Delete(entityID)
|
||||
}
|
||||
|
||||
func clearReportTrafficStates() {
|
||||
reportTrafficStates.Range(func(key, _ any) bool {
|
||||
reportTrafficStates.Delete(key)
|
||||
return true
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package metricstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/komari-monitor/komari/pkg/metric"
|
||||
v2 "github.com/komari-monitor/komari/protocol/v2"
|
||||
)
|
||||
|
||||
func reportMetricPoints(report v2.Report, trafficUp, trafficDown int64) []metric.Point {
|
||||
entityID := report.UUID
|
||||
ts := report.UpdatedAt
|
||||
points := []metric.Point{
|
||||
{MetricName: MetricCPU, EntityID: entityID, Timestamp: ts, Value: report.CPU.Usage},
|
||||
{MetricName: MetricRAM, EntityID: entityID, Timestamp: ts, Value: float64(report.Ram.Used)},
|
||||
{MetricName: MetricSwap, EntityID: entityID, Timestamp: ts, Value: float64(report.Swap.Used)},
|
||||
{MetricName: MetricLoad, EntityID: entityID, Timestamp: ts, Value: report.Load.Load1},
|
||||
{MetricName: MetricDisk, EntityID: entityID, Timestamp: ts, Value: float64(report.Disk.Used)},
|
||||
{MetricName: MetricNetIn, EntityID: entityID, Timestamp: ts, Value: float64(report.Network.Down)},
|
||||
{MetricName: MetricNetOut, EntityID: entityID, Timestamp: ts, Value: float64(report.Network.Up)},
|
||||
{MetricName: MetricNetTotalUp, EntityID: entityID, Timestamp: ts, Value: float64(report.Network.TotalUp)},
|
||||
{MetricName: MetricNetTotalDown, EntityID: entityID, Timestamp: ts, Value: float64(report.Network.TotalDown)},
|
||||
{MetricName: MetricTrafficUp, EntityID: entityID, Timestamp: ts, Value: float64(trafficUp)},
|
||||
{MetricName: MetricTrafficDown, EntityID: entityID, Timestamp: ts, Value: float64(trafficDown)},
|
||||
{MetricName: MetricProcess, EntityID: entityID, Timestamp: ts, Value: float64(report.Process)},
|
||||
{MetricName: MetricConnections, EntityID: entityID, Timestamp: ts, Value: float64(report.Connections.TCP)},
|
||||
{MetricName: MetricConnectionsUDP, EntityID: entityID, Timestamp: ts, Value: float64(report.Connections.UDP)},
|
||||
}
|
||||
if report.GPU == nil {
|
||||
return points
|
||||
}
|
||||
points = append(points, metric.Point{MetricName: MetricGPU, EntityID: entityID, Timestamp: ts, Value: report.GPU.AverageUsage})
|
||||
for deviceIndex, gpu := range report.GPU.DetailedInfo {
|
||||
tags := map[string]string{
|
||||
"device_index": strconv.Itoa(deviceIndex),
|
||||
"device_name": gpu.Name,
|
||||
}
|
||||
points = append(points,
|
||||
metric.Point{MetricName: MetricGPUMem, EntityID: entityID, Timestamp: ts, Value: float64(gpu.MemoryUsed), Tags: tags},
|
||||
metric.Point{MetricName: MetricGPUMemTotal, EntityID: entityID, Timestamp: ts, Value: float64(gpu.MemoryTotal), Tags: tags},
|
||||
metric.Point{MetricName: MetricGPUDeviceUsage, EntityID: entityID, Timestamp: ts, Value: gpu.Utilization, Tags: tags},
|
||||
metric.Point{MetricName: MetricGPUTemp, EntityID: entityID, Timestamp: ts, Value: float64(gpu.Temperature), Tags: tags},
|
||||
)
|
||||
}
|
||||
return points
|
||||
}
|
||||
|
||||
// latestReportPoint preserves independent counter restoration errors: one failed
|
||||
// direction must not prevent the other from restoring its baseline.
|
||||
func latestReportPoint(ctx context.Context, s *metric.Store, metricName, entityID string, before time.Time) (metric.Point, bool, error) {
|
||||
loaded, err := QueryMetrics(ctx, MetricsQuery{
|
||||
Store: s, Mode: QueryLatestBefore, Source: SourceAuto,
|
||||
Entities: EntityScope{IDs: []string{entityID}},
|
||||
Specs: []MetricSpec{{Name: metricName}}, End: before, AllowMissing: true,
|
||||
})
|
||||
if err != nil {
|
||||
return metric.Point{}, false, err
|
||||
}
|
||||
point, ok := loaded.Latest[metricName][entityID]
|
||||
return point, ok, nil
|
||||
}
|
||||
|
||||
func latestReportCounter(ctx context.Context, s *metric.Store, metricName, entityID string, before time.Time) (int64, bool, error) {
|
||||
point, ok, err := latestReportPoint(ctx, s, metricName, entityID, before)
|
||||
if err != nil {
|
||||
return 0, false, err
|
||||
}
|
||||
if !ok {
|
||||
return 0, false, nil
|
||||
}
|
||||
return int64(point.Value), true, nil
|
||||
}
|
||||
|
||||
// GetLatestTrafficBefore returns the latest retained upload/download counters
|
||||
// before a boundary, transparently reading raw points or rollup summaries.
|
||||
func GetLatestTrafficBefore(ctx context.Context, entityIDs []string, before time.Time) (map[string]models.Record, error) {
|
||||
s := GetStore()
|
||||
if s == nil {
|
||||
return nil, fmt.Errorf("metric store not enabled")
|
||||
}
|
||||
result := make(map[string]models.Record, len(entityIDs))
|
||||
for _, entityID := range entityIDs {
|
||||
if entityID == "" {
|
||||
continue
|
||||
}
|
||||
up, hasUp, err := latestReportCounter(ctx, s, MetricNetTotalUp, entityID, before)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
down, hasDown, err := latestReportCounter(ctx, s, MetricNetTotalDown, entityID, before)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !hasUp && !hasDown {
|
||||
continue
|
||||
}
|
||||
result[entityID] = models.Record{
|
||||
Client: entityID,
|
||||
Time: before.UTC().Add(-time.Nanosecond),
|
||||
NetTotalUp: up,
|
||||
NetTotalDown: down,
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// maxResetAwareDelta is the largest per-sample increase accepted when a
|
||||
// counter appears to reset. A "reset" whose current value is larger than this
|
||||
// is treated as jitter of a still-huge counter, not a wrap or reboot leftover.
|
||||
const maxResetAwareDelta int64 = 64 << 30
|
||||
|
||||
// TrafficCounterDelta returns a reset-aware increase between two cumulative
|
||||
// traffic counters. After a wrap or reboot, the current counter is the new
|
||||
// increase. A tiny dip of a TB-scale counter is not a reset: counting the
|
||||
// still-huge current value would inject a multi-terabyte spike.
|
||||
func TrafficCounterDelta(current, previous int64) int64 {
|
||||
if current < 0 || previous < 0 {
|
||||
return 0
|
||||
}
|
||||
if current >= previous {
|
||||
return current - previous
|
||||
}
|
||||
if current > maxResetAwareDelta {
|
||||
return 0
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
func deleteReportTrafficState(entityID string) {
|
||||
reportTrafficStates.Delete(entityID)
|
||||
}
|
||||
|
||||
func clearReportTrafficStates() {
|
||||
reportTrafficStates.Range(func(key, _ any) bool {
|
||||
reportTrafficStates.Delete(key)
|
||||
return true
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,840 @@
|
||||
package metricstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"database/sql/driver"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/komari-monitor/komari/pkg/metric"
|
||||
v2 "github.com/komari-monitor/komari/protocol/v2"
|
||||
sqlite3 "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
func useReportTestStore(t *testing.T, policy *metric.RollupPolicy) *metric.Store {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
opts := []metric.Option{metric.WithMaxOpenConns(1)}
|
||||
if policy != nil {
|
||||
opts = append(opts, metric.WithRollupPolicy(*policy))
|
||||
}
|
||||
dsn := fmt.Sprintf("file:report-%d?mode=memory&cache=shared", time.Now().UnixNano())
|
||||
s, err := metric.Open(ctx, metric.SQLite(dsn, opts...))
|
||||
if err != nil {
|
||||
t.Fatalf("open metric store: %v", err)
|
||||
}
|
||||
if err := createMetricDefinitions(ctx, s); err != nil {
|
||||
_ = s.Close()
|
||||
t.Fatalf("create metric definitions: %v", err)
|
||||
}
|
||||
|
||||
storeMu.Lock()
|
||||
previous := store
|
||||
store = s
|
||||
storeMu.Unlock()
|
||||
clearReportTrafficStates()
|
||||
t.Cleanup(func() {
|
||||
clearReportTrafficStates()
|
||||
storeMu.Lock()
|
||||
store = previous
|
||||
storeMu.Unlock()
|
||||
_ = s.Close()
|
||||
})
|
||||
return s
|
||||
}
|
||||
|
||||
type reportCounterFault struct {
|
||||
remaining atomic.Int32
|
||||
denied atomic.Int32
|
||||
failWrite atomic.Bool
|
||||
}
|
||||
|
||||
func (f *reportCounterFault) denyRollupRead() bool {
|
||||
for {
|
||||
remaining := f.remaining.Load()
|
||||
if remaining <= 0 {
|
||||
return false
|
||||
}
|
||||
if f.remaining.CompareAndSwap(remaining, remaining-1) {
|
||||
f.denied.Add(1)
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type reportSQLiteConnector struct {
|
||||
driver *sqlite3.SQLiteDriver
|
||||
dsn string
|
||||
}
|
||||
|
||||
func (c *reportSQLiteConnector) Connect(context.Context) (driver.Conn, error) {
|
||||
return c.driver.Open(c.dsn)
|
||||
}
|
||||
|
||||
func (c *reportSQLiteConnector) Driver() driver.Driver {
|
||||
return c.driver
|
||||
}
|
||||
|
||||
// useReportCounterFailureStore denies exactly two rollup reads after schema
|
||||
// setup, exercising failed counter restoration while leaving writes usable.
|
||||
func useReportCounterFailureStore(t *testing.T) (*metric.Store, *reportCounterFault) {
|
||||
t.Helper()
|
||||
fault := &reportCounterFault{}
|
||||
dsn := fmt.Sprintf("file:report-counter-fault-%d?mode=memory&cache=shared", time.Now().UnixNano())
|
||||
driver := &sqlite3.SQLiteDriver{
|
||||
ConnectHook: func(conn *sqlite3.SQLiteConn) error {
|
||||
conn.RegisterAuthorizer(func(op int, arg1, _, _ string) int {
|
||||
if op == sqlite3.SQLITE_TRANSACTION && arg1 == "BEGIN" && fault.failWrite.Load() {
|
||||
return sqlite3.SQLITE_DENY
|
||||
}
|
||||
if op == sqlite3.SQLITE_READ && arg1 == "metric_rollups" && fault.denyRollupRead() {
|
||||
return sqlite3.SQLITE_DENY
|
||||
}
|
||||
return sqlite3.SQLITE_OK
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
db := sql.OpenDB(&reportSQLiteConnector{driver: driver, dsn: dsn})
|
||||
s, err := metric.Open(context.Background(), metric.SQLite("", metric.WithDB(db), metric.WithMaxOpenConns(1)))
|
||||
if err != nil {
|
||||
_ = db.Close()
|
||||
t.Fatalf("open metric store: %v", err)
|
||||
}
|
||||
if err := createMetricDefinitions(context.Background(), s); err != nil {
|
||||
_ = s.Close()
|
||||
_ = db.Close()
|
||||
t.Fatalf("create metric definitions: %v", err)
|
||||
}
|
||||
fault.remaining.Store(2)
|
||||
storeMu.Lock()
|
||||
previous := store
|
||||
store = s
|
||||
storeMu.Unlock()
|
||||
t.Cleanup(func() {
|
||||
clearReportTrafficStates()
|
||||
storeMu.Lock()
|
||||
store = previous
|
||||
storeMu.Unlock()
|
||||
_ = s.Close()
|
||||
_ = db.Close()
|
||||
})
|
||||
return s, fault
|
||||
}
|
||||
|
||||
func TestReportBatchCounterRestoreFailureInitializesStateOnce(t *testing.T) {
|
||||
s, fault := useReportCounterFailureStore(t)
|
||||
ctx := context.Background()
|
||||
base := time.Now().UTC().Truncate(time.Second)
|
||||
first := v2.Report{
|
||||
UUID: "counter-restore-failure",
|
||||
UpdatedAt: base,
|
||||
CPU: v2.CPUReport{Usage: 10},
|
||||
Network: v2.NetworkReport{TotalUp: 100, TotalDown: 200},
|
||||
}
|
||||
|
||||
if _, err := writeReportBatch(ctx, []v2.Report{first}); err != nil {
|
||||
t.Fatalf("write first report after counter restore failure: %v", err)
|
||||
}
|
||||
if fault.denied.Load() != 2 {
|
||||
t.Fatalf("counter restore queries = %d, want 2", fault.denied.Load())
|
||||
}
|
||||
stateValue, ok := reportTrafficStates.Load(first.UUID)
|
||||
if !ok {
|
||||
t.Fatal("report traffic state was not persisted")
|
||||
}
|
||||
state := stateValue.(*reportTrafficState)
|
||||
state.mu.Lock()
|
||||
initialized := state.initialized
|
||||
state.mu.Unlock()
|
||||
if !initialized {
|
||||
t.Fatal("report traffic state was not initialized after restore failure")
|
||||
}
|
||||
|
||||
second := first
|
||||
second.UpdatedAt = base.Add(time.Second)
|
||||
second.Network.TotalUp = 150
|
||||
second.Network.TotalDown = 260
|
||||
if _, err := writeReportBatch(ctx, []v2.Report{second}); err != nil {
|
||||
t.Fatalf("write second report: %v", err)
|
||||
}
|
||||
if fault.denied.Load() != 2 {
|
||||
t.Fatalf("counter restore queries after second batch = %d, want no repeat", fault.denied.Load())
|
||||
}
|
||||
assertMetricValues(t, s, MetricTrafficUp, first.UUID, base.Add(-time.Second), base.Add(2*time.Second), []float64{0, 50})
|
||||
assertMetricValues(t, s, MetricTrafficDown, first.UUID, base.Add(-time.Second), base.Add(2*time.Second), []float64{0, 60})
|
||||
}
|
||||
|
||||
func TestReportBatchRetryDoesNotCountHistoricalTraffic(t *testing.T) {
|
||||
for _, existingBaseline := range []bool{false, true} {
|
||||
t.Run(fmt.Sprintf("existing-baseline-%v", existingBaseline), func(t *testing.T) {
|
||||
s, fault := useReportCounterFailureStore(t)
|
||||
fault.remaining.Store(0)
|
||||
ctx := context.Background()
|
||||
base := time.Now().UTC().Truncate(time.Minute).Add(-2 * time.Minute)
|
||||
report := v2.Report{
|
||||
UUID: "retry-node", UpdatedAt: base,
|
||||
Network: v2.NetworkReport{TotalUp: 12 << 30, TotalDown: 30 << 30},
|
||||
}
|
||||
if existingBaseline {
|
||||
if _, err := writeReportBatch(ctx, []v2.Report{report}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
first := report
|
||||
first.UpdatedAt = base.Add(3 * time.Second)
|
||||
first.Network.TotalUp += 100
|
||||
first.Network.TotalDown += 200
|
||||
second := first
|
||||
second.UpdatedAt = base.Add(6 * time.Second)
|
||||
second.Network.TotalUp += 50
|
||||
second.Network.TotalDown += 60
|
||||
pending := []v2.Report{first, second}
|
||||
|
||||
fault.failWrite.Store(true)
|
||||
for attempt := 0; attempt < 2; attempt++ {
|
||||
if err := writePendingReports(ctx, &pending); err == nil {
|
||||
t.Fatal("expected injected rollup write failure")
|
||||
}
|
||||
if len(pending) != 2 {
|
||||
t.Fatal("failed reports were removed from pending batch")
|
||||
}
|
||||
value, _ := reportTrafficStates.Load(report.UUID)
|
||||
state := value.(*reportTrafficState)
|
||||
state.mu.Lock()
|
||||
baseline := state.reportTrafficValues
|
||||
state.mu.Unlock()
|
||||
if baseline.hasUp != existingBaseline || baseline.hasDown != existingBaseline ||
|
||||
(existingBaseline && (baseline.totalUp != report.Network.TotalUp ||
|
||||
baseline.totalDown != report.Network.TotalDown || !baseline.timestamp.Equal(base))) {
|
||||
t.Fatal("failed write advanced the committed counter baseline")
|
||||
}
|
||||
}
|
||||
fault.failWrite.Store(false)
|
||||
if err := writePendingReports(ctx, &pending); err != nil {
|
||||
t.Fatalf("retry failed: %v", err)
|
||||
}
|
||||
if len(pending) != 0 {
|
||||
t.Fatal("successful reports remained pending")
|
||||
}
|
||||
|
||||
wantUp, wantDown := []float64{0, 50}, []float64{0, 60}
|
||||
if existingBaseline {
|
||||
wantUp, wantDown = []float64{0, 100, 50}, []float64{0, 200, 60}
|
||||
}
|
||||
assertMetricValues(t, s, MetricTrafficUp, report.UUID, base.Add(-time.Second), base.Add(time.Minute), wantUp)
|
||||
assertMetricValues(t, s, MetricTrafficDown, report.UUID, base.Add(-time.Second), base.Add(time.Minute), wantDown)
|
||||
var sumUp, sumDown float64
|
||||
for i := range wantUp {
|
||||
sumUp += wantUp[i]
|
||||
sumDown += wantDown[i]
|
||||
}
|
||||
assertMetricAggregate(t, s, MetricTrafficUp, report.UUID, base.Add(-time.Second), base.Add(time.Minute), metric.AggSum, sumUp, len(wantUp))
|
||||
assertMetricAggregate(t, s, MetricTrafficDown, report.UUID, base.Add(-time.Second), base.Add(time.Minute), metric.AggSum, sumDown, len(wantDown))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteReportStoresMinuteMetricsAndResetAwareTraffic(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
policy := defaultRollupPolicy()
|
||||
s := useReportTestStore(t, &policy)
|
||||
base := time.Now().UTC().Truncate(time.Minute).Add(5 * time.Second)
|
||||
now := base.Add(45 * time.Second)
|
||||
|
||||
report := v2.Report{
|
||||
UUID: "node-a",
|
||||
UpdatedAt: base,
|
||||
CPU: v2.CPUReport{Usage: 12.5},
|
||||
Ram: v2.RamReport{Used: 100, Total: 1000},
|
||||
Swap: v2.RamReport{Used: 20, Total: 200},
|
||||
Load: v2.LoadReport{Load1: 0.5},
|
||||
Disk: v2.DiskReport{Used: 300, Total: 3000},
|
||||
Network: v2.NetworkReport{Up: 3, Down: 4, TotalUp: 100, TotalDown: 200},
|
||||
Process: 7,
|
||||
Connections: v2.ConnectionsReport{TCP: 8, UDP: 9},
|
||||
GPU: &v2.GPUDetailReport{
|
||||
AverageUsage: 25,
|
||||
DetailedInfo: []v2.GPUDeviceInfo{{
|
||||
Name: "GPU 0", MemoryUsed: 400, MemoryTotal: 800, Utilization: 30, Temperature: 55,
|
||||
}},
|
||||
},
|
||||
}
|
||||
if _, err := WriteReport(ctx, report); err != nil {
|
||||
t.Fatalf("write first report: %v", err)
|
||||
}
|
||||
|
||||
report.UpdatedAt = base.Add(3 * time.Second)
|
||||
report.Network.TotalUp = 150
|
||||
report.Network.TotalDown = 260
|
||||
if _, err := WriteReport(ctx, report); err != nil {
|
||||
t.Fatalf("write second report: %v", err)
|
||||
}
|
||||
|
||||
report.UpdatedAt = base.Add(6 * time.Second)
|
||||
report.Network.TotalUp = 20
|
||||
report.Network.TotalDown = 30
|
||||
if _, err := WriteReport(ctx, report); err != nil {
|
||||
t.Fatalf("write reset report: %v", err)
|
||||
}
|
||||
|
||||
assertMetricValues(t, s, MetricTrafficUp, report.UUID, base.Add(-time.Second), base.Add(time.Minute), []float64{0, 50, 20})
|
||||
assertMetricValues(t, s, MetricTrafficDown, report.UUID, base.Add(-time.Second), base.Add(time.Minute), []float64{0, 60, 30})
|
||||
assertMetricValues(t, s, MetricNetTotalUp, report.UUID, base.Add(-time.Second), base.Add(time.Minute), []float64{100, 150, 20})
|
||||
assertMetricAggregate(t, s, MetricTrafficUp, report.UUID, base.Add(-time.Second), base.Add(time.Minute), metric.AggSum, 70, 3)
|
||||
assertMetricAggregate(t, s, MetricTrafficDown, report.UUID, base.Add(-time.Second), base.Add(time.Minute), metric.AggSum, 90, 3)
|
||||
|
||||
gpuPoints, err := s.Query(ctx, metric.Query{
|
||||
MetricName: MetricGPUDeviceUsage,
|
||||
EntityID: report.UUID,
|
||||
Start: base.Add(-time.Second),
|
||||
End: base.Add(time.Minute),
|
||||
Tags: map[string]string{"device_index": "0"},
|
||||
Order: metric.OrderAsc,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("query GPU points: %v", err)
|
||||
}
|
||||
if len(gpuPoints) != 3 || !gpuPoints[2].Timestamp.Equal(base.Add(6*time.Second)) || gpuPoints[2].Tags["device_name"] != "GPU 0" {
|
||||
t.Fatalf("unexpected GPU points: %#v", gpuPoints)
|
||||
}
|
||||
|
||||
if _, err := s.Compact(ctx, now); err != nil {
|
||||
t.Fatalf("compact reports: %v", err)
|
||||
}
|
||||
deleteReportTrafficState(report.UUID)
|
||||
report.UpdatedAt = now
|
||||
report.Network.TotalUp = 35
|
||||
report.Network.TotalDown = 50
|
||||
if _, err := WriteReport(ctx, report); err != nil {
|
||||
t.Fatalf("write after restoring rollup baseline: %v", err)
|
||||
}
|
||||
assertMetricValues(t, s, MetricTrafficUp, report.UUID, now.Add(-time.Second), now.Add(time.Second), []float64{15})
|
||||
assertMetricValues(t, s, MetricTrafficDown, report.UUID, now.Add(-time.Second), now.Add(time.Second), []float64{20})
|
||||
}
|
||||
|
||||
func TestWriteReportSkipsMetricsWithoutAgentData(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := useReportTestStore(t, nil)
|
||||
timestamp := time.Now().UTC()
|
||||
if _, err := WriteReport(ctx, v2.Report{
|
||||
UUID: "node-without-gpu", UpdatedAt: timestamp,
|
||||
}); err != nil {
|
||||
t.Fatalf("write report: %v", err)
|
||||
}
|
||||
points, err := s.Query(ctx, metric.Query{
|
||||
MetricName: MetricGPU, EntityID: "node-without-gpu",
|
||||
Start: timestamp.Add(-time.Second), End: timestamp.Add(time.Second),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("query GPU metric: %v", err)
|
||||
}
|
||||
if len(points) != 0 {
|
||||
t.Fatalf("GPU metric was written without GPU data: %#v", points)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReportBatcherFlushesQueuedReports(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := useReportTestStore(t, nil)
|
||||
StartReportBatcher()
|
||||
t.Cleanup(func() {
|
||||
if err := StopReportBatcher(ctx); err != nil {
|
||||
t.Errorf("stop report batcher: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
base := time.Now().UTC().Truncate(time.Minute).Add(10 * time.Second)
|
||||
first := v2.Report{
|
||||
UUID: "batched-node",
|
||||
UpdatedAt: base,
|
||||
CPU: v2.CPUReport{Usage: 10},
|
||||
Network: v2.NetworkReport{TotalUp: 100, TotalDown: 200},
|
||||
}
|
||||
second := first
|
||||
second.UpdatedAt = base.Add(3 * time.Second)
|
||||
second.CPU.Usage = 20
|
||||
second.Network.TotalUp = 150
|
||||
second.Network.TotalDown = 260
|
||||
|
||||
if _, err := WriteReport(ctx, first); err != nil {
|
||||
t.Fatalf("queue first report: %v", err)
|
||||
}
|
||||
if _, err := WriteReport(ctx, second); err != nil {
|
||||
t.Fatalf("queue second report: %v", err)
|
||||
}
|
||||
points, err := s.Query(ctx, metric.Query{
|
||||
MetricName: MetricCPU,
|
||||
EntityID: first.UUID,
|
||||
Start: base.Add(-time.Second),
|
||||
End: base.Add(time.Minute),
|
||||
Order: metric.OrderAsc,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("query before flush: %v", err)
|
||||
}
|
||||
if len(points) != 0 {
|
||||
t.Fatalf("queued reports were written before flush: %#v", points)
|
||||
}
|
||||
|
||||
if err := FlushReportBatch(ctx); err != nil {
|
||||
t.Fatalf("flush report batch: %v", err)
|
||||
}
|
||||
assertMetricValues(t, s, MetricCPU, first.UUID, base.Add(-time.Second), base.Add(time.Minute), []float64{10, 20})
|
||||
assertMetricValues(t, s, MetricTrafficUp, first.UUID, base.Add(-time.Second), base.Add(time.Minute), []float64{0, 50})
|
||||
assertMetricValues(t, s, MetricTrafficDown, first.UUID, base.Add(-time.Second), base.Add(time.Minute), []float64{0, 60})
|
||||
assertMetricAggregate(t, s, MetricCPU, first.UUID, base.Add(-time.Second), base.Add(time.Minute), metric.AggAvg, 15, 2)
|
||||
}
|
||||
|
||||
func TestPingBatcherFlushesLatencyAndLossTogether(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := useReportTestStore(t, nil)
|
||||
StartReportBatcher()
|
||||
t.Cleanup(func() {
|
||||
if err := StopReportBatcher(ctx); err != nil {
|
||||
t.Errorf("stop report batcher: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
base := time.Now().UTC().Truncate(time.Second)
|
||||
records := []models.PingRecord{
|
||||
{Client: "ping-node", TaskId: 7, Time: base, Value: 24},
|
||||
{Client: "ping-node", TaskId: 7, Time: base.Add(time.Minute), Value: -1},
|
||||
{Client: "ping-node", TaskId: 8, Time: base, Value: 31},
|
||||
}
|
||||
for _, record := range records {
|
||||
if err := WritePingRecord(ctx, record); err != nil {
|
||||
t.Fatalf("queue ping record: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
for _, name := range []string{MetricPingLatency, MetricPingLoss} {
|
||||
points, err := s.Query(ctx, metric.Query{
|
||||
MetricName: name,
|
||||
EntityID: "ping-node",
|
||||
Start: base.Add(-time.Second),
|
||||
End: base.Add(2 * time.Minute),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("query queued %s points: %v", name, err)
|
||||
}
|
||||
if len(points) != 0 {
|
||||
t.Fatalf("queued %s points were written before flush: %#v", name, points)
|
||||
}
|
||||
}
|
||||
|
||||
if err := FlushReportBatch(ctx); err != nil {
|
||||
t.Fatalf("flush ping batch: %v", err)
|
||||
}
|
||||
|
||||
latency, err := s.Query(ctx, metric.Query{
|
||||
MetricName: MetricPingLatency,
|
||||
EntityID: "ping-node",
|
||||
Tags: map[string]string{"task_id": "7"},
|
||||
Start: base.Add(-time.Second),
|
||||
End: base.Add(2 * time.Minute),
|
||||
Order: metric.OrderAsc,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("query flushed latency points: %v", err)
|
||||
}
|
||||
if len(latency) != 2 || latency[0].Value != 24 || latency[1].Value != -1 {
|
||||
t.Fatalf("latency points = %#v, want both original samples", latency)
|
||||
}
|
||||
|
||||
loss, err := s.Query(ctx, metric.Query{
|
||||
MetricName: MetricPingLoss,
|
||||
EntityID: "ping-node",
|
||||
Tags: map[string]string{"task_id": "7"},
|
||||
Start: base.Add(-time.Second),
|
||||
End: base.Add(2 * time.Minute),
|
||||
Order: metric.OrderAsc,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("query flushed loss points: %v", err)
|
||||
}
|
||||
if len(loss) != 2 || loss[0].Value != 0 || loss[1].Value != 1 {
|
||||
t.Fatalf("loss points = %#v, want success and loss samples", loss)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReportBatchKeepsEverySample(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := useReportTestStore(t, nil)
|
||||
base := time.Now().UTC().Truncate(time.Second)
|
||||
pending := []v2.Report{
|
||||
{UUID: "node-a", UpdatedAt: base, CPU: v2.CPUReport{Usage: 10}, Network: v2.NetworkReport{TotalUp: 100}},
|
||||
{UUID: "node-a", UpdatedAt: base, CPU: v2.CPUReport{Usage: 20}, Network: v2.NetworkReport{TotalUp: 150}},
|
||||
{UUID: "node-b", UpdatedAt: base, CPU: v2.CPUReport{Usage: 30}, Network: v2.NetworkReport{TotalUp: 200}},
|
||||
{UUID: "node-b", UpdatedAt: base.Add(time.Second), CPU: v2.CPUReport{Usage: 40}, Network: v2.NetworkReport{TotalUp: 260}},
|
||||
}
|
||||
|
||||
if err := writePendingReports(ctx, &pending); err != nil {
|
||||
t.Fatalf("write report batch: %v", err)
|
||||
}
|
||||
if len(pending) != 0 {
|
||||
t.Fatalf("pending reports = %d, want 0", len(pending))
|
||||
}
|
||||
assertMetricValues(t, s, MetricCPU, "node-a", base.Add(-time.Second), base.Add(time.Minute), []float64{10, 20})
|
||||
assertMetricValues(t, s, MetricCPU, "node-b", base.Add(-time.Second), base.Add(time.Minute), []float64{30, 40})
|
||||
}
|
||||
|
||||
func TestReportQueueFullReturnsError(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
useReportTestStore(t, nil)
|
||||
worker := &reportBatchWorker{
|
||||
queue: make(chan v2.Report, 1),
|
||||
requests: make(chan reportBatchRequest, 1),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
worker.queue <- v2.Report{UUID: "already-queued"}
|
||||
reportBatcherMu.Lock()
|
||||
reportBatcher = worker
|
||||
reportBatcherMu.Unlock()
|
||||
t.Cleanup(func() {
|
||||
reportBatcherMu.Lock()
|
||||
if reportBatcher == worker {
|
||||
reportBatcher = nil
|
||||
}
|
||||
reportBatcherMu.Unlock()
|
||||
})
|
||||
|
||||
report := v2.Report{
|
||||
UUID: "realtime-node",
|
||||
UpdatedAt: time.Now().UTC(),
|
||||
}
|
||||
_, err := WriteReport(ctx, report)
|
||||
if !errors.Is(err, ErrReportBatchQueueFull) {
|
||||
t.Fatalf("queue-full error = %v, want %v", err, ErrReportBatchQueueFull)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordReconstructionUsesMetricSpecificAggregation(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := useReportTestStore(t, nil)
|
||||
base := time.Now().UTC().Truncate(time.Minute)
|
||||
entityID := "node-aggregation"
|
||||
points := []metric.Point{
|
||||
{MetricName: MetricCPU, EntityID: entityID, Timestamp: base.Add(time.Second), Value: 10},
|
||||
{MetricName: MetricCPU, EntityID: entityID, Timestamp: base.Add(2 * time.Second), Value: 30},
|
||||
{MetricName: MetricNetTotalUp, EntityID: entityID, Timestamp: base.Add(time.Second), Value: 100},
|
||||
{MetricName: MetricNetTotalUp, EntityID: entityID, Timestamp: base.Add(2 * time.Second), Value: 200},
|
||||
{MetricName: MetricTrafficUp, EntityID: entityID, Timestamp: base.Add(time.Second), Value: 10},
|
||||
{MetricName: MetricTrafficUp, EntityID: entityID, Timestamp: base.Add(2 * time.Second), Value: 20},
|
||||
}
|
||||
if err := s.WriteBatch(ctx, points); err != nil {
|
||||
t.Fatalf("write points: %v", err)
|
||||
}
|
||||
|
||||
records, err := GetRecordsByClientAndTime(ctx, entityID, base, base.Add(time.Hour))
|
||||
if err != nil {
|
||||
t.Fatalf("reconstruct records: %v", err)
|
||||
}
|
||||
if len(records) != 1 {
|
||||
t.Fatalf("records = %#v, want one bucket", records)
|
||||
}
|
||||
if records[0].Cpu != 20 || records[0].NetTotalUp != 200 || records[0].TrafficUp != 30 {
|
||||
t.Fatalf("unexpected aggregation result: %#v", records[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrafficCounterDelta(t *testing.T) {
|
||||
const (
|
||||
oneGB = int64(1_000_000_000)
|
||||
oneTB = int64(1_000_000_000_000)
|
||||
)
|
||||
tests := []struct {
|
||||
name string
|
||||
current int64
|
||||
previous int64
|
||||
want int64
|
||||
}{
|
||||
{name: "previous zero", current: 120, previous: 0, want: 120},
|
||||
{name: "monotonic counter", current: 250, previous: 200, want: 50},
|
||||
{name: "unchanged counter", current: 100, previous: 100, want: 0},
|
||||
{name: "counter reset", current: 15, previous: 250, want: 15},
|
||||
{name: "negative current", current: -1, previous: 100, want: 0},
|
||||
{name: "negative previous", current: 15, previous: -1, want: 0},
|
||||
{name: "tb-scale monotonic", current: 2*oneTB + 5*oneGB, previous: 2 * oneTB, want: 5 * oneGB},
|
||||
{name: "32-bit wrap leftover", current: 800_000_000, previous: 3*oneGB + 900_000_000, want: 800_000_000},
|
||||
{name: "reboot leftover below cap", current: 8 * oneGB, previous: 2 * oneTB, want: 8 * oneGB},
|
||||
{name: "tb-scale tiny dip", current: 2*oneTB - 100, previous: 2 * oneTB, want: 0},
|
||||
{name: "tb-scale false reset", current: 2*oneTB + 5*oneGB - 1000, previous: 2*oneTB + 5*oneGB, want: 0},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if got := TrafficCounterDelta(test.current, test.previous); got != test.want {
|
||||
t.Fatalf("TrafficCounterDelta(%d, %d) = %d, want %d", test.current, test.previous, got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteReportRebasesTrafficAfterAgentRestart(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := useReportTestStore(t, nil)
|
||||
base := time.Now().UTC().Truncate(time.Minute).Add(5 * time.Second)
|
||||
report := v2.Report{
|
||||
UUID: "restarted-node",
|
||||
UpdatedAt: base,
|
||||
Uptime: 1000,
|
||||
Network: v2.NetworkReport{TotalUp: 100, TotalDown: 200},
|
||||
}
|
||||
if _, err := WriteReport(ctx, report); err != nil {
|
||||
t.Fatalf("write first report: %v", err)
|
||||
}
|
||||
|
||||
report.UpdatedAt = base.Add(3 * time.Second)
|
||||
report.Uptime = 1003
|
||||
report.Network.TotalUp = 150
|
||||
report.Network.TotalDown = 260
|
||||
if _, err := WriteReport(ctx, report); err != nil {
|
||||
t.Fatalf("write continuous report: %v", err)
|
||||
}
|
||||
|
||||
report.UpdatedAt = base.Add(6 * time.Second)
|
||||
report.Uptime = 1
|
||||
report.Network.TotalUp = 155
|
||||
report.Network.TotalDown = 265
|
||||
if _, err := WriteReport(ctx, report); err != nil {
|
||||
t.Fatalf("write report after agent restart: %v", err)
|
||||
}
|
||||
|
||||
report.UpdatedAt = base.Add(9 * time.Second)
|
||||
report.Uptime = 4
|
||||
report.Network.TotalUp = 180
|
||||
report.Network.TotalDown = 300
|
||||
if _, err := WriteReport(ctx, report); err != nil {
|
||||
t.Fatalf("write report after new baseline: %v", err)
|
||||
}
|
||||
|
||||
assertMetricValues(t, s, MetricTrafficUp, report.UUID, base.Add(-time.Second), base.Add(time.Minute), []float64{0, 50, 5, 25})
|
||||
assertMetricValues(t, s, MetricTrafficDown, report.UUID, base.Add(-time.Second), base.Add(time.Minute), []float64{0, 60, 5, 35})
|
||||
}
|
||||
|
||||
func TestWriteReportCountsTBScaleTrafficDeltas(t *testing.T) {
|
||||
const (
|
||||
oneGB = int64(1_000_000_000)
|
||||
oneTB = int64(1_000_000_000_000)
|
||||
)
|
||||
ctx := context.Background()
|
||||
s := useReportTestStore(t, nil)
|
||||
base := time.Now().UTC().Truncate(time.Minute).Add(5 * time.Second)
|
||||
report := v2.Report{
|
||||
UUID: "tb-node",
|
||||
UpdatedAt: base,
|
||||
Uptime: 10_000,
|
||||
Network: v2.NetworkReport{TotalUp: 2 * oneTB, TotalDown: 3 * oneTB},
|
||||
}
|
||||
if _, err := WriteReport(ctx, report); err != nil {
|
||||
t.Fatalf("write first report: %v", err)
|
||||
}
|
||||
|
||||
report.UpdatedAt = base.Add(3 * time.Second)
|
||||
report.Uptime = 10_003
|
||||
report.Network.TotalUp = 2*oneTB + 5*oneGB
|
||||
report.Network.TotalDown = 3*oneTB + 7*oneGB
|
||||
if _, err := WriteReport(ctx, report); err != nil {
|
||||
t.Fatalf("write growing report: %v", err)
|
||||
}
|
||||
|
||||
assertMetricValues(t, s, MetricTrafficUp, report.UUID, base.Add(-time.Second), base.Add(time.Minute), []float64{0, float64(5 * oneGB)})
|
||||
assertMetricValues(t, s, MetricTrafficDown, report.UUID, base.Add(-time.Second), base.Add(time.Minute), []float64{0, float64(7 * oneGB)})
|
||||
}
|
||||
|
||||
func TestWriteReportRestoresTBScaleCountersFromStore(t *testing.T) {
|
||||
const (
|
||||
oneGB = int64(1_000_000_000)
|
||||
oneTB = int64(1_000_000_000_000)
|
||||
)
|
||||
ctx := context.Background()
|
||||
policy := defaultRollupPolicy()
|
||||
s := useReportTestStore(t, &policy)
|
||||
base := time.Now().UTC().Truncate(time.Minute).Add(5 * time.Second)
|
||||
now := base.Add(45 * time.Second)
|
||||
report := v2.Report{
|
||||
UUID: "tb-restore-node",
|
||||
UpdatedAt: base,
|
||||
Uptime: 10_000,
|
||||
Network: v2.NetworkReport{TotalUp: 2*oneTB + 123, TotalDown: 4*oneTB + 456},
|
||||
}
|
||||
if _, err := WriteReport(ctx, report); err != nil {
|
||||
t.Fatalf("write first report: %v", err)
|
||||
}
|
||||
|
||||
restoredUp, hasUp, err := latestReportCounter(ctx, s, MetricNetTotalUp, report.UUID, now)
|
||||
if err != nil {
|
||||
t.Fatalf("restore upload counter: %v", err)
|
||||
}
|
||||
if !hasUp || restoredUp != report.Network.TotalUp {
|
||||
t.Fatalf("restored upload counter = %d (has=%v), want %d", restoredUp, hasUp, report.Network.TotalUp)
|
||||
}
|
||||
restoredDown, hasDown, err := latestReportCounter(ctx, s, MetricNetTotalDown, report.UUID, now)
|
||||
if err != nil {
|
||||
t.Fatalf("restore download counter: %v", err)
|
||||
}
|
||||
if !hasDown || restoredDown != report.Network.TotalDown {
|
||||
t.Fatalf("restored download counter = %d (has=%v), want %d", restoredDown, hasDown, report.Network.TotalDown)
|
||||
}
|
||||
|
||||
if _, err := s.Compact(ctx, now); err != nil {
|
||||
t.Fatalf("compact reports: %v", err)
|
||||
}
|
||||
deleteReportTrafficState(report.UUID)
|
||||
report.UpdatedAt = now
|
||||
report.Uptime = 10_045
|
||||
report.Network.TotalUp = 2*oneTB + 123 + 5*oneGB
|
||||
report.Network.TotalDown = 4*oneTB + 456 + 7*oneGB
|
||||
if _, err := WriteReport(ctx, report); err != nil {
|
||||
t.Fatalf("write after restoring tb-scale baseline: %v", err)
|
||||
}
|
||||
assertMetricValues(t, s, MetricTrafficUp, report.UUID, now.Add(-time.Second), now.Add(time.Second), []float64{float64(5 * oneGB)})
|
||||
assertMetricValues(t, s, MetricTrafficDown, report.UUID, now.Add(-time.Second), now.Add(time.Second), []float64{float64(7 * oneGB)})
|
||||
}
|
||||
|
||||
func TestWriteReportCountsTrafficAfterHighRateCounterWrap(t *testing.T) {
|
||||
const oneGB = int64(1_000_000_000)
|
||||
ctx := context.Background()
|
||||
s := useReportTestStore(t, nil)
|
||||
base := time.Now().UTC().Truncate(time.Minute).Add(5 * time.Second)
|
||||
report := v2.Report{
|
||||
UUID: "wrap-node",
|
||||
UpdatedAt: base,
|
||||
Uptime: 10_000,
|
||||
Network: v2.NetworkReport{TotalUp: 3*oneGB + 900_000_000, TotalDown: 3*oneGB + 800_000_000},
|
||||
}
|
||||
if _, err := WriteReport(ctx, report); err != nil {
|
||||
t.Fatalf("write first report: %v", err)
|
||||
}
|
||||
|
||||
report.UpdatedAt = base.Add(3 * time.Second)
|
||||
report.Uptime = 10_003
|
||||
report.Network.TotalUp = 800_000_000
|
||||
report.Network.TotalDown = 900_000_000
|
||||
if _, err := WriteReport(ctx, report); err != nil {
|
||||
t.Fatalf("write wrapped report: %v", err)
|
||||
}
|
||||
|
||||
assertMetricValues(t, s, MetricTrafficUp, report.UUID, base.Add(-time.Second), base.Add(time.Minute), []float64{0, 800_000_000})
|
||||
assertMetricValues(t, s, MetricTrafficDown, report.UUID, base.Add(-time.Second), base.Add(time.Minute), []float64{0, 900_000_000})
|
||||
}
|
||||
|
||||
func TestWriteReportIgnoresTinyDipOfTBScaleCounter(t *testing.T) {
|
||||
const (
|
||||
oneGB = int64(1_000_000_000)
|
||||
oneTB = int64(1_000_000_000_000)
|
||||
)
|
||||
ctx := context.Background()
|
||||
s := useReportTestStore(t, nil)
|
||||
base := time.Now().UTC().Truncate(time.Minute).Add(5 * time.Second)
|
||||
report := v2.Report{
|
||||
UUID: "tb-jitter-node",
|
||||
UpdatedAt: base,
|
||||
Uptime: 10_000,
|
||||
Network: v2.NetworkReport{TotalUp: 2 * oneTB, TotalDown: 3 * oneTB},
|
||||
}
|
||||
if _, err := WriteReport(ctx, report); err != nil {
|
||||
t.Fatalf("write first report: %v", err)
|
||||
}
|
||||
|
||||
report.UpdatedAt = base.Add(3 * time.Second)
|
||||
report.Uptime = 10_003
|
||||
report.Network.TotalUp = 2*oneTB + 5*oneGB
|
||||
report.Network.TotalDown = 3*oneTB + 7*oneGB
|
||||
if _, err := WriteReport(ctx, report); err != nil {
|
||||
t.Fatalf("write growing report: %v", err)
|
||||
}
|
||||
|
||||
report.UpdatedAt = base.Add(6 * time.Second)
|
||||
report.Uptime = 10_006
|
||||
report.Network.TotalUp = 2*oneTB + 5*oneGB - 1000
|
||||
report.Network.TotalDown = 3*oneTB + 7*oneGB - 2000
|
||||
if _, err := WriteReport(ctx, report); err != nil {
|
||||
t.Fatalf("write jitter report: %v", err)
|
||||
}
|
||||
|
||||
report.UpdatedAt = base.Add(9 * time.Second)
|
||||
report.Uptime = 10_009
|
||||
report.Network.TotalUp = 2*oneTB + 6*oneGB
|
||||
report.Network.TotalDown = 3*oneTB + 8*oneGB
|
||||
if _, err := WriteReport(ctx, report); err != nil {
|
||||
t.Fatalf("write recovered report: %v", err)
|
||||
}
|
||||
|
||||
assertMetricValues(t, s, MetricTrafficUp, report.UUID, base.Add(-time.Second), base.Add(time.Minute), []float64{0, float64(5 * oneGB), 0, float64(oneGB + 1000)})
|
||||
assertMetricValues(t, s, MetricTrafficDown, report.UUID, base.Add(-time.Second), base.Add(time.Minute), []float64{0, float64(7 * oneGB), 0, float64(oneGB + 2000)})
|
||||
}
|
||||
|
||||
func TestWriteReportNormalizesReceiveTimeToUTC(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := useReportTestStore(t, nil)
|
||||
local := time.FixedZone("UTC+8", 8*60*60)
|
||||
receiveTime := time.Now().In(local).Add(-10 * time.Second)
|
||||
report := v2.Report{
|
||||
UUID: "utc-report",
|
||||
UpdatedAt: receiveTime,
|
||||
CPU: v2.CPUReport{Usage: 10},
|
||||
Network: v2.NetworkReport{TotalUp: 1, TotalDown: 2},
|
||||
}
|
||||
|
||||
saved, err := WriteReport(ctx, report)
|
||||
if err != nil {
|
||||
t.Fatalf("write report: %v", err)
|
||||
}
|
||||
if !saved.UpdatedAt.Equal(receiveTime) || saved.UpdatedAt.Location() != time.UTC {
|
||||
t.Fatalf("saved receive time = %s (%s), want UTC", saved.UpdatedAt, saved.UpdatedAt.Location())
|
||||
}
|
||||
points, err := s.Query(ctx, metric.Query{
|
||||
MetricName: MetricCPU,
|
||||
EntityID: report.UUID,
|
||||
Start: receiveTime.Add(-time.Nanosecond),
|
||||
End: receiveTime.Add(time.Nanosecond),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("query stored point: %v", err)
|
||||
}
|
||||
if len(points) != 1 || points[0].Timestamp.Location() != time.UTC || points[0].Timestamp.UnixMilli() != receiveTime.UnixMilli() {
|
||||
t.Fatalf("stored points = %#v, want one UTC millisecond point", points)
|
||||
}
|
||||
}
|
||||
|
||||
func assertMetricValues(t *testing.T, s *metric.Store, metricName, entityID string, start, end time.Time, want []float64) {
|
||||
t.Helper()
|
||||
points, err := s.Query(context.Background(), metric.Query{
|
||||
MetricName: metricName,
|
||||
EntityID: entityID,
|
||||
Start: start,
|
||||
End: end,
|
||||
Order: metric.OrderAsc,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("query %s: %v", metricName, err)
|
||||
}
|
||||
if len(points) != len(want) {
|
||||
t.Fatalf("%s point count = %d, want %d: %#v", metricName, len(points), len(want), points)
|
||||
}
|
||||
for i := range want {
|
||||
if points[i].Value != want[i] {
|
||||
t.Fatalf("%s point %d = %v, want %v", metricName, i, points[i].Value, want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertMetricAggregate(t *testing.T, s *metric.Store, metricName, entityID string, start, end time.Time, aggregation metric.Aggregation, want float64, wantCount int) {
|
||||
t.Helper()
|
||||
points, err := s.Series(context.Background(), metric.AggregateQuery{
|
||||
Query: metric.Query{MetricName: metricName, EntityID: entityID, Start: start, End: end},
|
||||
Aggregation: aggregation, Interval: time.Minute, PreserveSeries: true,
|
||||
}, end)
|
||||
if err != nil {
|
||||
t.Fatalf("aggregate %s: %v", metricName, err)
|
||||
}
|
||||
if len(points) != 1 || points[0].Value != want || points[0].Count != wantCount {
|
||||
t.Fatalf("aggregate %s = %#v, want value=%v count=%d", metricName, points, want, wantCount)
|
||||
}
|
||||
}
|
||||
@@ -37,99 +37,67 @@ func StructureUpgradeRequired(ctx context.Context) (bool, error) {
|
||||
}
|
||||
|
||||
func structureUpgradeRequiredForConfig(ctx context.Context, cfg *MetricStoreConfig) (bool, error) {
|
||||
s, err := openWithoutMigration(ctx, cfg)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer s.Close()
|
||||
return s.NeedsRestructure(ctx)
|
||||
}
|
||||
|
||||
// openWithoutMigration opens cfg without creating or altering any table.
|
||||
func openWithoutMigration(ctx context.Context, cfg *MetricStoreConfig) (*metric.Store, error) {
|
||||
metricCfg, err := buildMetricConfig(cfg, false)
|
||||
if err != nil {
|
||||
return false, err
|
||||
return nil, err
|
||||
}
|
||||
store, err := metric.Open(ctx, metricCfg)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer store.Close()
|
||||
return store.NeedsRestructure(ctx)
|
||||
return metric.Open(ctx, metricCfg)
|
||||
}
|
||||
|
||||
// RestructureConfiguredStore performs the explicit data copy, table swap and
|
||||
// one physical reclaim pass used by the upgrade guide.
|
||||
func RestructureConfiguredStore(ctx context.Context, report func(RestructureProgress)) (RestructureResult, error) {
|
||||
cfg, err := config.GetManyAs[MetricStoreConfig]()
|
||||
if err != nil {
|
||||
return RestructureResult{}, err
|
||||
}
|
||||
metricCfg, err := buildMetricConfig(cfg, false)
|
||||
if err != nil {
|
||||
return RestructureResult{}, err
|
||||
}
|
||||
store, err := metric.Open(ctx, metricCfg)
|
||||
if err != nil {
|
||||
return RestructureResult{}, err
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
before, err := store.LegacyStorageSize(ctx)
|
||||
if err != nil {
|
||||
return RestructureResult{}, fmt.Errorf("measure legacy metric storage: %w", err)
|
||||
}
|
||||
result, err := store.Restructure(ctx, func(progress metric.RestructureProgress) {
|
||||
if report == nil {
|
||||
return
|
||||
}
|
||||
report(RestructureProgress{Phase: progress.Phase, CurrentMetric: progress.Current, RowsDone: progress.RowsDone, RowsTotal: progress.RowsTotal, MetricsDone: progress.MetricsDone, MetricsTotal: progress.MetricsTotal})
|
||||
})
|
||||
if err != nil {
|
||||
return RestructureResult{}, err
|
||||
}
|
||||
if report != nil {
|
||||
report(RestructureProgress{Phase: "reclaiming", RowsDone: result.RowsCopied, RowsTotal: result.RowsCopied, MetricsDone: result.Metrics, MetricsTotal: result.Metrics})
|
||||
}
|
||||
if err := store.ReclaimSpace(ctx); err != nil {
|
||||
return RestructureResult{}, fmt.Errorf("reclaim metric storage: %w", err)
|
||||
}
|
||||
after, err := store.StorageSize(ctx)
|
||||
if err != nil {
|
||||
return RestructureResult{}, fmt.Errorf("measure rebuilt metric storage: %w", err)
|
||||
}
|
||||
return RestructureResult{BeforeBytes: before, AfterBytes: after, RowsCopied: result.RowsCopied, Metrics: result.Metrics}, nil
|
||||
return rebuildConfiguredStore(ctx, report, (*metric.Store).Restructure)
|
||||
}
|
||||
|
||||
// DiscardConfiguredStoreHistory completes the same schema upgrade as
|
||||
// RestructureConfiguredStore but drops every historical sample instead of
|
||||
// copying it. Metric definitions remain available for new ingest.
|
||||
func DiscardConfiguredStoreHistory(ctx context.Context, report func(RestructureProgress)) (RestructureResult, error) {
|
||||
return rebuildConfiguredStore(ctx, report, (*metric.Store).DiscardHistory)
|
||||
}
|
||||
|
||||
type storeRebuild func(*metric.Store, context.Context, func(metric.RestructureProgress)) (metric.RestructureResult, error)
|
||||
|
||||
func rebuildConfiguredStore(ctx context.Context, report func(RestructureProgress), rebuild storeRebuild) (RestructureResult, error) {
|
||||
if report == nil {
|
||||
report = func(RestructureProgress) {}
|
||||
}
|
||||
cfg, err := config.GetManyAs[MetricStoreConfig]()
|
||||
if err != nil {
|
||||
return RestructureResult{}, err
|
||||
}
|
||||
metricCfg, err := buildMetricConfig(cfg, false)
|
||||
s, err := openWithoutMigration(ctx, cfg)
|
||||
if err != nil {
|
||||
return RestructureResult{}, err
|
||||
}
|
||||
store, err := metric.Open(ctx, metricCfg)
|
||||
if err != nil {
|
||||
return RestructureResult{}, err
|
||||
}
|
||||
defer store.Close()
|
||||
defer s.Close()
|
||||
|
||||
before, err := store.LegacyStorageSize(ctx)
|
||||
before, err := s.LegacyStorageSize(ctx)
|
||||
if err != nil {
|
||||
return RestructureResult{}, fmt.Errorf("measure legacy metric storage: %w", err)
|
||||
}
|
||||
result, err := store.DiscardHistory(ctx, func(progress metric.RestructureProgress) {
|
||||
if report == nil {
|
||||
return
|
||||
}
|
||||
result, err := rebuild(s, ctx, func(progress metric.RestructureProgress) {
|
||||
report(RestructureProgress{Phase: progress.Phase, CurrentMetric: progress.Current, RowsDone: progress.RowsDone, RowsTotal: progress.RowsTotal, MetricsDone: progress.MetricsDone, MetricsTotal: progress.MetricsTotal})
|
||||
})
|
||||
if err != nil {
|
||||
return RestructureResult{}, err
|
||||
}
|
||||
if report != nil {
|
||||
report(RestructureProgress{Phase: "reclaiming", RowsDone: result.RowsCopied, RowsTotal: result.RowsCopied, MetricsDone: result.Metrics, MetricsTotal: result.Metrics})
|
||||
}
|
||||
if err := store.ReclaimSpace(ctx); err != nil {
|
||||
report(RestructureProgress{Phase: "reclaiming", RowsDone: result.RowsCopied, RowsTotal: result.RowsCopied, MetricsDone: result.Metrics, MetricsTotal: result.Metrics})
|
||||
if err := s.ReclaimSpace(ctx); err != nil {
|
||||
return RestructureResult{}, fmt.Errorf("reclaim metric storage: %w", err)
|
||||
}
|
||||
after, err := store.StorageSize(ctx)
|
||||
after, err := s.StorageSize(ctx)
|
||||
if err != nil {
|
||||
return RestructureResult{}, fmt.Errorf("measure rebuilt metric storage: %w", err)
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
package metricstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
logger "github.com/komari-monitor/komari/utils/log"
|
||||
|
||||
"github.com/komari-monitor/komari/internal/config"
|
||||
"github.com/komari-monitor/komari/pkg/metric"
|
||||
)
|
||||
|
||||
var (
|
||||
store *metric.Store
|
||||
storeFingerprint string
|
||||
storeMu sync.RWMutex
|
||||
storeInitMu sync.Mutex
|
||||
storeOperations = newStoreOperationGate()
|
||||
compactOperations = newStoreOperationGate()
|
||||
)
|
||||
|
||||
var ErrCompactInProgress = errors.New("metric store compact already in progress")
|
||||
|
||||
// ErrStructureUpgradeRequired reports that the configured store must be
|
||||
// migrated by the restricted startup guide before it can be opened normally.
|
||||
var ErrStructureUpgradeRequired = errors.New("metric store structure upgrade is required")
|
||||
|
||||
// openStore 按配置打开 metric store 并创建指标定义。
|
||||
func openStore(ctx context.Context, cfg *MetricStoreConfig) (*metric.Store, error) {
|
||||
return openStoreWithDefaultRetention(ctx, cfg, defaultBuiltinMetricRetentionDays)
|
||||
}
|
||||
|
||||
func openStoreWithDefaultRetention(ctx context.Context, cfg *MetricStoreConfig, defaultRetentionDays int) (*metric.Store, error) {
|
||||
metricCfg, err := buildMetricConfig(cfg, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s, err := metric.Open(ctx, metricCfg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open metric store: %w", err)
|
||||
}
|
||||
|
||||
if err := createMetricDefinitionsWithDefaultRetention(ctx, s, defaultRetentionDays); err != nil {
|
||||
s.Close()
|
||||
return nil, fmt.Errorf("failed to create metric definitions: %w", err)
|
||||
}
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// OpenStoreForMigration opens an isolated target and uses the legacy data span
|
||||
// as the initial retention for definitions that do not exist yet. Existing
|
||||
// definitions keep their configured retention, including an explicit zero.
|
||||
func OpenStoreForMigration(ctx context.Context, cfg *MetricStoreConfig, legacyRetentionDays int) (*metric.Store, error) {
|
||||
if legacyRetentionDays < defaultBuiltinMetricRetentionDays {
|
||||
legacyRetentionDays = defaultBuiltinMetricRetentionDays
|
||||
}
|
||||
return openStoreWithDefaultRetention(ctx, cfg, legacyRetentionDays)
|
||||
}
|
||||
|
||||
// TestConnection 使用给定配置尝试连接 metrics 数据库(不影响当前运行的 store)。
|
||||
// 仅打开连接并 Ping,不执行自动建表,连接成功后立即关闭。失败时返回可读错误。
|
||||
func TestConnection(ctx context.Context, cfg *MetricStoreConfig) error {
|
||||
s, err := openWithoutMigration(ctx, cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer s.Close()
|
||||
return s.Ping(ctx)
|
||||
}
|
||||
|
||||
// InitializeStore 初始化 metric store(启动时调用,可在失败后重试)。
|
||||
func InitializeStore() error {
|
||||
storeInitMu.Lock()
|
||||
defer storeInitMu.Unlock()
|
||||
|
||||
// A previous failed connection must remain retryable. The old sync.Once
|
||||
// implementation consumed the one-time call on failure and returned nil on
|
||||
// every later call while the store was still nil.
|
||||
storeMu.RLock()
|
||||
initialized := store != nil
|
||||
storeMu.RUnlock()
|
||||
if initialized {
|
||||
return nil
|
||||
}
|
||||
|
||||
cfg, err := config.GetManyAs[MetricStoreConfig]()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load metric store config: %w", err)
|
||||
}
|
||||
|
||||
// metric store 始终启用;未配置时默认 SQLite(./data/metrics.db)。
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
s, err := openStore(ctx, cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
activateStore(s, targetFingerprint(cfg), "initialization")
|
||||
clearStoreClosing()
|
||||
|
||||
logger.Infof("metricstore", "Metric store initialized successfully (driver=%s)", ResolveDriverFromConfig(cfg.Driver, cfg.DSN))
|
||||
return nil
|
||||
}
|
||||
|
||||
// RecoverStore opens, persists, and activates a replacement store selected
|
||||
// from the restricted recovery page. It records that target as the current
|
||||
// manual-migration source, but does not copy data from the unavailable store.
|
||||
func RecoverStore(ctx context.Context, cfg *MetricStoreConfig) error {
|
||||
storeInitMu.Lock()
|
||||
defer storeInitMu.Unlock()
|
||||
if err := storeOperations.Acquire(ctx); err != nil {
|
||||
return fmt.Errorf("wait for metric store operations before recovery: %w", err)
|
||||
}
|
||||
defer storeOperations.Release()
|
||||
if isStoreClosing() {
|
||||
return ErrStoreBusy
|
||||
}
|
||||
|
||||
recovered := *cfg
|
||||
recovered.DSN = strings.TrimSpace(recovered.DSN)
|
||||
recovered.Driver = string(ResolveDriverFromConfig(recovered.Driver, recovered.DSN))
|
||||
target := targetFingerprint(&recovered)
|
||||
restructureRequired, err := structureUpgradeRequiredForConfig(ctx, &recovered)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if restructureRequired {
|
||||
if err := saveRecoveredTarget(&recovered, target); err != nil {
|
||||
return err
|
||||
}
|
||||
logger.Infof("metricstore", "Metric store connection recovered; structure upgrade is required (driver=%s)", recovered.Driver)
|
||||
return nil
|
||||
}
|
||||
s, err := openStore(ctx, &recovered)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := saveRecoveredTarget(&recovered, target); err != nil {
|
||||
_ = s.Close()
|
||||
return err
|
||||
}
|
||||
activateStore(s, target, "recovery")
|
||||
clearStoreClosing()
|
||||
logger.Infof("metricstore", "Metric store recovered successfully (driver=%s)", recovered.Driver)
|
||||
return nil
|
||||
}
|
||||
|
||||
// saveRecoveredTarget persists the recovered connection and records it as the
|
||||
// default source for the next manual store migration.
|
||||
func saveRecoveredTarget(cfg *MetricStoreConfig, target string) error {
|
||||
if err := config.SetMany(map[string]any{
|
||||
MetricDBDriverKey: cfg.Driver,
|
||||
MetricDBDSNKey: cfg.DSN,
|
||||
MigrationTargetKey: target,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("save recovered metric store config: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// activateStore publishes s as the active store, then closes the store it
|
||||
// replaced. Callers must hold the exclusive operation gate or storeInitMu.
|
||||
func activateStore(s *metric.Store, fingerprint, reason string) {
|
||||
storeMu.Lock()
|
||||
old := store
|
||||
store = s
|
||||
storeFingerprint = fingerprint
|
||||
storeMu.Unlock()
|
||||
if old == nil {
|
||||
return
|
||||
}
|
||||
if err := old.Close(); err != nil {
|
||||
logger.Errorf("metricstore", "Failed to close previous metric store during %s: %v", reason, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Reload 根据最新配置热重载 metric store,无需重启进程。
|
||||
// metric store 始终启用:用新配置打开并建表(内部已 Ping 校验连接),
|
||||
// 成功后再替换运行中的 store,最后关闭旧实例。任何失败都会保留旧 store 不变。
|
||||
//
|
||||
// 注意:Reload 只切换运行中的连接,不会把旧目标(如 SQLite)中的历史数据
|
||||
// 搬运到新目标(如 MySQL/PostgreSQL)。跨库数据迁移必须由管理员手动启动。
|
||||
func Reload(ctx context.Context) error {
|
||||
if err := storeOperations.Acquire(ctx); err != nil {
|
||||
return fmt.Errorf("wait for metric store operations before reload: %w", err)
|
||||
}
|
||||
defer storeOperations.Release()
|
||||
if isStoreClosing() {
|
||||
return ErrStoreBusy
|
||||
}
|
||||
|
||||
cfg, err := config.GetManyAs[MetricStoreConfig]()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load metric store config: %w", err)
|
||||
}
|
||||
|
||||
// Do not let the normal hot-reload path run AutoMigrate against an old
|
||||
// point-backed schema. That schema must go through the authenticated
|
||||
// structure-upgrade guide first; otherwise index creation can reference
|
||||
// columns such as resolution_id that do not exist yet.
|
||||
needsRestructure, err := structureUpgradeRequiredForConfig(ctx, cfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("inspect metric store structure: %w", err)
|
||||
}
|
||||
if needsRestructure {
|
||||
return fmt.Errorf("%w before hot reload", ErrStructureUpgradeRequired)
|
||||
}
|
||||
|
||||
// 用新配置打开并建表(内部已 Ping 校验连接)。
|
||||
s, err := openStore(ctx, cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
activateStore(s, targetFingerprint(cfg), "reload")
|
||||
logger.Infof("metricstore", "Metric store reloaded successfully (driver=%s)", ResolveDriverFromConfig(cfg.Driver, cfg.DSN))
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetStore 获取 metric store 实例(如果未启用返回 nil)
|
||||
func GetStore() *metric.Store {
|
||||
storeMu.RLock()
|
||||
defer storeMu.RUnlock()
|
||||
return store
|
||||
}
|
||||
|
||||
// CloseStoreContext stops the asynchronous store migration before taking the
|
||||
// store write lock, so shutdown cannot wait forever on the migration's lease.
|
||||
func CloseStoreContext(ctx context.Context) error {
|
||||
if err := StopStoreMigrationForShutdown(ctx); err != nil {
|
||||
clearStoreClosing()
|
||||
return err
|
||||
}
|
||||
if err := storeOperations.Acquire(ctx); err != nil {
|
||||
clearStoreClosing()
|
||||
return fmt.Errorf("wait for metric store operations before close: %w", err)
|
||||
}
|
||||
defer storeOperations.Release()
|
||||
|
||||
storeMu.Lock()
|
||||
defer storeMu.Unlock()
|
||||
var err error
|
||||
if store != nil {
|
||||
err = store.Close()
|
||||
store = nil
|
||||
}
|
||||
storeFingerprint = ""
|
||||
return err
|
||||
}
|
||||
@@ -3,11 +3,12 @@ package metricstore
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
logger "github.com/komari-monitor/komari/utils/log"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
logger "github.com/komari-monitor/komari/utils/log"
|
||||
|
||||
"github.com/komari-monitor/komari/internal/config"
|
||||
"github.com/komari-monitor/komari/pkg/metric"
|
||||
)
|
||||
@@ -223,54 +224,36 @@ func finishStoreMigration(status string, err error) {
|
||||
func CancelStoreMigration() error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
return stopStoreMigration(ctx, true)
|
||||
}
|
||||
|
||||
func stopStoreMigration(ctx context.Context, requireRunning bool) error {
|
||||
storeMigMu.Lock()
|
||||
cancel := storeMigCancel
|
||||
done := storeMigDone
|
||||
stop, done := storeMigCancel, storeMigDone
|
||||
storeMigMu.Unlock()
|
||||
|
||||
if cancel == nil {
|
||||
if requireRunning {
|
||||
return fmt.Errorf("no store migration is currently running")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
cancel()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return fmt.Errorf("wait for store migration to cancel: %w", ctx.Err())
|
||||
}
|
||||
}
|
||||
|
||||
func stopStoreMigrationForClose(ctx context.Context) error {
|
||||
storeMigMu.Lock()
|
||||
storeClosing = true
|
||||
cancel := storeMigCancel
|
||||
done := storeMigDone
|
||||
storeMigMu.Unlock()
|
||||
|
||||
if cancel == nil {
|
||||
return nil
|
||||
}
|
||||
cancel()
|
||||
select {
|
||||
case <-done:
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return fmt.Errorf("wait for store migration to cancel: %w", ctx.Err())
|
||||
if stop == nil {
|
||||
return fmt.Errorf("no store migration is currently running")
|
||||
}
|
||||
return awaitStoreMigrationStop(ctx, stop, done)
|
||||
}
|
||||
|
||||
// StopStoreMigrationForShutdown prevents a new store migration and waits for
|
||||
// an active one to release its exclusive lease before report batches flush.
|
||||
func StopStoreMigrationForShutdown(ctx context.Context) error {
|
||||
return stopStoreMigrationForClose(ctx)
|
||||
storeMigMu.Lock()
|
||||
storeClosing = true
|
||||
stop, done := storeMigCancel, storeMigDone
|
||||
storeMigMu.Unlock()
|
||||
if stop == nil {
|
||||
return nil
|
||||
}
|
||||
return awaitStoreMigrationStop(ctx, stop, done)
|
||||
}
|
||||
|
||||
func awaitStoreMigrationStop(ctx context.Context, stop context.CancelFunc, done <-chan struct{}) error {
|
||||
stop()
|
||||
select {
|
||||
case <-done:
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return fmt.Errorf("wait for store migration to cancel: %w", ctx.Err())
|
||||
}
|
||||
}
|
||||
|
||||
func clearStoreClosing() {
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/komari-monitor/komari/database/metricstore"
|
||||
"github.com/komari-monitor/komari/internal/metricstore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
appconfig "github.com/komari-monitor/komari/internal/config"
|
||||
"github.com/komari-monitor/komari/pkg/metric"
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/komari-monitor/komari/database/metricstore"
|
||||
"github.com/komari-monitor/komari/internal/metricstore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
appconfig "github.com/komari-monitor/komari/internal/config"
|
||||
"github.com/komari-monitor/komari/pkg/metric"
|
||||
|
||||
@@ -112,9 +112,6 @@ func Run(ctx Context) error {
|
||||
if err := migrateLegacyClientInfo(db); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := migrateLegacyLoadNotification(db); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := migrateLegacyPingAllClientsExpansion(db); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -175,14 +172,6 @@ func hasTableColumn(db *gorm.DB, tableName, columnName string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func migrateLegacyLoadNotification(db *gorm.DB) error {
|
||||
if db.Migrator().HasColumn(&models.LoadNotification{}, "client") {
|
||||
logger.InfoArgs("migration", "[>0.1.4] Rebuilding LoadNotification table....")
|
||||
return db.Migrator().DropTable(&models.LoadNotification{})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func migrateLegacyOidcConfig(db *gorm.DB) error {
|
||||
if db.Migrator().HasTable(&models.OidcProvider{}) {
|
||||
return nil
|
||||
|
||||
@@ -35,10 +35,7 @@ var legacyTimestampColumns = [...]timestampColumn{
|
||||
{table: "sessions", column: "expires"},
|
||||
{table: "sessions", column: "created_at"},
|
||||
{table: "logs", column: "time"},
|
||||
{table: "clipboards", column: "created_at"},
|
||||
{table: "clipboards", column: "updated_at"},
|
||||
{table: "offline_notifications", column: "last_notified"},
|
||||
{table: "load_notifications", column: "last_notified"},
|
||||
{table: "task_results", column: "finished_at"},
|
||||
{table: "task_results", column: "created_at"},
|
||||
{table: "records", column: "time"},
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
// Package notifications wires the built-in notification channels into the
|
||||
// shared messageSender registry.
|
||||
package notifications
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
|
||||
"github.com/komari-monitor/komari/database"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/komari-monitor/komari/internal/managedconfig"
|
||||
"github.com/komari-monitor/komari/utils/messageSender"
|
||||
"github.com/komari-monitor/komari/utils/messageSender/javascript"
|
||||
"github.com/komari-monitor/komari/utils/messageSender/webhook"
|
||||
)
|
||||
|
||||
// Initialize registers the built-in channels and seeds their defaults.
|
||||
func Initialize() (err error) {
|
||||
if err := webhook.Register(); err != nil {
|
||||
return err
|
||||
}
|
||||
registered := []string{"webhook"}
|
||||
defer func() {
|
||||
if err != nil {
|
||||
for _, id := range registered {
|
||||
err = errors.Join(err, messageSender.UnregisterNotificationChannel(id))
|
||||
}
|
||||
}
|
||||
}()
|
||||
if err := javascript.Register(); err != nil {
|
||||
return err
|
||||
}
|
||||
registered = append(registered, javascript.ChannelID)
|
||||
for _, item := range messageSender.ListNotificationChannels() {
|
||||
if _, err := database.GetMessageSenderConfigByName(item.ID); err == nil {
|
||||
continue
|
||||
}
|
||||
values := map[string]any{}
|
||||
for _, field := range managedconfig.Items(item.Configuration) {
|
||||
if field.Key != "" {
|
||||
values[field.Key] = managedconfig.DefaultValue(field)
|
||||
}
|
||||
}
|
||||
raw, err := json.Marshal(values)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := database.SaveMessageSenderConfig(&models.MessageSenderProvider{
|
||||
Name: item.ID,
|
||||
Addition: string(raw),
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Shutdown unregisters the built-in channels and releases their runtimes.
|
||||
func Shutdown() error {
|
||||
return errors.Join(
|
||||
messageSender.UnregisterNotificationChannel(javascript.ChannelID),
|
||||
messageSender.UnregisterNotificationChannel("webhook"),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/komari-monitor/komari/internal/managedconfig"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// GetConfiguration returns the saved configuration values of one plugin,
|
||||
// merged with the manifest-declared defaults for keys that were never saved.
|
||||
// This mirrors the theme configuration behavior so plugins and the admin UI
|
||||
// always see a complete value set. Missing rows yield only defaults; a
|
||||
// missing manifest yields only the saved values.
|
||||
func GetConfiguration(short string) (map[string]any, error) {
|
||||
values, err := savedConfiguration(short)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
info, err := readManifest(filepath.Join(DataDir, short))
|
||||
if err != nil {
|
||||
return values, nil // not installed (or unreadable): keep saved values
|
||||
}
|
||||
items := managedconfig.Items(info.Configuration)
|
||||
mergeConfigurationDefaults(values, items)
|
||||
if err := managedconfig.ResolveForOutput(values, items); err != nil {
|
||||
return nil, fmt.Errorf("resolve plugin configuration: %w", err)
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
|
||||
func savedConfiguration(short string) (map[string]any, error) {
|
||||
db := dbcore.GetDBInstance()
|
||||
var cfg models.PluginConfiguration
|
||||
if err := db.Where("short = ?", short).First(&cfg).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return map[string]any{}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("read plugin configuration: %w", err)
|
||||
}
|
||||
var data map[string]any
|
||||
if err := json.Unmarshal([]byte(cfg.Data), &data); err != nil || data == nil {
|
||||
return map[string]any{}, nil
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// mergeConfigurationDefaults fills manifest-declared defaults for keys that
|
||||
// are missing from the saved values, following the theme merge rules.
|
||||
func mergeConfigurationDefaults(values map[string]any, items []models.ManagedThemeConfigurationItem) {
|
||||
for _, item := range items {
|
||||
if item.Key == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := values[item.Key]; exists {
|
||||
continue
|
||||
}
|
||||
values[item.Key] = managedconfig.DefaultValue(item)
|
||||
}
|
||||
}
|
||||
|
||||
// SaveConfiguration upserts the configuration values of one plugin.
|
||||
func SaveConfiguration(short string, data map[string]any) error {
|
||||
if data == nil {
|
||||
data = map[string]any{}
|
||||
}
|
||||
raw, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal plugin configuration: %w", err)
|
||||
}
|
||||
db := dbcore.GetDBInstance()
|
||||
if err := db.Where("short = ?", short).
|
||||
Assign(models.PluginConfiguration{Short: short, Data: string(raw)}).
|
||||
FirstOrCreate(&models.PluginConfiguration{}).Error; err != nil {
|
||||
return fmt.Errorf("save plugin configuration: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/Calcium-Ion/moejs"
|
||||
"github.com/Calcium-Ion/moejs/engine"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/pkg/jsruntime/native"
|
||||
"github.com/komari-monitor/komari/pkg/rpc"
|
||||
)
|
||||
|
||||
// principalContextKey stays in sync with web/api/principal.go:
|
||||
// IdentityMiddleware stores the resolved principal under this gin key.
|
||||
const principalContextKey = "principal"
|
||||
|
||||
// routeRequestContext builds the JS request context with the caller identity
|
||||
// resolved by IdentityMiddleware. Raw credentials (session/client tokens) are
|
||||
// intentionally not exposed.
|
||||
func routeRequestContext(vm *moejs.Runtime, c *gin.Context) *engine.Object {
|
||||
ctx := vm.Realm().NewObject()
|
||||
principal := rpcPrincipalFromGin(c)
|
||||
p := vm.Realm().NewObject()
|
||||
if principal != nil {
|
||||
_ = native.Set(vm, p, "type", principalTypeString(principal.Type))
|
||||
_ = native.Set(vm, p, "roles", principal.Roles)
|
||||
_ = native.Set(vm, p, "user_uuid", principal.UserUUID)
|
||||
_ = native.Set(vm, p, "client_uuid", principal.ClientUUID)
|
||||
_ = native.Set(vm, p, "is_api_key", principal.IsAPIKey)
|
||||
}
|
||||
_ = native.Set(vm, ctx, "principal", p)
|
||||
if role, ok := c.Get("role"); ok {
|
||||
if s, ok := role.(string); ok {
|
||||
_ = native.Set(vm, ctx, "role", s)
|
||||
}
|
||||
}
|
||||
if uuid, ok := c.Get("uuid"); ok {
|
||||
_ = native.Set(vm, ctx, "user_uuid", uuid)
|
||||
}
|
||||
if clientUUID, ok := c.Get("client_uuid"); ok {
|
||||
_ = native.Set(vm, ctx, "client_uuid", clientUUID)
|
||||
}
|
||||
_ = native.Set(vm, ctx, "remote_ip", c.ClientIP())
|
||||
_ = native.Set(vm, ctx, "user_agent", c.GetHeader("User-Agent"))
|
||||
return ctx
|
||||
}
|
||||
|
||||
// hookRequestContext builds the JS request context at the raw HTTP layer
|
||||
// (before the gin identity middleware runs): only network metadata is
|
||||
// available there.
|
||||
func hookRequestContext(vm *moejs.Runtime, r *http.Request) *engine.Object {
|
||||
ctx := vm.Realm().NewObject()
|
||||
_ = native.Set(vm, ctx, "remote_ip", remoteIP(r))
|
||||
_ = native.Set(vm, ctx, "user_agent", r.Header.Get("User-Agent"))
|
||||
return ctx
|
||||
}
|
||||
|
||||
func rpcPrincipalFromGin(c *gin.Context) *rpc.Principal {
|
||||
value, ok := c.Get(principalContextKey)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
principal, _ := value.(*rpc.Principal)
|
||||
return principal
|
||||
}
|
||||
|
||||
func principalTypeString(t rpc.PrincipalType) string {
|
||||
switch t {
|
||||
case rpc.PrincipalAgent:
|
||||
return "agent"
|
||||
case rpc.PrincipalUser:
|
||||
return "user"
|
||||
case rpc.PrincipalAPIKey:
|
||||
return "api_key"
|
||||
default:
|
||||
return "anonymous"
|
||||
}
|
||||
}
|
||||
|
||||
// remoteIP mirrors gin's ClientIP for the raw request layer: first
|
||||
// X-Forwarded-For entry, then X-Real-IP, then the RemoteAddr host.
|
||||
func remoteIP(r *http.Request) string {
|
||||
if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" {
|
||||
if first, _, ok := strings.Cut(forwarded, ","); ok {
|
||||
return strings.TrimSpace(first)
|
||||
}
|
||||
return strings.TrimSpace(forwarded)
|
||||
}
|
||||
if real := r.Header.Get("X-Real-IP"); real != "" {
|
||||
return strings.TrimSpace(real)
|
||||
}
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
return r.RemoteAddr
|
||||
}
|
||||
return host
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/Calcium-Ion/moejs"
|
||||
"github.com/komari-monitor/komari/internal/scheduler"
|
||||
"github.com/komari-monitor/komari/pkg/jsruntime"
|
||||
)
|
||||
|
||||
// cronJobSeq gives every registered cron job a process-unique id. Job names
|
||||
// stay unique across plugin reloads because unload cancels every job of the
|
||||
// plugin before a new load registers replacements.
|
||||
var cronJobSeq atomic.Uint64
|
||||
|
||||
// registerCron registers one cron job for a plugin. The handler is a
|
||||
// JavaScript function that runs on the plugin's own event loop each time the
|
||||
// schedule fires. It is called from the plugin's own event loop during script
|
||||
// evaluation and takes the manager lock itself; the critical section is short
|
||||
// and never waits on the event loop, so no lock cycle is possible. The job is
|
||||
// removed automatically when the plugin is unloaded or fails to load.
|
||||
func (m *Manager) registerCron(short, spec string, fn moejs.Value) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
inst, ok := m.instances[short]
|
||||
if !ok {
|
||||
return fmt.Errorf("plugin %q is not loaded", short)
|
||||
}
|
||||
inst.mu.RLock()
|
||||
host := inst.host
|
||||
alive := inst.runtime != nil
|
||||
inst.mu.RUnlock()
|
||||
if !alive || host == nil {
|
||||
return fmt.Errorf("plugin %q runtime is not ready", short)
|
||||
}
|
||||
|
||||
name := fmt.Sprintf("plugin:%s:%d", short, cronJobSeq.Add(1))
|
||||
if err := scheduler.AddContextFunc(name, spec, false, func(ctx context.Context) {
|
||||
m.runCronJob(short, name, fn, host)
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
inst.mu.Lock()
|
||||
inst.cronJobs = append(inst.cronJobs, name)
|
||||
inst.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// runCronJob bridges one scheduled fire into the plugin event loop. It runs
|
||||
// on a scheduler goroutine and drops the fire when the plugin was unloaded
|
||||
// (the job is cancelled with the instance, but a fire may already be queued).
|
||||
// Errors thrown by the handler are reported to the plugin log by RunJob.
|
||||
func (m *Manager) runCronJob(short, name string, fn moejs.Value, owner *jsruntime.Host) {
|
||||
inst := m.instanceFor(short)
|
||||
if inst == nil {
|
||||
return
|
||||
}
|
||||
inst.mu.RLock()
|
||||
host := inst.host
|
||||
alive := inst.runtime != nil
|
||||
inst.mu.RUnlock()
|
||||
if !alive || host == nil || host != owner {
|
||||
return
|
||||
}
|
||||
host.RunOnLoop(func(vm *moejs.Runtime) {
|
||||
_ = host.RunJob(vm, "plugin cron "+name, func() error {
|
||||
_, err := vm.CallFunction(fn, moejs.Undefined())
|
||||
return err
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,896 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/komari-monitor/komari/pkg/rpc"
|
||||
)
|
||||
|
||||
const featureManifest = `{"name":"Feat","short":"feat","version":"1.0.0","permissions":{"timeout":5,"allowRoutes":true,"allowHooks":true}}`
|
||||
|
||||
func TestHookRequestAndResponse(t *testing.T) {
|
||||
withTempDataDir(t)
|
||||
gin.SetMode(gin.TestMode)
|
||||
engine := gin.New()
|
||||
engine.GET("/ping", func(c *gin.Context) {
|
||||
if c.GetHeader("x-hooked") != "yes" {
|
||||
c.String(http.StatusBadRequest, "missing x-hooked")
|
||||
return
|
||||
}
|
||||
c.String(http.StatusOK, "pong")
|
||||
})
|
||||
Init(engine)
|
||||
|
||||
zipPath := writePluginZip(t, map[string]string{
|
||||
"komari-plugin.json": featureManifest,
|
||||
"script.js": `
|
||||
const server = require("server");
|
||||
function load() {
|
||||
server.hook("request", (req) => {
|
||||
req.headers["x-hooked"] = "yes";
|
||||
});
|
||||
server.hook("response", (req, res) => {
|
||||
res.statusCode = 201;
|
||||
res.headers["x-res"] = "1";
|
||||
res.body = res.body + "|hooked";
|
||||
});
|
||||
}
|
||||
`,
|
||||
})
|
||||
if _, err := InstallZip(zipPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := SetEnabled("feat", true, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 用 WrapHandler 包裹(对应 internal/server 的接入点)
|
||||
wrapped := WrapHandler(engine)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
wrapped.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/ping", nil))
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if rec.Body.String() != "pong|hooked" {
|
||||
t.Fatalf("body = %q", rec.Body.String())
|
||||
}
|
||||
if rec.Header().Get("x-res") != "1" {
|
||||
t.Fatalf("headers = %v", rec.Header())
|
||||
}
|
||||
|
||||
// 卸载后 hook 移除:请求不再带 x-hooked,handler 返回 400
|
||||
if err := SetEnabled("feat", false, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec2 := httptest.NewRecorder()
|
||||
wrapped.ServeHTTP(rec2, httptest.NewRequest(http.MethodGet, "/ping", nil))
|
||||
if rec2.Code != http.StatusBadRequest || !strings.Contains(rec2.Body.String(), "missing x-hooked") {
|
||||
t.Fatalf("after unload status = %d, body = %q", rec2.Code, rec2.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHookUpgradePassesThrough(t *testing.T) {
|
||||
withTempDataDir(t)
|
||||
gin.SetMode(gin.TestMode)
|
||||
engine := gin.New()
|
||||
engine.GET("/ws", func(c *gin.Context) {
|
||||
c.String(http.StatusOK, "direct")
|
||||
})
|
||||
Init(engine)
|
||||
|
||||
zipPath := writePluginZip(t, map[string]string{
|
||||
"komari-plugin.json": featureManifest,
|
||||
"script.js": `
|
||||
const server = require("server");
|
||||
function load() {
|
||||
server.hook("request", (req) => { req.headers["x"] = "y"; });
|
||||
server.hook("response", (req, res) => { res.body = "changed"; });
|
||||
}
|
||||
`,
|
||||
})
|
||||
if _, err := InstallZip(zipPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := SetEnabled("feat", true, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/ws", nil)
|
||||
req.Header.Set("Connection", "upgrade")
|
||||
req.Header.Set("Upgrade", "websocket")
|
||||
WrapHandler(engine).ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK || rec.Body.String() != "direct" {
|
||||
t.Fatalf("upgrade passthrough failed: status = %d, body = %q", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterRPCCallableThroughRegistry(t *testing.T) {
|
||||
withTempDataDir(t)
|
||||
gin.SetMode(gin.TestMode)
|
||||
Init(gin.New())
|
||||
|
||||
zipPath := writePluginZip(t, map[string]string{
|
||||
"komari-plugin.json": featureManifest,
|
||||
"script.js": `
|
||||
const server = require("server");
|
||||
function load() {
|
||||
server.registerRPC("plugin:featEcho", (params) => {
|
||||
return { echo: params, from: "feat" };
|
||||
});
|
||||
}
|
||||
`,
|
||||
})
|
||||
if _, err := InstallZip(zipPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := SetEnabled("feat", true, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = SetEnabled("feat", false, false) }()
|
||||
|
||||
resp := rpc.CallWithContext(context.Background(), nil, "plugin:featEcho", map[string]any{"x": 1})
|
||||
if resp.Error != nil {
|
||||
t.Fatalf("rpc error: %v", resp.Error)
|
||||
}
|
||||
result, ok := resp.Result.(map[string]any)
|
||||
if !ok || result["from"] != "feat" {
|
||||
t.Fatalf("result = %v", resp.Result)
|
||||
}
|
||||
echo, ok := result["echo"].(map[string]any)
|
||||
if !ok || fmt.Sprint(echo["x"]) != "1" {
|
||||
t.Fatalf("echo = %v", result["echo"])
|
||||
}
|
||||
|
||||
// 卸载后方法注销
|
||||
if err := SetEnabled("feat", false, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp2 := rpc.CallWithContext(context.Background(), nil, "plugin:featEcho", nil)
|
||||
if resp2.Error == nil || resp2.Error.Code != rpc.MethodNotFound {
|
||||
t.Fatalf("expected MethodNotFound after unload, got %v", resp2.Error)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterRPCErrorCarriesCode(t *testing.T) {
|
||||
withTempDataDir(t)
|
||||
gin.SetMode(gin.TestMode)
|
||||
Init(gin.New())
|
||||
|
||||
zipPath := writePluginZip(t, map[string]string{
|
||||
"komari-plugin.json": featureManifest,
|
||||
"script.js": `
|
||||
const server = require("server");
|
||||
function load() {
|
||||
server.registerRPC("plugin:featFail", () => {
|
||||
const err = new Error("boom");
|
||||
err.code = -32045;
|
||||
err.data = { detail: "x" };
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
`,
|
||||
})
|
||||
if _, err := InstallZip(zipPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := SetEnabled("feat", true, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = SetEnabled("feat", false, false) }()
|
||||
|
||||
resp := rpc.CallWithContext(context.Background(), nil, "plugin:featFail", nil)
|
||||
if resp.Error == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if resp.Error.Code != -32045 || resp.Error.Message != "boom" {
|
||||
t.Fatalf("error = %+v", resp.Error)
|
||||
}
|
||||
data, ok := resp.Error.Data.(map[string]any)
|
||||
if !ok || data["detail"] != "x" {
|
||||
t.Fatalf("error data = %v", resp.Error.Data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteRemovesPlugin(t *testing.T) {
|
||||
withTempDataDir(t)
|
||||
gin.SetMode(gin.TestMode)
|
||||
engine := gin.New()
|
||||
Init(engine)
|
||||
|
||||
zipPath := writePluginZip(t, map[string]string{
|
||||
"komari-plugin.json": featureManifest,
|
||||
"script.js": `function load() { console.log("hi"); }`,
|
||||
})
|
||||
if _, err := InstallZip(zipPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := SetEnabled("feat", true, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := Delete("feat"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if infos := List(); len(infos) != 0 {
|
||||
t.Fatalf("List = %+v", infos)
|
||||
}
|
||||
if _, err := Manifest("feat"); err == nil {
|
||||
t.Fatal("manifest should be gone")
|
||||
}
|
||||
if err := Delete("feat"); err == nil {
|
||||
t.Fatal("second delete should fail")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCronRunsOnScheduleAndStopsOnUnload 验证 server.cron 按表达式在插件事件
|
||||
// 循环上执行 handler,且卸载后任务被移除、不再触发。
|
||||
func TestCronRunsOnScheduleAndStopsOnUnload(t *testing.T) {
|
||||
withTempDataDir(t)
|
||||
gin.SetMode(gin.TestMode)
|
||||
Init(gin.New())
|
||||
|
||||
zipPath := writePluginZip(t, map[string]string{
|
||||
"komari-plugin.json": `{"name":"Cron","short":"cron","version":"1.0.0","permissions":{"node":true,"timeout":5}}`,
|
||||
"script.js": `
|
||||
const fs = require("fs");
|
||||
const server = require("server");
|
||||
function load() {
|
||||
server.cron("@every 100ms", () => {
|
||||
fs.appendFileSync("tick.txt", "x");
|
||||
});
|
||||
}
|
||||
`,
|
||||
})
|
||||
if _, err := InstallZip(zipPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := SetEnabled("cron", true, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
tickFile := filepath.Join(DataDir, "cron", "tick.txt")
|
||||
waitFor := func(minTicks int) int {
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if data, err := os.ReadFile(tickFile); err == nil && len(data) >= minTicks {
|
||||
return len(data)
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("cron did not fire within 5s (need at least %d ticks)", minTicks)
|
||||
return 0
|
||||
}
|
||||
if ticks := waitFor(3); ticks == 0 {
|
||||
t.Fatal("cron fired but wrote no ticks")
|
||||
}
|
||||
|
||||
// 卸载后任务被移除:两个采样窗口之间的计数不得再增长。
|
||||
if err := SetEnabled("cron", false, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
first, _ := os.ReadFile(tickFile)
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
second, _ := os.ReadFile(tickFile)
|
||||
if len(second) != len(first) {
|
||||
t.Fatalf("cron kept firing after unload: %d -> %d", len(first), len(second))
|
||||
}
|
||||
}
|
||||
|
||||
// TestCronInvalidSpecFailsLoad 验证非法 cron 表达式使插件加载失败并自动禁用。
|
||||
func TestCronInvalidSpecFailsLoad(t *testing.T) {
|
||||
withTempDataDir(t)
|
||||
gin.SetMode(gin.TestMode)
|
||||
Init(gin.New())
|
||||
|
||||
zipPath := writePluginZip(t, map[string]string{
|
||||
"komari-plugin.json": `{"name":"Cron","short":"cron","version":"1.0.0","permissions":{"node":true,"timeout":5}}`,
|
||||
"script.js": `
|
||||
const server = require("server");
|
||||
function load() {
|
||||
server.cron("not a cron spec", () => {});
|
||||
}
|
||||
`,
|
||||
})
|
||||
if _, err := InstallZip(zipPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := SetEnabled("cron", true, true); err == nil {
|
||||
t.Fatal("invalid cron spec must fail the load")
|
||||
}
|
||||
infos := List()
|
||||
if len(infos) != 1 || infos[0].Enabled || !strings.Contains(infos[0].LastError, "invalid") {
|
||||
t.Fatalf("state after failed load = %+v", infos)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigurationSaveAndGet(t *testing.T) {
|
||||
withTempDataDir(t)
|
||||
Init(gin.New())
|
||||
|
||||
if err := SaveConfiguration("feat", map[string]any{"greeting": "hello", "n": float64(3)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
values, err := GetConfiguration("feat")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if values["greeting"] != "hello" || values["n"] != float64(3) {
|
||||
t.Fatalf("values = %v", values)
|
||||
}
|
||||
// 覆盖保存
|
||||
if err := SaveConfiguration("feat", map[string]any{"greeting": "bye"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
values, err = GetConfiguration("feat")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if values["greeting"] != "bye" || len(values) != 1 {
|
||||
t.Fatalf("values = %v", values)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetConfigurationMergesManifestDefaults 验证与主题一致的行为:未保存的
|
||||
// key 返回 manifest 声明的默认值(select 取首个选项、number->0、switch->false、
|
||||
// string->""),已保存的 key 保留保存值。
|
||||
func TestGetConfigurationMergesManifestDefaults(t *testing.T) {
|
||||
withTempDataDir(t)
|
||||
zipPath := writePluginZip(t, map[string]string{
|
||||
"komari-plugin.json": `{"name":"Cfg","short":"cfg","version":"1.0.0","configuration":{"type":"managed","data":[
|
||||
{"key":"greeting","name":"Greeting","type":"string","default":"Hello"},
|
||||
{"key":"count","name":"Count","type":"number"},
|
||||
{"key":"enabled","name":"Enabled","type":"switch","default":true},
|
||||
{"key":"mode","name":"Mode","type":"select","options":"json,text"},
|
||||
{"key":"note","name":"Note","type":"string"}
|
||||
]}}`,
|
||||
"script.js": `function load() {}`,
|
||||
})
|
||||
if _, err := InstallZip(zipPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 未保存任何值时,全部返回默认值。
|
||||
values, err := GetConfiguration("cfg")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if values["greeting"] != "Hello" {
|
||||
t.Errorf("greeting default = %v", values["greeting"])
|
||||
}
|
||||
if values["count"] != float64(0) {
|
||||
t.Errorf("count default = %v", values["count"])
|
||||
}
|
||||
if values["enabled"] != true {
|
||||
t.Errorf("enabled default = %v", values["enabled"])
|
||||
}
|
||||
if values["mode"] != "json" {
|
||||
t.Errorf("mode default = %v", values["mode"])
|
||||
}
|
||||
if values["note"] != "" {
|
||||
t.Errorf("note default = %v", values["note"])
|
||||
}
|
||||
|
||||
// 保存部分值:缺失 key 仍回退默认值,已保存 key 不被默认覆盖。
|
||||
if err := SaveConfiguration("cfg", map[string]any{"greeting": "saved"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
values, err = GetConfiguration("cfg")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if values["greeting"] != "saved" {
|
||||
t.Errorf("greeting saved = %v", values["greeting"])
|
||||
}
|
||||
if values["mode"] != "json" {
|
||||
t.Errorf("mode still default = %v", values["mode"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetConfigurationResolvesSelectorValues(t *testing.T) {
|
||||
withTempDataDir(t)
|
||||
if err := dbcore.GetDBInstance().Create(&models.Client{UUID: "node-a"}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := dbcore.GetDBInstance().Create(&models.PingTask{Id: 8}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
zipPath := writePluginZip(t, map[string]string{
|
||||
"komari-plugin.json": `{"name":"Cfg","short":"cfg","version":"1.0.0","configuration":{"type":"managed","data":[
|
||||
{"key":"nodes","name":"Nodes","type":"nodes"},
|
||||
{"key":"tasks","name":"Tasks","type":"pingtasks"}
|
||||
]}}`,
|
||||
"script.js": `function load() {}`,
|
||||
})
|
||||
if _, err := InstallZip(zipPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := SaveConfiguration("cfg", map[string]any{
|
||||
"nodes": `["node-a","deleted-node"]`,
|
||||
"tasks": `[8,7]`,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
values, err := GetConfiguration("cfg")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
nodes, ok := values["nodes"].([]string)
|
||||
if !ok || len(nodes) != 1 || nodes[0] != "node-a" {
|
||||
t.Fatalf("nodes = %#v", values["nodes"])
|
||||
}
|
||||
tasks, ok := values["tasks"].([]uint)
|
||||
if !ok || len(tasks) != 1 || tasks[0] != 8 {
|
||||
t.Fatalf("tasks = %#v", values["tasks"])
|
||||
}
|
||||
|
||||
var stored models.PluginConfiguration
|
||||
if err := dbcore.GetDBInstance().Where("short = ?", "cfg").First(&stored).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var raw map[string]any
|
||||
if err := json.Unmarshal([]byte(stored.Data), &raw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if raw["nodes"] != `["node-a","deleted-node"]` || raw["tasks"] != `[8,7]` {
|
||||
t.Fatalf("stored = %#v", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetConfigFromScript(t *testing.T) {
|
||||
withTempDataDir(t)
|
||||
gin.SetMode(gin.TestMode)
|
||||
Init(gin.New())
|
||||
|
||||
if err := SaveConfiguration("feat", map[string]any{"greeting": "saved-value"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
zipPath := writePluginZip(t, map[string]string{
|
||||
"komari-plugin.json": featureManifest,
|
||||
"script.js": `
|
||||
const server = require("server");
|
||||
function load() {
|
||||
server.route("GET", "/cfg", async (req, res) => {
|
||||
const cfg = await server.getConfig();
|
||||
res.end(JSON.stringify(cfg));
|
||||
});
|
||||
}
|
||||
`,
|
||||
})
|
||||
if _, err := InstallZip(zipPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := SetEnabled("feat", true, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = SetEnabled("feat", false, false) }()
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
engine := global.engine
|
||||
engine.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/cfg", nil))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var body map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil || body["greeting"] != "saved-value" {
|
||||
t.Fatalf("body = %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvePublicFileScopedToEnabledPublicPages(t *testing.T) {
|
||||
withTempDataDir(t)
|
||||
zipPath := writePluginZip(t, map[string]string{
|
||||
"komari-plugin.json": `{"name":"Pub","short":"pub","version":"1.0.0","pages":[{"file":"pages/pub.html","title":"Pub","visibility":"public"},{"file":"admin.html","title":"Admin"}]}`,
|
||||
"script.js": `function load() {}`,
|
||||
"pages/pub.html": `<h1>pub</h1>`,
|
||||
"pages/pub.js": `console.log("asset")`,
|
||||
"admin.html": `<h1>admin</h1>`,
|
||||
})
|
||||
if _, err := InstallZip(zipPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 未启用:公开文件不可访问。
|
||||
if _, err := ResolvePublicFile("pub", "pages/pub.html"); err == nil {
|
||||
t.Fatal("disabled plugin public page must not resolve")
|
||||
}
|
||||
|
||||
// 启用后:页面与同目录资源可访问,admin 页面与目录外文件不可访问。
|
||||
if err := SetEnabled("pub", true, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := ResolvePublicFile("pub", "pages/pub.html"); err != nil {
|
||||
t.Fatalf("public page must resolve when enabled: %v", err)
|
||||
}
|
||||
if _, err := ResolvePublicFile("pub", "pages/pub.js"); err != nil {
|
||||
t.Fatalf("relative asset must resolve: %v", err)
|
||||
}
|
||||
for _, bad := range []string{"admin.html", "script.js", "../evil"} {
|
||||
if _, err := ResolvePublicFile("pub", bad); err == nil {
|
||||
t.Fatalf("ResolvePublicFile(%q) should fail", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
func TestResolveFileRejectsTraversal(t *testing.T) {
|
||||
withTempDataDir(t)
|
||||
zipPath := writePluginZip(t, map[string]string{
|
||||
"komari-plugin.json": featureManifest,
|
||||
"script.js": `function load() {}`,
|
||||
"pages/hello.html": `<h1>hello</h1>`,
|
||||
})
|
||||
if _, err := InstallZip(zipPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
full, err := ResolveFile("feat", "pages/hello.html")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.HasSuffix(full, "hello.html") {
|
||||
t.Fatalf("path = %q", full)
|
||||
}
|
||||
for _, bad := range []string{"../evil", "/abs", "..", "pages/../evil"} {
|
||||
if _, err := ResolveFile("feat", bad); err == nil {
|
||||
t.Fatalf("ResolveFile(%q) should fail", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouteRequestContextCarriesIdentity(t *testing.T) {
|
||||
withTempDataDir(t)
|
||||
gin.SetMode(gin.TestMode)
|
||||
engine := gin.New()
|
||||
// 模拟 IdentityMiddleware:写入 principal/role/uuid(与 web/api 一致)
|
||||
engine.Use(func(c *gin.Context) {
|
||||
c.Set("principal", rpc.NewUserPrincipal("user-1"))
|
||||
c.Set("role", "admin")
|
||||
c.Set("uuid", "user-1")
|
||||
c.Next()
|
||||
})
|
||||
Init(engine)
|
||||
|
||||
zipPath := writePluginZip(t, map[string]string{
|
||||
"komari-plugin.json": featureManifest,
|
||||
"script.js": `
|
||||
const server = require("server");
|
||||
function load() {
|
||||
server.route("GET", "/who", (req, res) => {
|
||||
res.end(JSON.stringify(req.context));
|
||||
});
|
||||
}
|
||||
`,
|
||||
})
|
||||
if _, err := InstallZip(zipPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := SetEnabled("feat", true, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = SetEnabled("feat", false, false) }()
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/who", nil)
|
||||
req.RemoteAddr = "203.0.113.9:1234"
|
||||
engine.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var body map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("body = %s", rec.Body.String())
|
||||
}
|
||||
principal, ok := body["principal"].(map[string]any)
|
||||
if !ok || principal["type"] != "user" || principal["user_uuid"] != "user-1" {
|
||||
t.Fatalf("principal = %v", body["principal"])
|
||||
}
|
||||
if body["role"] != "admin" || body["user_uuid"] != "user-1" {
|
||||
t.Fatalf("context = %v", body)
|
||||
}
|
||||
if body["remote_ip"] != "203.0.113.9" {
|
||||
t.Fatalf("remote_ip = %v", body["remote_ip"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestHookRequestContextHasNetworkOnly(t *testing.T) {
|
||||
withTempDataDir(t)
|
||||
gin.SetMode(gin.TestMode)
|
||||
engine := gin.New()
|
||||
engine.GET("/ping", func(c *gin.Context) { c.String(http.StatusOK, "pong") })
|
||||
Init(engine)
|
||||
|
||||
zipPath := writePluginZip(t, map[string]string{
|
||||
"komari-plugin.json": featureManifest,
|
||||
"script.js": `
|
||||
const server = require("server");
|
||||
function load() {
|
||||
server.hook("request", (req) => {
|
||||
req.headers["x-ip"] = req.context.remote_ip;
|
||||
});
|
||||
}
|
||||
`,
|
||||
})
|
||||
if _, err := InstallZip(zipPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := SetEnabled("feat", true, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = SetEnabled("feat", false, false) }()
|
||||
|
||||
engine.GET("/check", func(c *gin.Context) {
|
||||
if c.GetHeader("x-ip") != "198.51.100.7" {
|
||||
c.String(http.StatusBadRequest, "bad ip: "+c.GetHeader("x-ip"))
|
||||
return
|
||||
}
|
||||
c.String(http.StatusOK, "ok")
|
||||
})
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/check", nil)
|
||||
req.RemoteAddr = "198.51.100.7:4567"
|
||||
WrapHandler(engine).ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestServerModulePermissionEnforcement 验证 server 模块的权限门:
|
||||
// server.route/server.hook 缺权限时加载失败;server.call 缺权限时 Promise
|
||||
// reject;registerRPC/getConfig 始终可用;无危险权限的插件无需批准即可启用。
|
||||
func TestServerModulePermissionEnforcement(t *testing.T) {
|
||||
withTempDataDir(t)
|
||||
gin.SetMode(gin.TestMode)
|
||||
engine := gin.New()
|
||||
Init(engine)
|
||||
|
||||
// 无危险权限:直接启用,不要求批准。
|
||||
zipPath := writePluginZip(t, map[string]string{
|
||||
"komari-plugin.json": `{"name":"Plain","short":"plain","version":"1.0.0","permissions":{"node":true,"timeout":5}}`,
|
||||
"script.js": `const server = require("server"); function load() { server.registerRPC("plugin:plain", () => ({ok:true})); }`,
|
||||
})
|
||||
if _, err := InstallZip(zipPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := SetEnabled("plain", true, false); err != nil {
|
||||
t.Fatalf("plain plugin without dangerous permissions must enable without approval: %v", err)
|
||||
}
|
||||
defer func() { _ = SetEnabled("plain", false, false) }()
|
||||
resp := rpc.CallWithContext(context.Background(), nil, "plugin:plain", nil)
|
||||
if resp.Error != nil {
|
||||
t.Fatalf("default-granted registerRPC failed: %v", resp.Error)
|
||||
}
|
||||
|
||||
// server.route 缺 allowRoutes:加载失败并提示权限名。
|
||||
zipPath = writePluginZip(t, map[string]string{
|
||||
"komari-plugin.json": `{"name":"NoRoute","short":"noroute","version":"1.0.0"}`,
|
||||
"script.js": `const server = require("server");
|
||||
function load() { server.route("GET", "/x", (req, res) => res.end("x")); }`,
|
||||
})
|
||||
if _, err := InstallZip(zipPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := SetEnabled("noroute", true, true); err == nil || !strings.Contains(err.Error(), "allowRoutes") {
|
||||
t.Fatalf("server.route without allowRoutes must fail loading: %v", err)
|
||||
}
|
||||
|
||||
// server.hook 缺 allowHooks:加载失败并提示权限名。
|
||||
zipPath = writePluginZip(t, map[string]string{
|
||||
"komari-plugin.json": `{"name":"NoHook","short":"nohook","version":"1.0.0"}`,
|
||||
"script.js": `const server = require("server");
|
||||
function load() { server.hook("request", (req) => {}); }`,
|
||||
})
|
||||
if _, err := InstallZip(zipPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := SetEnabled("nohook", true, true); err == nil || !strings.Contains(err.Error(), "allowHooks") {
|
||||
t.Fatalf("server.hook without allowHooks must fail loading: %v", err)
|
||||
}
|
||||
|
||||
// server.call 缺 allowSystemRPC:路由可用但 Promise reject。
|
||||
zipPath = writePluginZip(t, map[string]string{
|
||||
"komari-plugin.json": `{"name":"NoRPC","short":"norpc","version":"1.0.0","permissions":{"allowRoutes":true}}`,
|
||||
"script.js": `const server = require("server");
|
||||
function load() {
|
||||
server.route("GET", "/call", async (req, res) => {
|
||||
try {
|
||||
await server.call("common:getVersion");
|
||||
res.end("ok");
|
||||
} catch (e) {
|
||||
res.end("denied:" + e.message);
|
||||
}
|
||||
});
|
||||
}`,
|
||||
})
|
||||
if _, err := InstallZip(zipPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := SetEnabled("norpc", true, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = SetEnabled("norpc", false, false) }()
|
||||
rec := httptest.NewRecorder()
|
||||
engine.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/call", nil))
|
||||
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "allowSystemRPC") {
|
||||
t.Fatalf("server.call without allowSystemRPC must reject: status = %d, body = %q", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestHookFiltering 验证 server.hook(kind, matcher, fn) 的路径/方法过滤:
|
||||
// 不匹配的请求完全跳过 hook 链(不做 body 缓冲),匹配的请求按规则执行。
|
||||
func TestHookFiltering(t *testing.T) {
|
||||
withTempDataDir(t)
|
||||
gin.SetMode(gin.TestMode)
|
||||
engine := gin.New()
|
||||
check := func(name, header string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if c.GetHeader(header) == "" {
|
||||
c.String(http.StatusBadRequest, "missing "+header)
|
||||
return
|
||||
}
|
||||
c.String(http.StatusOK, name)
|
||||
}
|
||||
}
|
||||
engine.GET("/ping", func(c *gin.Context) { c.String(http.StatusOK, "no-hook") })
|
||||
engine.GET("/hooked", check("hooked", "x-hooked"))
|
||||
engine.GET("/hooked/sub", check("hooked", "x-hooked"))
|
||||
engine.GET("/hookedx", func(c *gin.Context) { c.String(http.StatusOK, "no-hook") })
|
||||
engine.POST("/exact", check("exact", "x-exact"))
|
||||
engine.GET("/exact", func(c *gin.Context) { c.String(http.StatusOK, "no-hook") })
|
||||
engine.GET("/prefix/sub", check("prefix", "x-prefix"))
|
||||
engine.GET("/prefixx", func(c *gin.Context) { c.String(http.StatusOK, "no-hook") })
|
||||
engine.GET("/resp/a", func(c *gin.Context) { c.String(http.StatusOK, "r") })
|
||||
Init(engine)
|
||||
|
||||
zipPath := writePluginZip(t, map[string]string{
|
||||
"komari-plugin.json": featureManifest,
|
||||
"script.js": `
|
||||
const server = require("server");
|
||||
function load() {
|
||||
server.hook("request", "/hooked/*", (req) => { req.headers["x-hooked"] = "yes"; });
|
||||
server.hook("request", "POST /exact", (req) => { req.headers["x-exact"] = "yes"; });
|
||||
server.hook("request", "/prefix/*", (req) => { req.headers["x-prefix"] = "yes"; });
|
||||
server.hook("response", "/resp/*", (req, res) => { res.body = res.body + "|resp"; });
|
||||
}
|
||||
`,
|
||||
})
|
||||
if _, err := InstallZip(zipPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := SetEnabled("feat", true, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = SetEnabled("feat", false, false) }()
|
||||
wrapped := WrapHandler(engine)
|
||||
|
||||
serve := func(method, path string) *httptest.ResponseRecorder {
|
||||
rec := httptest.NewRecorder()
|
||||
wrapped.ServeHTTP(rec, httptest.NewRequest(method, path, nil))
|
||||
return rec
|
||||
}
|
||||
assert := func(method, path, want string) {
|
||||
rec := serve(method, path)
|
||||
if rec.Code != http.StatusOK || rec.Body.String() != want {
|
||||
t.Fatalf("%s %s = %d %q, want %q", method, path, rec.Code, rec.Body.String(), want)
|
||||
}
|
||||
}
|
||||
assert(http.MethodGet, "/ping", "no-hook")
|
||||
assert(http.MethodGet, "/hooked", "hooked")
|
||||
assert(http.MethodGet, "/hooked/sub", "hooked")
|
||||
assert(http.MethodGet, "/hookedx", "no-hook")
|
||||
assert(http.MethodPost, "/exact", "exact")
|
||||
assert(http.MethodGet, "/exact", "no-hook")
|
||||
assert(http.MethodGet, "/prefix/sub", "prefix")
|
||||
assert(http.MethodGet, "/prefixx", "no-hook")
|
||||
assert(http.MethodGet, "/resp/a", "r|resp")
|
||||
assert(http.MethodGet, "/ping", "no-hook")
|
||||
}
|
||||
|
||||
// TestHookResponseContentLength 验证响应钩子改写 body 后不会留下失效的
|
||||
// Content-Length(否则客户端会截断/挂起);未改 body 时保留原长度。
|
||||
func TestHookResponseContentLength(t *testing.T) {
|
||||
withTempDataDir(t)
|
||||
gin.SetMode(gin.TestMode)
|
||||
engine := gin.New()
|
||||
engine.GET("/changed", func(c *gin.Context) {
|
||||
c.Header("Content-Length", strconv.Itoa(len("abcdef")))
|
||||
c.String(http.StatusOK, "abcdef")
|
||||
})
|
||||
engine.GET("/kept", func(c *gin.Context) {
|
||||
c.Header("Content-Length", strconv.Itoa(len("abcdef")))
|
||||
c.String(http.StatusOK, "abcdef")
|
||||
})
|
||||
Init(engine)
|
||||
|
||||
zipPath := writePluginZip(t, map[string]string{
|
||||
"komari-plugin.json": featureManifest,
|
||||
"script.js": `
|
||||
const server = require("server");
|
||||
function load() {
|
||||
server.hook("response", (req, res) => {
|
||||
if (req.url === "/changed") { res.body = "modified body"; }
|
||||
});
|
||||
}
|
||||
`,
|
||||
})
|
||||
if _, err := InstallZip(zipPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := SetEnabled("feat", true, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = SetEnabled("feat", false, false) }()
|
||||
wrapped := WrapHandler(engine)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
wrapped.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/changed", nil))
|
||||
if rec.Body.String() != "modified body" {
|
||||
t.Fatalf("body = %q", rec.Body.String())
|
||||
}
|
||||
if cl := rec.Header().Get("Content-Length"); cl != "" {
|
||||
t.Fatalf("stale Content-Length %q kept after body rewrite", cl)
|
||||
}
|
||||
|
||||
rec2 := httptest.NewRecorder()
|
||||
wrapped.ServeHTTP(rec2, httptest.NewRequest(http.MethodGet, "/kept", nil))
|
||||
if rec2.Body.String() != "abcdef" || rec2.Header().Get("Content-Length") != "6" {
|
||||
t.Fatalf("unmodified body: len header = %q, body = %q", rec2.Header().Get("Content-Length"), rec2.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestHookBodyLimit 验证请求钩子按插件声明的 maxHTTPBodyBytes 读取请求体:
|
||||
// 超过限制的请求返回 413,而非沿用全局默认 32 MiB。
|
||||
func TestHookBodyLimit(t *testing.T) {
|
||||
withTempDataDir(t)
|
||||
gin.SetMode(gin.TestMode)
|
||||
engine := gin.New()
|
||||
engine.POST("/echo", func(c *gin.Context) {
|
||||
if c.GetHeader("x-hooked") != "yes" {
|
||||
c.String(http.StatusBadRequest, "missing x-hooked")
|
||||
return
|
||||
}
|
||||
c.String(http.StatusOK, "ok")
|
||||
})
|
||||
Init(engine)
|
||||
|
||||
zipPath := writePluginZip(t, map[string]string{
|
||||
"komari-plugin.json": `{"name":"Limit","short":"limit","version":"1.0.0","permissions":{"allowHooks":true,"maxHTTPBodyBytes":256,"timeout":5}}`,
|
||||
"script.js": `
|
||||
const server = require("server");
|
||||
function load() {
|
||||
server.hook("request", (req) => { req.headers["x-hooked"] = "yes"; });
|
||||
}
|
||||
`,
|
||||
})
|
||||
if _, err := InstallZip(zipPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := SetEnabled("limit", true, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = SetEnabled("limit", false, false) }()
|
||||
wrapped := WrapHandler(engine)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
wrapped.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/echo", strings.NewReader(strings.Repeat("x", 128))))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("small body = %d, want 200", rec.Code)
|
||||
}
|
||||
rec2 := httptest.NewRecorder()
|
||||
wrapped.ServeHTTP(rec2, httptest.NewRequest(http.MethodPost, "/echo", strings.NewReader(strings.Repeat("x", 1024))))
|
||||
if rec2.Code != http.StatusRequestEntityTooLarge {
|
||||
t.Fatalf("large body = %d %q, want 413", rec2.Code, rec2.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,605 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/Calcium-Ion/moejs"
|
||||
"github.com/Calcium-Ion/moejs/engine"
|
||||
"github.com/komari-monitor/komari/pkg/jsruntime"
|
||||
"github.com/komari-monitor/komari/pkg/jsruntime/httpbody"
|
||||
"github.com/komari-monitor/komari/pkg/jsruntime/native"
|
||||
)
|
||||
|
||||
// hookKind identifies the HTTP hook phase.
|
||||
type hookKind string
|
||||
|
||||
const (
|
||||
hookRequest hookKind = "request"
|
||||
hookResponse hookKind = "response"
|
||||
)
|
||||
|
||||
// hookEntry is one registered JavaScript hook.
|
||||
type hookEntry struct {
|
||||
short string
|
||||
kind hookKind
|
||||
fn moejs.Value
|
||||
host *jsruntime.Host
|
||||
matcher *hookMatcher // nil matches every request
|
||||
bodyLimit int64 // plugin-declared maxHTTPBodyBytes (0 = default)
|
||||
}
|
||||
|
||||
// hookMatcher optionally restricts a hook to matching requests. A nil
|
||||
// matcher matches every request.
|
||||
type hookMatcher struct {
|
||||
method string // optional HTTP method; "" matches any
|
||||
path string // cleaned request path, lowercase
|
||||
prefix bool // subtree match when the pattern ended with "*"
|
||||
}
|
||||
|
||||
// parseHookMatcher parses a server.hook path filter: "POST /api/foo",
|
||||
// "/api/foo" or "/api/foo/*". The optional leading HTTP method restricts the
|
||||
// hook to one request method; a trailing "*" matches the path and its whole
|
||||
// subtree; any other pattern is an exact path match.
|
||||
func parseHookMatcher(pattern string) (*hookMatcher, error) {
|
||||
pattern = strings.TrimSpace(pattern)
|
||||
if pattern == "" {
|
||||
return nil, errors.New("hook path must not be empty")
|
||||
}
|
||||
m := &hookMatcher{}
|
||||
if method, rest, ok := strings.Cut(pattern, " "); ok {
|
||||
method = strings.ToUpper(strings.TrimSpace(method))
|
||||
if isHTTPMethod(method) {
|
||||
m.method = method
|
||||
pattern = strings.TrimSpace(rest)
|
||||
}
|
||||
}
|
||||
if !strings.HasPrefix(pattern, "/") {
|
||||
return nil, fmt.Errorf("hook path %q must start with /", pattern)
|
||||
}
|
||||
if strings.HasSuffix(pattern, "*") {
|
||||
m.prefix = true
|
||||
pattern = strings.TrimSuffix(pattern, "*")
|
||||
}
|
||||
pattern = strings.TrimRight(pattern, "/")
|
||||
if pattern == "" {
|
||||
if m.prefix {
|
||||
return m, nil // "/*" matches every path
|
||||
}
|
||||
return nil, fmt.Errorf("hook path %q is invalid", pattern)
|
||||
}
|
||||
if !filepath.IsLocal(strings.TrimPrefix(pattern, "/")) {
|
||||
return nil, fmt.Errorf("hook path %q contains invalid segments", pattern)
|
||||
}
|
||||
m.path = strings.ToLower(pattern)
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// matchesPath reports whether a request path matches the filter. A nil
|
||||
// matcher matches everything.
|
||||
func (m *hookMatcher) matchesPath(path string) bool {
|
||||
if m == nil {
|
||||
return true
|
||||
}
|
||||
if path == "" {
|
||||
path = "/"
|
||||
}
|
||||
path = strings.ToLower(path)
|
||||
if m.prefix {
|
||||
return path == m.path || strings.HasPrefix(path, m.path+"/")
|
||||
}
|
||||
return path == m.path
|
||||
}
|
||||
|
||||
// matches reports whether the request matches the filter. A nil matcher
|
||||
// matches everything.
|
||||
func (m *hookMatcher) matches(r *http.Request) bool {
|
||||
if m == nil {
|
||||
return true
|
||||
}
|
||||
if m.method != "" && r.Method != m.method {
|
||||
return false
|
||||
}
|
||||
return m.matchesPath(r.URL.Path)
|
||||
}
|
||||
|
||||
// isHTTPMethod reports whether s is a known HTTP request method.
|
||||
func isHTTPMethod(s string) bool {
|
||||
switch s {
|
||||
case http.MethodGet, http.MethodHead, http.MethodPost, http.MethodPut,
|
||||
http.MethodPatch, http.MethodDelete, http.MethodOptions:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// matches reports whether the hook applies to the request: no matcher, or a
|
||||
// matching filter.
|
||||
func (h *hookEntry) matches(r *http.Request) bool {
|
||||
return h.matcher == nil || h.matcher.matches(r)
|
||||
}
|
||||
|
||||
// filterHooks drops the hooks whose matcher does not match the request.
|
||||
func filterHooks(hooks []*hookEntry, r *http.Request) []*hookEntry {
|
||||
kept := hooks[:0]
|
||||
for _, hook := range hooks {
|
||||
if hook.matches(r) {
|
||||
kept = append(kept, hook)
|
||||
}
|
||||
}
|
||||
return kept
|
||||
}
|
||||
|
||||
// maxHookBodyLimit returns the body read limit for the request hooks that
|
||||
// will run: the largest plugin-declared maxHTTPBodyBytes, or the default
|
||||
// when no hook declares a limit.
|
||||
func maxHookBodyLimit(hooks []*hookEntry) int64 {
|
||||
var limit int64
|
||||
for _, hook := range hooks {
|
||||
if hook.bodyLimit > limit {
|
||||
limit = hook.bodyLimit
|
||||
}
|
||||
}
|
||||
if limit < 1 {
|
||||
return defaultMaxHTTPBodyBytes
|
||||
}
|
||||
return limit
|
||||
}
|
||||
|
||||
// wrapHandler installs the hook chain around the application handler. WebSocket
|
||||
// upgrades pass through untouched because the response cannot be buffered.
|
||||
// Hooks with a path/method filter only run for matching requests; unfiltered
|
||||
// requests skip both the hook chain and its body buffering.
|
||||
func (m *Manager) wrapHandler(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if isUpgradeRequest(r) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
reqHooks := filterHooks(m.hooksOf(hookRequest), r)
|
||||
respHooks := filterHooks(m.hooksOf(hookResponse), r)
|
||||
if len(reqHooks) == 0 && len(respHooks) == 0 {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
req := r
|
||||
if len(reqHooks) > 0 {
|
||||
body, err := httpbody.ReadAll(r.Body, maxHookBodyLimit(reqHooks))
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusRequestEntityTooLarge)
|
||||
return
|
||||
}
|
||||
for _, hook := range reqHooks {
|
||||
body, err = m.runRequestHook(req, body, hook)
|
||||
if err != nil {
|
||||
m.logHookError(hook, err)
|
||||
http.Error(w, "plugin request hook failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
req = requestWithBody(req, body)
|
||||
}
|
||||
|
||||
if len(respHooks) == 0 {
|
||||
next.ServeHTTP(w, req)
|
||||
return
|
||||
}
|
||||
bw := newBufferedResponseWriter(w)
|
||||
next.ServeHTTP(bw, req)
|
||||
for _, hook := range respHooks {
|
||||
if err := m.runResponseHook(req, bw, hook); err != nil {
|
||||
m.logHookError(hook, err)
|
||||
}
|
||||
}
|
||||
bw.flushTo(w)
|
||||
})
|
||||
}
|
||||
|
||||
// runRequestHook runs one request hook on its plugin event loop, applies the
|
||||
// JS-side modifications to the request, and returns the (possibly replaced)
|
||||
// request body.
|
||||
func (m *Manager) runRequestHook(r *http.Request, body []byte, h *hookEntry) ([]byte, error) {
|
||||
nextBody := body
|
||||
var hookErr error
|
||||
queued, timedOut := runHookTurn(h.host, "plugin request hook "+h.short, func(vm *moejs.Runtime) {
|
||||
reqObj := hookRequestObject(vm, r, nextBody)
|
||||
hookErr = h.host.RunJob(vm, "plugin request hook "+h.short, func() error {
|
||||
_, err := vm.CallFunction(h.fn, moejs.Undefined(), engine.ObjectValue(reqObj))
|
||||
return err
|
||||
})
|
||||
if hookErr != nil {
|
||||
return
|
||||
}
|
||||
if value := native.Get(vm, reqObj, "method"); !value.IsUndefined() {
|
||||
if method := strings.ToUpper(strings.TrimSpace(native.String(vm, value))); method != "" {
|
||||
r.Method = method
|
||||
}
|
||||
}
|
||||
if value := native.Get(vm, reqObj, "url"); !value.IsUndefined() {
|
||||
if raw := strings.TrimSpace(native.String(vm, value)); raw != "" {
|
||||
if parsed, err := url.Parse(raw); err == nil {
|
||||
r.URL = parsed
|
||||
r.RequestURI = raw
|
||||
}
|
||||
}
|
||||
}
|
||||
if value := native.Get(vm, reqObj, "headers"); !value.IsUndefined() {
|
||||
var raw map[string]any
|
||||
if err := native.ExportTo(vm, value, &raw); err == nil {
|
||||
r.Header = normalizeHeader(raw)
|
||||
}
|
||||
}
|
||||
if value := native.Get(vm, reqObj, "body"); !value.IsUndefined() {
|
||||
nextBody = []byte(native.String(vm, value))
|
||||
}
|
||||
})
|
||||
if !queued {
|
||||
return body, errors.New("plugin runtime is closed")
|
||||
}
|
||||
if timedOut {
|
||||
return body, errors.New("plugin request hook timed out")
|
||||
}
|
||||
return nextBody, hookErr
|
||||
}
|
||||
|
||||
// runResponseHook runs one response hook on its plugin event loop and applies
|
||||
// the JS-side modifications to the captured response.
|
||||
func (m *Manager) runResponseHook(r *http.Request, bw *bufferedResponseWriter, h *hookEntry) error {
|
||||
var hookErr error
|
||||
queued, timedOut := runHookTurn(h.host, "plugin response hook "+h.short, func(vm *moejs.Runtime) {
|
||||
resObj := hookResponseObject(vm, bw)
|
||||
hookErr = h.host.RunJob(vm, "plugin response hook "+h.short, func() error {
|
||||
_, err := vm.CallFunction(h.fn, moejs.Undefined(), engine.ObjectValue(hookRequestObject(vm, r, nil)), engine.ObjectValue(resObj))
|
||||
return err
|
||||
})
|
||||
if hookErr != nil {
|
||||
return
|
||||
}
|
||||
status := bw.status()
|
||||
if value := native.Get(vm, resObj, "statusCode"); !value.IsUndefined() {
|
||||
status = int(native.Int(vm, value))
|
||||
}
|
||||
header := bw.Header().Clone()
|
||||
if value := native.Get(vm, resObj, "headers"); !value.IsUndefined() {
|
||||
var raw map[string]any
|
||||
if err := native.ExportTo(vm, value, &raw); err == nil {
|
||||
header = normalizeHeader(raw)
|
||||
}
|
||||
}
|
||||
responseBody := bw.bodyBytes()
|
||||
if value := native.Get(vm, resObj, "body"); !value.IsUndefined() {
|
||||
responseBody = []byte(native.String(vm, value))
|
||||
}
|
||||
bw.apply(status, header, responseBody)
|
||||
})
|
||||
if !queued {
|
||||
return errors.New("plugin runtime is closed")
|
||||
}
|
||||
if timedOut {
|
||||
return errors.New("plugin response hook timed out")
|
||||
}
|
||||
if bw.passedThrough() {
|
||||
_, _ = m.logStore(h.short).Write([]byte(
|
||||
fmt.Sprintf("[plugin] response exceeded the %d-byte hook buffer limit and was sent to the client unmodified\n", maxHookBufferBytes)))
|
||||
}
|
||||
return hookErr
|
||||
}
|
||||
|
||||
// runHookTurn queues one hook callback on the plugin event loop and waits
|
||||
// for it with the runtime timeout. It reports whether the job was queued and
|
||||
// whether it timed out.
|
||||
func runHookTurn(host *jsruntime.Host, name string, job func(vm *moejs.Runtime)) (queued, timedOut bool) {
|
||||
return runHookTurnTimeout(host, name, host.Timeout(), job)
|
||||
}
|
||||
|
||||
// runHookTurnTimeout is runHookTurn with an explicit wait budget. The
|
||||
// callback keeps running on the plugin loop after a timeout; the waiter gives
|
||||
// up and the caller falls back to passing the message through.
|
||||
func runHookTurnTimeout(host *jsruntime.Host, name string, timeout time.Duration, job func(vm *moejs.Runtime)) (queued, timedOut bool) {
|
||||
done := make(chan struct{})
|
||||
if !host.RunOnLoop(func(vm *moejs.Runtime) {
|
||||
defer close(done)
|
||||
job(vm)
|
||||
}) {
|
||||
return false, false
|
||||
}
|
||||
select {
|
||||
case <-done:
|
||||
return true, false
|
||||
case <-time.After(timeout):
|
||||
return true, true
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) logHookError(h *hookEntry, err error) {
|
||||
_, _ = m.logStore(h.short).Write([]byte("[plugin] hook error: " + err.Error() + "\n"))
|
||||
}
|
||||
|
||||
// hookRequestObject builds the mutable JS request object.
|
||||
func hookRequestObject(vm *moejs.Runtime, r *http.Request, body []byte) *engine.Object {
|
||||
req := vm.Realm().NewObject()
|
||||
_ = native.Set(vm, req, "method", r.Method)
|
||||
_ = native.Set(vm, req, "url", r.URL.RequestURI())
|
||||
_ = native.Set(vm, req, "headers", headerToMap(r.Header))
|
||||
query := make(map[string]string, len(r.URL.Query()))
|
||||
for name, values := range r.URL.Query() {
|
||||
query[name] = strings.Join(values, ",")
|
||||
}
|
||||
_ = native.Set(vm, req, "query", query)
|
||||
_ = native.Set(vm, req, "body", string(body))
|
||||
_ = native.Set(vm, req, "context", hookRequestContext(vm, r))
|
||||
return req
|
||||
}
|
||||
|
||||
// hookResponseObject builds the mutable JS response object from the captured
|
||||
// response.
|
||||
func hookResponseObject(vm *moejs.Runtime, bw *bufferedResponseWriter) *engine.Object {
|
||||
res := vm.Realm().NewObject()
|
||||
_ = native.Set(vm, res, "statusCode", bw.status())
|
||||
_ = native.Set(vm, res, "statusMessage", "")
|
||||
_ = native.Set(vm, res, "headers", headerToMap(bw.Header()))
|
||||
_ = native.Set(vm, res, "body", string(bw.bodyBytes()))
|
||||
return res
|
||||
}
|
||||
|
||||
func requestWithBody(r *http.Request, body []byte) *http.Request {
|
||||
clone := r.Clone(r.Context())
|
||||
clone.Body = io.NopCloser(bytes.NewReader(body))
|
||||
clone.ContentLength = int64(len(body))
|
||||
return clone
|
||||
}
|
||||
|
||||
func normalizeHeader(raw map[string]any) http.Header {
|
||||
header := make(http.Header, len(raw))
|
||||
for name, value := range raw {
|
||||
switch values := value.(type) {
|
||||
case string:
|
||||
header.Set(name, values)
|
||||
case []any:
|
||||
for _, item := range values {
|
||||
header.Add(name, fmt.Sprint(item))
|
||||
}
|
||||
case []string:
|
||||
for _, item := range values {
|
||||
header.Add(name, item)
|
||||
}
|
||||
default:
|
||||
header.Set(name, fmt.Sprint(value))
|
||||
}
|
||||
}
|
||||
return header
|
||||
}
|
||||
|
||||
func headerToMap(header http.Header) map[string]any {
|
||||
out := make(map[string]any, len(header))
|
||||
for name, values := range header {
|
||||
if len(values) == 1 {
|
||||
out[strings.ToLower(name)] = values[0]
|
||||
} else {
|
||||
out[strings.ToLower(name)] = values
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// isUpgradeRequest reports whether r is a genuine protocol-upgrade request:
|
||||
// both Connection: upgrade and an Upgrade header must be present (RFC 9110 §7.8).
|
||||
func isUpgradeRequest(r *http.Request) bool {
|
||||
if r.Header.Get("Upgrade") == "" {
|
||||
return false
|
||||
}
|
||||
for _, token := range strings.Split(r.Header.Get("Connection"), ",") {
|
||||
if strings.EqualFold(strings.TrimSpace(token), "upgrade") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// bufferedResponseWriter captures status/headers/body so response hooks can
|
||||
// modify them before the real writer receives the final bytes.
|
||||
//
|
||||
// Streaming responses (SSE / MJPEG / chunked feeds) must not be buffered:
|
||||
// their handlers never return, so buffering would hold the client empty
|
||||
// forever and grow memory without bound. The first Flush() switches the
|
||||
// writer to passthrough mode: buffered status/headers/body are forwarded to
|
||||
// the real writer immediately, every later Write/WriteHeader goes straight
|
||||
// through, and the response hooks can no longer rewrite the stream.
|
||||
type bufferedResponseWriter struct {
|
||||
mu sync.Mutex
|
||||
header http.Header
|
||||
statusCode int
|
||||
body bytes.Buffer
|
||||
wroteHeader bool
|
||||
streaming bool // switched to passthrough after the first Flush()
|
||||
forwarded bool // status/headers already sent to the underlying writer
|
||||
hijacked bool
|
||||
limit int // maximum buffered body bytes before passthrough
|
||||
passthrough bool // buffering abandoned because the body exceeded limit
|
||||
underlying http.ResponseWriter
|
||||
}
|
||||
|
||||
func newBufferedResponseWriter(w http.ResponseWriter) *bufferedResponseWriter {
|
||||
return &bufferedResponseWriter{header: make(http.Header), limit: maxHookBufferBytes, underlying: w}
|
||||
}
|
||||
|
||||
func (b *bufferedResponseWriter) passedThrough() bool {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return b.passthrough
|
||||
}
|
||||
|
||||
func (b *bufferedResponseWriter) Header() http.Header { return b.header }
|
||||
|
||||
func (b *bufferedResponseWriter) WriteHeader(code int) {
|
||||
b.mu.Lock()
|
||||
if b.streaming {
|
||||
if b.forwarded || b.hijacked {
|
||||
b.mu.Unlock()
|
||||
return
|
||||
}
|
||||
b.forwarded = true
|
||||
status, header := code, b.header.Clone()
|
||||
b.mu.Unlock()
|
||||
b.writeThrough(status, header)
|
||||
return
|
||||
}
|
||||
if !b.wroteHeader {
|
||||
b.statusCode = code
|
||||
b.wroteHeader = true
|
||||
}
|
||||
b.mu.Unlock()
|
||||
}
|
||||
|
||||
func (b *bufferedResponseWriter) Write(p []byte) (int, error) {
|
||||
b.mu.Lock()
|
||||
if b.streaming {
|
||||
b.mu.Unlock()
|
||||
return b.underlying.Write(p)
|
||||
}
|
||||
if !b.wroteHeader {
|
||||
b.statusCode = http.StatusOK
|
||||
b.wroteHeader = true
|
||||
}
|
||||
if b.body.Len()+len(p) > b.limit {
|
||||
// Response is too large to rewrite: switch to passthrough mode and
|
||||
// forward status/headers and the buffered body to the real writer
|
||||
// immediately. Response hooks can no longer modify this response.
|
||||
status, header, buffered := b.statusCode, b.header.Clone(), b.body.Bytes()
|
||||
b.streaming = true
|
||||
b.forwarded = true
|
||||
b.passthrough = true
|
||||
b.mu.Unlock()
|
||||
b.writeThrough(status, header)
|
||||
_, _ = b.underlying.Write(buffered)
|
||||
_, _ = b.underlying.Write(p)
|
||||
return len(p), nil
|
||||
}
|
||||
_, err := b.body.Write(p)
|
||||
b.mu.Unlock()
|
||||
return len(p), err
|
||||
}
|
||||
|
||||
func (b *bufferedResponseWriter) status() int {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
if !b.wroteHeader {
|
||||
return http.StatusOK
|
||||
}
|
||||
return b.statusCode
|
||||
}
|
||||
|
||||
func (b *bufferedResponseWriter) bodyBytes() []byte {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return append([]byte(nil), b.body.Bytes()...)
|
||||
}
|
||||
|
||||
func (b *bufferedResponseWriter) apply(status int, header http.Header, body []byte) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
if b.streaming {
|
||||
return // already streamed to the client; hooks cannot rewrite it
|
||||
}
|
||||
bodyChanged := !bytes.Equal(body, b.body.Bytes())
|
||||
b.statusCode = status
|
||||
b.wroteHeader = true
|
||||
b.header = header
|
||||
b.body.Reset()
|
||||
_, _ = b.body.Write(body)
|
||||
if bodyChanged {
|
||||
// The rewritten body differs from the original response; a
|
||||
// Content-Length copied from it is now stale and would truncate or
|
||||
// hang the client. Drop it so Go recomputes the length (or falls
|
||||
// back to chunked encoding).
|
||||
b.header.Del("Content-Length")
|
||||
}
|
||||
}
|
||||
|
||||
func (b *bufferedResponseWriter) flushTo(w http.ResponseWriter) {
|
||||
b.mu.Lock()
|
||||
if b.streaming || b.hijacked {
|
||||
b.mu.Unlock()
|
||||
return // passthrough already forwarded everything
|
||||
}
|
||||
status, body, header := b.statusCode, append([]byte(nil), b.body.Bytes()...), b.header.Clone()
|
||||
b.mu.Unlock()
|
||||
for name, values := range header {
|
||||
for _, value := range values {
|
||||
w.Header().Add(name, value)
|
||||
}
|
||||
}
|
||||
w.WriteHeader(status)
|
||||
_, _ = w.Write(body)
|
||||
}
|
||||
|
||||
func (b *bufferedResponseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
|
||||
b.mu.Lock()
|
||||
b.hijacked = true
|
||||
b.mu.Unlock()
|
||||
hijacker, ok := b.underlying.(http.Hijacker)
|
||||
if !ok {
|
||||
return nil, nil, errors.New("plugin response buffer does not support hijacking")
|
||||
}
|
||||
return hijacker.Hijack()
|
||||
}
|
||||
|
||||
func (b *bufferedResponseWriter) Flush() {
|
||||
b.mu.Lock()
|
||||
if !b.streaming {
|
||||
// First Flush: switch to passthrough mode and forward the buffered
|
||||
// response to the real writer immediately.
|
||||
b.streaming = true
|
||||
status, body, header := b.statusCode, append([]byte(nil), b.body.Bytes()...), b.header.Clone()
|
||||
b.mu.Unlock()
|
||||
b.writeThrough(status, header)
|
||||
_, _ = b.underlying.Write(body)
|
||||
} else {
|
||||
b.mu.Unlock()
|
||||
}
|
||||
if flusher, ok := b.underlying.(http.Flusher); ok {
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
// writeThrough forwards status and headers to the underlying writer once.
|
||||
func (b *bufferedResponseWriter) writeThrough(status int, header http.Header) {
|
||||
for name, values := range header {
|
||||
for _, value := range values {
|
||||
b.underlying.Header().Add(name, value)
|
||||
}
|
||||
}
|
||||
b.underlying.WriteHeader(status)
|
||||
}
|
||||
|
||||
func (b *bufferedResponseWriter) ReadFrom(r io.Reader) (int64, error) {
|
||||
if !b.wroteHeader {
|
||||
b.WriteHeader(http.StatusOK)
|
||||
}
|
||||
return io.Copy(b, r)
|
||||
}
|
||||
|
||||
func (b *bufferedResponseWriter) CloseNotify() <-chan bool {
|
||||
if notifier, ok := b.underlying.(interface{ CloseNotify() <-chan bool }); ok {
|
||||
return notifier.CloseNotify()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var (
|
||||
_ http.ResponseWriter = (*bufferedResponseWriter)(nil)
|
||||
_ http.Hijacker = (*bufferedResponseWriter)(nil)
|
||||
_ http.Flusher = (*bufferedResponseWriter)(nil)
|
||||
)
|
||||
@@ -0,0 +1,277 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// injectEntry is one plugin's registered HTML fragments. head is inserted
|
||||
// before </head>, body before </body>, in every text/html response.
|
||||
type injectEntry struct {
|
||||
short string
|
||||
head string
|
||||
body string
|
||||
}
|
||||
|
||||
// htmlInjectHandler installs the HTML injection wrapper around the
|
||||
// application handler. WebSocket upgrades pass through untouched because the
|
||||
// response cannot be buffered.
|
||||
func (m *Manager) htmlInjectHandler(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if isUpgradeRequest(r) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
fragments := m.injectsSnapshot()
|
||||
if len(fragments) == 0 {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
iw := newHTMLInjectWriter(w)
|
||||
next.ServeHTTP(iw, r)
|
||||
iw.flushTo(fragments)
|
||||
})
|
||||
}
|
||||
|
||||
// injectHTMLContent inserts the plugin head/body fragments into an HTML
|
||||
// document: head before </head>, body before </body> with fallbacks to
|
||||
// </html> and the document end. Tag matching is case-insensitive.
|
||||
func injectHTMLContent(body []byte, fragments []*injectEntry) []byte {
|
||||
if len(fragments) == 0 {
|
||||
return body
|
||||
}
|
||||
var head, tail strings.Builder
|
||||
for _, fragment := range fragments {
|
||||
head.WriteString(fragment.head)
|
||||
tail.WriteString(fragment.body)
|
||||
}
|
||||
if head.Len() == 0 && tail.Len() == 0 {
|
||||
return body
|
||||
}
|
||||
text := string(body)
|
||||
headHTML, tailHTML := head.String(), tail.String()
|
||||
if headHTML != "" {
|
||||
if idx := indexFold(text, "</head>"); idx >= 0 {
|
||||
text = text[:idx] + headHTML + text[idx:]
|
||||
} else {
|
||||
text = headHTML + text
|
||||
}
|
||||
}
|
||||
if tailHTML != "" {
|
||||
switch {
|
||||
case indexFold(text, "</body>") >= 0:
|
||||
text = text[:indexFold(text, "</body>")] + tailHTML + text[indexFold(text, "</body>"):]
|
||||
case indexFold(text, "</html>") >= 0:
|
||||
text = text[:indexFold(text, "</html>")] + tailHTML + text[indexFold(text, "</html>"):]
|
||||
default:
|
||||
text += tailHTML
|
||||
}
|
||||
}
|
||||
return []byte(text)
|
||||
}
|
||||
|
||||
func indexFold(text, needle string) int {
|
||||
return strings.Index(strings.ToLower(text), needle)
|
||||
}
|
||||
|
||||
// htmlInjectWriter captures only text/html responses so the registered
|
||||
// plugin fragments can be injected before flushTo. Every other response is
|
||||
// forwarded to the real writer unmodified as soon as its content type is
|
||||
// known, so JSON, assets and streams are never buffered.
|
||||
//
|
||||
// The content type is read from the response header when set; otherwise the
|
||||
// first bytes are sniffed with http.DetectContentType. Streaming responses
|
||||
// (SSE / MJPEG / chunked feeds) switch to passthrough on the first Flush(),
|
||||
// and responses larger than the buffer limit are forwarded unmodified.
|
||||
type htmlInjectWriter struct {
|
||||
mu sync.Mutex
|
||||
header http.Header
|
||||
statusCode int
|
||||
wroteHeader bool
|
||||
body bytes.Buffer
|
||||
html bool // decided: the response is text/html and buffered
|
||||
streaming bool // switched to passthrough
|
||||
hijacked bool
|
||||
limit int
|
||||
underlying http.ResponseWriter
|
||||
}
|
||||
|
||||
func newHTMLInjectWriter(w http.ResponseWriter) *htmlInjectWriter {
|
||||
return &htmlInjectWriter{header: make(http.Header), limit: maxHTMLInjectBufferBytes, underlying: w}
|
||||
}
|
||||
|
||||
func (w *htmlInjectWriter) Header() http.Header { return w.header }
|
||||
|
||||
func (w *htmlInjectWriter) WriteHeader(code int) {
|
||||
w.mu.Lock()
|
||||
if w.streaming {
|
||||
w.mu.Unlock()
|
||||
return
|
||||
}
|
||||
if !w.wroteHeader {
|
||||
w.statusCode = code
|
||||
w.wroteHeader = true
|
||||
}
|
||||
ct := w.header.Get("Content-Type")
|
||||
w.mu.Unlock()
|
||||
if ct != "" && !isHTMLContentType(ct) {
|
||||
w.passthrough()
|
||||
}
|
||||
}
|
||||
|
||||
func (w *htmlInjectWriter) Write(p []byte) (int, error) {
|
||||
w.mu.Lock()
|
||||
if w.streaming {
|
||||
w.mu.Unlock()
|
||||
return w.underlying.Write(p)
|
||||
}
|
||||
if !w.wroteHeader {
|
||||
w.statusCode = http.StatusOK
|
||||
w.wroteHeader = true
|
||||
}
|
||||
if !w.html {
|
||||
ct := w.header.Get("Content-Type")
|
||||
if ct == "" {
|
||||
ct = sniffContentType(w.body.Bytes(), p)
|
||||
if ct != "" {
|
||||
w.header.Set("Content-Type", ct)
|
||||
}
|
||||
}
|
||||
if ct != "" {
|
||||
if !isHTMLContentType(ct) {
|
||||
w.mu.Unlock()
|
||||
w.passthrough()
|
||||
return w.underlying.Write(p)
|
||||
}
|
||||
w.html = true
|
||||
}
|
||||
}
|
||||
if w.body.Len()+len(p) > w.limit {
|
||||
w.mu.Unlock()
|
||||
w.passthrough()
|
||||
return w.underlying.Write(p)
|
||||
}
|
||||
n, err := w.body.Write(p)
|
||||
w.mu.Unlock()
|
||||
return n, err
|
||||
}
|
||||
|
||||
// passthrough forwards the buffered status/headers/body to the real writer
|
||||
// and switches every later write to direct passthrough. The injection is
|
||||
// skipped for the response.
|
||||
func (w *htmlInjectWriter) passthrough() {
|
||||
w.mu.Lock()
|
||||
if w.streaming || w.hijacked {
|
||||
w.mu.Unlock()
|
||||
return
|
||||
}
|
||||
w.streaming = true
|
||||
status, header, body := w.statusCode, w.header.Clone(), append([]byte(nil), w.body.Bytes()...)
|
||||
w.mu.Unlock()
|
||||
w.writeThrough(status, header)
|
||||
_, _ = w.underlying.Write(body)
|
||||
}
|
||||
|
||||
// flushTo finalizes a fully buffered response. HTML responses get the
|
||||
// registered fragments injected, everything else is written through
|
||||
// unmodified.
|
||||
func (w *htmlInjectWriter) flushTo(fragments []*injectEntry) {
|
||||
w.mu.Lock()
|
||||
if w.streaming || w.hijacked {
|
||||
w.mu.Unlock()
|
||||
return
|
||||
}
|
||||
w.streaming = true
|
||||
status, header, body := w.statusCode, w.header.Clone(), append([]byte(nil), w.body.Bytes()...)
|
||||
w.mu.Unlock()
|
||||
|
||||
ct := header.Get("Content-Type")
|
||||
if ct == "" {
|
||||
ct = http.DetectContentType(body)
|
||||
if isHTMLContentType(ct) {
|
||||
header.Set("Content-Type", ct)
|
||||
}
|
||||
}
|
||||
if isHTMLContentType(ct) {
|
||||
body = injectHTMLContent(body, fragments)
|
||||
header.Del("Content-Length")
|
||||
}
|
||||
w.writeThrough(status, header)
|
||||
_, _ = w.underlying.Write(body)
|
||||
}
|
||||
|
||||
func (w *htmlInjectWriter) writeThrough(status int, header http.Header) {
|
||||
for name, values := range header {
|
||||
for _, value := range values {
|
||||
w.underlying.Header().Add(name, value)
|
||||
}
|
||||
}
|
||||
w.underlying.WriteHeader(status)
|
||||
}
|
||||
|
||||
// isHTMLContentType reports whether the MIME type is HTML, with or without
|
||||
// parameters such as charset.
|
||||
func isHTMLContentType(ct string) bool {
|
||||
return strings.Contains(strings.ToLower(ct), "text/html")
|
||||
}
|
||||
|
||||
// sniffContentType decides the content type of a response from the header
|
||||
// value when available, otherwise from the first bytes via
|
||||
// http.DetectContentType. An empty result means the type is not decidable
|
||||
// yet; buffering continues.
|
||||
func sniffContentType(buffered, next []byte) string {
|
||||
const sniffLength = 512
|
||||
total := buffered
|
||||
if len(total) < sniffLength {
|
||||
room := sniffLength - len(total)
|
||||
if len(next) > room {
|
||||
next = next[:room]
|
||||
}
|
||||
total = append(append([]byte(nil), total...), next...)
|
||||
}
|
||||
return http.DetectContentType(total)
|
||||
}
|
||||
|
||||
func (w *htmlInjectWriter) ReadFrom(r io.Reader) (int64, error) {
|
||||
if !w.wroteHeader {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
return io.Copy(w, r)
|
||||
}
|
||||
|
||||
func (w *htmlInjectWriter) Flush() {
|
||||
w.passthrough()
|
||||
if flusher, ok := w.underlying.(http.Flusher); ok {
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
func (w *htmlInjectWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
|
||||
w.mu.Lock()
|
||||
w.hijacked = true
|
||||
w.mu.Unlock()
|
||||
hijacker, ok := w.underlying.(http.Hijacker)
|
||||
if !ok {
|
||||
return nil, nil, errors.New("plugin HTML injector does not support hijacking")
|
||||
}
|
||||
return hijacker.Hijack()
|
||||
}
|
||||
|
||||
func (w *htmlInjectWriter) CloseNotify() <-chan bool {
|
||||
if notifier, ok := w.underlying.(interface{ CloseNotify() <-chan bool }); ok {
|
||||
return notifier.CloseNotify()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var (
|
||||
_ http.ResponseWriter = (*htmlInjectWriter)(nil)
|
||||
_ http.Hijacker = (*htmlInjectWriter)(nil)
|
||||
_ http.Flusher = (*htmlInjectWriter)(nil)
|
||||
)
|
||||
@@ -0,0 +1,161 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
)
|
||||
|
||||
// InstallZip validates a plugin ZIP and extracts it into DataDir/<short>.
|
||||
// The archive must contain komari-plugin.json at its root. Archive limits
|
||||
// mirror the theme package format; path-traversal entries reject the whole
|
||||
// package instead of being skipped. Reinstalling over a running plugin
|
||||
// unloads it first and restores it to its persisted enabled state when the
|
||||
// extraction succeeds.
|
||||
func InstallZip(zipPath string) (models.Plugin, error) {
|
||||
var info models.Plugin
|
||||
r, err := zip.OpenReader(zipPath)
|
||||
if err != nil {
|
||||
return info, fmt.Errorf("failed to open ZIP file: %v", err)
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
if err := validatePluginArchive(r.File); err != nil {
|
||||
return info, err
|
||||
}
|
||||
|
||||
var manifest *zip.File
|
||||
for _, f := range r.File {
|
||||
if f.Name == manifestFile {
|
||||
manifest = f
|
||||
break
|
||||
}
|
||||
}
|
||||
if manifest == nil {
|
||||
return info, fmt.Errorf("plugin manifest %s not found, not a valid plugin package", manifestFile)
|
||||
}
|
||||
|
||||
rc, err := manifest.Open()
|
||||
if err != nil {
|
||||
return info, fmt.Errorf("failed to read plugin manifest: %v", err)
|
||||
}
|
||||
configData, readErr := io.ReadAll(io.LimitReader(rc, maxPluginManifestSize+1))
|
||||
_ = rc.Close()
|
||||
if readErr != nil {
|
||||
return info, fmt.Errorf("failed to read plugin manifest: %v", readErr)
|
||||
}
|
||||
if len(configData) > maxPluginManifestSize {
|
||||
return info, fmt.Errorf("plugin manifest exceeds the %d byte limit", maxPluginManifestSize)
|
||||
}
|
||||
if err := json.Unmarshal(configData, &info); err != nil {
|
||||
return info, fmt.Errorf("invalid plugin manifest: %v", err)
|
||||
}
|
||||
if err := validateManifest(&info); err != nil {
|
||||
return info, err
|
||||
}
|
||||
if err := CheckKomariVersion(info.Komari); err != nil {
|
||||
return info, err
|
||||
}
|
||||
|
||||
if err := global.unload(info.Short); err != nil && !errors.Is(err, errNotLoaded) {
|
||||
return info, fmt.Errorf("failed to unload running plugin %q before reinstall: %w", info.Short, err)
|
||||
}
|
||||
|
||||
dir := filepath.Join(DataDir, info.Short)
|
||||
if err := os.RemoveAll(dir); err != nil {
|
||||
return info, fmt.Errorf("failed to remove existing plugin directory: %v", err)
|
||||
}
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return info, fmt.Errorf("failed to create plugin directory: %v", err)
|
||||
}
|
||||
if err := extractPluginArchive(r.File, dir); err != nil {
|
||||
_ = os.RemoveAll(dir)
|
||||
return info, err
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, info.Entry)); err != nil {
|
||||
_ = os.RemoveAll(dir)
|
||||
return info, fmt.Errorf("plugin entry %s does not exist", info.Entry)
|
||||
}
|
||||
for _, page := range info.Pages {
|
||||
if _, err := os.Stat(filepath.Join(dir, page.File)); err != nil {
|
||||
_ = os.RemoveAll(dir)
|
||||
return info, fmt.Errorf("plugin page %s does not exist", page.File)
|
||||
}
|
||||
}
|
||||
if global.stateStore().get(info.Short).Enabled {
|
||||
if err := global.restartPlugin(info.Short); err != nil {
|
||||
return info, fmt.Errorf("plugin reinstalled but reload failed: %w", err)
|
||||
}
|
||||
}
|
||||
return info, nil
|
||||
}
|
||||
|
||||
func validatePluginArchive(files []*zip.File) error {
|
||||
if len(files) > maxPluginArchiveFiles {
|
||||
return fmt.Errorf("plugin archive has more than %d files", maxPluginArchiveFiles)
|
||||
}
|
||||
var total uint64
|
||||
for _, file := range files {
|
||||
if file.FileInfo().IsDir() {
|
||||
continue
|
||||
}
|
||||
if file.UncompressedSize64 > maxPluginFileSize {
|
||||
return fmt.Errorf("plugin file %s exceeds the %d byte limit", file.Name, maxPluginFileSize)
|
||||
}
|
||||
total += file.UncompressedSize64
|
||||
if total > maxPluginExtractedSize {
|
||||
return fmt.Errorf("plugin archive exceeds the %d byte extraction limit", maxPluginExtractedSize)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func extractPluginArchive(files []*zip.File, dir string) error {
|
||||
for _, f := range files {
|
||||
path := filepath.Join(dir, f.Name)
|
||||
if !withinDir(path, dir) {
|
||||
return fmt.Errorf("plugin archive contains an invalid path %q", f.Name)
|
||||
}
|
||||
if f.FileInfo().IsDir() {
|
||||
if err := os.MkdirAll(path, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create directory: %v", err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||
return fmt.Errorf("failed to create directory: %v", err)
|
||||
}
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open archive file: %v", err)
|
||||
}
|
||||
outFile, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644)
|
||||
if err != nil {
|
||||
_ = rc.Close()
|
||||
return fmt.Errorf("failed to create file: %v", err)
|
||||
}
|
||||
_, copyErr := io.Copy(outFile, rc)
|
||||
_ = outFile.Close()
|
||||
_ = rc.Close()
|
||||
if copyErr != nil {
|
||||
return fmt.Errorf("failed to extract file: %v", copyErr)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// withinDir reports whether path stays inside dir after cleaning.
|
||||
func withinDir(path, dir string) bool {
|
||||
rel, err := filepath.Rel(dir, path)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return rel != ".." && !strings.HasPrefix(rel, ".."+string(os.PathSeparator)) && !filepath.IsAbs(rel)
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
)
|
||||
|
||||
func TestInstallZipExtractsValidPlugin(t *testing.T) {
|
||||
withTempDataDir(t)
|
||||
zipPath := writePluginZip(t, map[string]string{
|
||||
"komari-plugin.json": `{"name":"Demo","short":"demo","version":"1.0.0","komari":">=0.0.1","permissions":{"node":true}}`,
|
||||
"script.js": `function load() {}`,
|
||||
})
|
||||
info, err := InstallZip(zipPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if info.Short != "demo" {
|
||||
t.Fatalf("short = %q", info.Short)
|
||||
}
|
||||
if !info.Permissions.Node {
|
||||
t.Fatal("permissions.node not parsed")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(DataDir, "demo", "script.js")); err != nil {
|
||||
t.Fatalf("entry not extracted: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallZipDefaultsEntryToScriptJS(t *testing.T) {
|
||||
withTempDataDir(t)
|
||||
zipPath := writePluginZip(t, map[string]string{
|
||||
"komari-plugin.json": `{"name":"Demo","short":"demo","version":"1.0.0"}`,
|
||||
"script.js": `function load() {}`,
|
||||
})
|
||||
info, err := InstallZip(zipPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if info.Entry != "script.js" {
|
||||
t.Fatalf("entry = %q, want script.js", info.Entry)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallZipRejectsMissingManifest(t *testing.T) {
|
||||
withTempDataDir(t)
|
||||
zipPath := writePluginZip(t, map[string]string{"script.js": "function load() {}"})
|
||||
if _, err := InstallZip(zipPath); err == nil {
|
||||
t.Fatal("expected missing manifest error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallZipRejectsTraversal(t *testing.T) {
|
||||
withTempDataDir(t)
|
||||
zipPath := writePluginZip(t, map[string]string{
|
||||
"komari-plugin.json": `{"name":"Demo","short":"demo","version":"1.0.0"}`,
|
||||
"../evil.txt": "boom",
|
||||
})
|
||||
if _, err := InstallZip(zipPath); err == nil {
|
||||
t.Fatal("expected traversal rejection")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(DataDir, "demo")); !os.IsNotExist(err) {
|
||||
t.Fatal("plugin directory must be removed after a failed install")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallZipRejectsKomariVersionMismatch(t *testing.T) {
|
||||
withTempDataDir(t)
|
||||
zipPath := writePluginZip(t, map[string]string{
|
||||
"komari-plugin.json": `{"name":"Demo","short":"demo","version":"1.0.0","komari":">=99.0.0"}`,
|
||||
"script.js": `function load() {}`,
|
||||
})
|
||||
if _, err := InstallZip(zipPath); err == nil {
|
||||
t.Fatal("expected komari version mismatch error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallZipRejectsMissingEntry(t *testing.T) {
|
||||
withTempDataDir(t)
|
||||
zipPath := writePluginZip(t, map[string]string{
|
||||
"komari-plugin.json": `{"name":"Demo","short":"demo","version":"1.0.0"}`,
|
||||
})
|
||||
if _, err := InstallZip(zipPath); err == nil {
|
||||
t.Fatal("expected missing entry error")
|
||||
}
|
||||
}
|
||||
func TestInstallZipValidatesPageTypesAndVisibility(t *testing.T) {
|
||||
withTempDataDir(t)
|
||||
|
||||
// iframe page 默认 visibility=admin、type=iframe;合法安装。
|
||||
zipPath := writePluginZip(t, map[string]string{
|
||||
"komari-plugin.json": `{"name":"Pages","short":"pages","version":"1.0.0","pages":[{"file":"admin.html","title":"Admin"}]}`,
|
||||
"script.js": `function load() {}`,
|
||||
"admin.html": `<h1>admin</h1>`,
|
||||
})
|
||||
info, err := InstallZip(zipPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(info.Pages) != 1 || info.Pages[0].Type != models.PageTypeIframe || info.Pages[0].Visibility != models.PageVisibilityAdmin {
|
||||
t.Fatalf("page defaults = %+v", info.Pages)
|
||||
}
|
||||
|
||||
// redirect 页面需要站内路径。
|
||||
zipPath = writePluginZip(t, map[string]string{
|
||||
"komari-plugin.json": `{"name":"Pages","short":"pages2","version":"1.0.0","pages":[{"title":"Go","type":"redirect","url":"/admin/settings","visibility":"admin"}]}`,
|
||||
"script.js": `function load() {}`,
|
||||
})
|
||||
if _, err := InstallZip(zipPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// public iframe 页面合法。
|
||||
zipPath = writePluginZip(t, map[string]string{
|
||||
"komari-plugin.json": `{"name":"Pages","short":"pages3","version":"1.0.0","pages":[{"file":"pub.html","title":"Pub","visibility":"public"}]}`,
|
||||
"script.js": `function load() {}`,
|
||||
"pub.html": `<h1>pub</h1>`,
|
||||
})
|
||||
if _, err := InstallZip(zipPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallZipRejectsInvalidPages(t *testing.T) {
|
||||
withTempDataDir(t)
|
||||
cases := []struct {
|
||||
name string
|
||||
manifest string
|
||||
}{
|
||||
{"bad type", `{"name":"P","short":"p1","version":"1.0.0","pages":[{"file":"a.html","title":"A","type":"popup"}]}`},
|
||||
{"redirect without url", `{"name":"P","short":"p2","version":"1.0.0","pages":[{"title":"A","type":"redirect"}]}`},
|
||||
{"redirect external url", `{"name":"P","short":"p3","version":"1.0.0","pages":[{"title":"A","type":"redirect","url":"https://evil.example/"}]}`},
|
||||
{"redirect protocol-relative", `{"name":"P","short":"p4","version":"1.0.0","pages":[{"title":"A","type":"redirect","url":"//evil.example/"}]}`},
|
||||
{"redirect traversal", `{"name":"P","short":"p5","version":"1.0.0","pages":[{"title":"A","type":"redirect","url":"/admin/../secret"}]}`},
|
||||
{"bad visibility", `{"name":"P","short":"p6","version":"1.0.0","pages":[{"file":"a.html","title":"A","visibility":"everyone"}]}`},
|
||||
{"iframe without file", `{"name":"P","short":"p7","version":"1.0.0","pages":[{"title":"A"}]}`},
|
||||
}
|
||||
for _, tt := range cases {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
zipPath := writePluginZip(t, map[string]string{
|
||||
"komari-plugin.json": tt.manifest,
|
||||
"script.js": `function load() {}`,
|
||||
})
|
||||
if _, err := InstallZip(zipPath); err == nil {
|
||||
t.Fatalf("expected rejection for %s", tt.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
func TestInstallZipRejectsInvalidIcon(t *testing.T) {
|
||||
withTempDataDir(t)
|
||||
cases := []struct {
|
||||
name string
|
||||
manifest string
|
||||
}{
|
||||
{"plugin icon traversal", `{"name":"I","short":"i1","version":"1.0.0","icon":"../evil.png"}`},
|
||||
{"plugin icon absolute", `{"name":"I","short":"i2","version":"1.0.0","icon":"/etc/passwd"}`},
|
||||
{"page icon traversal", `{"name":"I","short":"i3","version":"1.0.0","pages":[{"file":"a.html","title":"A","icon":"../evil.png"}]}`},
|
||||
}
|
||||
for _, tt := range cases {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
zipPath := writePluginZip(t, map[string]string{
|
||||
"komari-plugin.json": tt.manifest,
|
||||
"script.js": `function load() {}`,
|
||||
})
|
||||
if _, err := InstallZip(zipPath); err == nil {
|
||||
t.Fatalf("expected rejection for %s", tt.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallZipAcceptsRelativeIcon(t *testing.T) {
|
||||
withTempDataDir(t)
|
||||
zipPath := writePluginZip(t, map[string]string{
|
||||
"komari-plugin.json": `{"name":"I","short":"ic","version":"1.0.0","icon":"icon.svg","pages":[{"file":"a.html","title":"A","icon":"page.svg"}]}`,
|
||||
"script.js": `function load() {}`,
|
||||
"a.html": `<h1>a</h1>`,
|
||||
"icon.svg": `<svg/>`,
|
||||
"page.svg": `<svg/>`,
|
||||
})
|
||||
info, err := InstallZip(zipPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if info.Icon != "icon.svg" || info.Pages[0].Icon != "page.svg" {
|
||||
t.Fatalf("icons = %+v", info)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// LogBuffer is a bounded ring buffer holding one plugin's console output and
|
||||
// lifecycle markers. Old bytes are dropped from the front when the limit is
|
||||
// reached.
|
||||
type LogBuffer struct {
|
||||
mu sync.Mutex
|
||||
buf bytes.Buffer
|
||||
limit int
|
||||
}
|
||||
|
||||
func newLogBuffer(limit int) *LogBuffer {
|
||||
if limit <= 0 {
|
||||
limit = defaultLogBufferSize
|
||||
}
|
||||
return &LogBuffer{limit: limit}
|
||||
}
|
||||
|
||||
func (b *LogBuffer) Write(p []byte) (int, error) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
_, _ = b.buf.Write(p)
|
||||
if b.buf.Len() > b.limit {
|
||||
b.buf.Next(b.buf.Len() - b.limit)
|
||||
}
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
func (b *LogBuffer) String() string {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return b.buf.String()
|
||||
}
|
||||
|
||||
func (b *LogBuffer) Reset() {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
b.buf.Reset()
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/komari-monitor/komari/cmd/flags"
|
||||
"github.com/komari-monitor/komari/database/dbcore"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
flags.DatabaseType = flags.DatabaseTypeSQLite
|
||||
flags.DatabaseFile = "file:komari_plugin_test?mode=memory&cache=shared"
|
||||
db := dbcore.GetDBInstance()
|
||||
if sqlDB, err := db.DB(); err == nil {
|
||||
sqlDB.SetMaxOpenConns(1)
|
||||
}
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
// withTempDataDir redirects DataDir and StorageDir and resets the global
|
||||
// manager for the duration of a test.
|
||||
func withTempDataDir(t *testing.T) {
|
||||
t.Helper()
|
||||
oldDir := DataDir
|
||||
oldStorage := StorageDir
|
||||
oldGlobal := global
|
||||
DataDir = t.TempDir()
|
||||
StorageDir = t.TempDir()
|
||||
mgr := &Manager{
|
||||
instances: make(map[string]*Instance),
|
||||
routes: make(map[string]map[string]bool),
|
||||
logs: make(map[string]*LogBuffer),
|
||||
}
|
||||
global = mgr
|
||||
t.Cleanup(func() { _ = mgr.closeAll() }) // release runtimes before TempDir removal
|
||||
t.Cleanup(func() {
|
||||
DataDir = oldDir
|
||||
StorageDir = oldStorage
|
||||
global = oldGlobal
|
||||
})
|
||||
}
|
||||
|
||||
// writePluginZip creates a plugin ZIP from a name -> content map.
|
||||
func writePluginZip(t *testing.T, files map[string]string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "plugin.zip")
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer f.Close()
|
||||
w := zip.NewWriter(f)
|
||||
for name, content := range files {
|
||||
entry, err := w.Create(name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := entry.Write([]byte(content)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/komari-monitor/komari/pkg/rpc"
|
||||
)
|
||||
|
||||
const demoManifest = `{"name":"Demo","short":"demo","version":"1.0.0","komari":">=0.0.1","permissions":{"node":true,"timeout":5,"allowRoutes":true,"allowSystemRPC":true}}`
|
||||
|
||||
func TestManagerSwitchRouteCallAndLogs(t *testing.T) {
|
||||
withTempDataDir(t)
|
||||
gin.SetMode(gin.TestMode)
|
||||
engine := gin.New()
|
||||
Init(engine)
|
||||
|
||||
zipPath := writePluginZip(t, map[string]string{
|
||||
"komari-plugin.json": demoManifest,
|
||||
"script.js": `
|
||||
const server = require("server");
|
||||
function load() {
|
||||
console.log("demo loaded");
|
||||
server.route("GET", "/plug", async (req, res) => {
|
||||
const result = await server.call("plugin:testEcho", { x: req.query.x });
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(JSON.stringify(result));
|
||||
});
|
||||
}
|
||||
function unload() {
|
||||
console.log("demo unloaded");
|
||||
}
|
||||
`,
|
||||
})
|
||||
if _, err := InstallZip(zipPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// The switch must ask for permission approval on first enable.
|
||||
if err := SetEnabled("demo", true, false); err != ErrPermissionApprovalRequired {
|
||||
t.Fatalf("first enable: expected permission approval required, got %v", err)
|
||||
}
|
||||
// Approving permissions and enabling must load the plugin.
|
||||
if err := SetEnabled("demo", true, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Register a test RPC method the plugin calls through server.call.
|
||||
if err := rpc.Register("plugin:testEcho", func(_ context.Context, req *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
|
||||
return req.Params, nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer rpc.Unregister("plugin:testEcho")
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
engine.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/plug?x=7", nil))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("route status = %d, body = %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var body map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("route body is not JSON: %v (%s)", err, rec.Body.String())
|
||||
}
|
||||
if body["x"] != "7" {
|
||||
t.Fatalf("server.call echo mismatch: %s", rec.Body.String())
|
||||
}
|
||||
|
||||
logs := GetLogs("demo")
|
||||
if !strings.Contains(logs, "demo loaded") {
|
||||
t.Fatalf("logs missing console output: %q", logs)
|
||||
}
|
||||
|
||||
// Disabling runs unload() and makes the route slot return 404.
|
||||
if err := SetEnabled("demo", false, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec2 := httptest.NewRecorder()
|
||||
engine.ServeHTTP(rec2, httptest.NewRequest(http.MethodGet, "/plug", nil))
|
||||
if rec2.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404 after unload, got %d", rec2.Code)
|
||||
}
|
||||
if logs := GetLogs("demo"); !strings.Contains(logs, "demo unloaded") {
|
||||
t.Fatalf("logs missing unload marker: %q", logs)
|
||||
}
|
||||
|
||||
// Re-enabling reuses the route slot (approval already stored).
|
||||
if err := SetEnabled("demo", true, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec3 := httptest.NewRecorder()
|
||||
engine.ServeHTTP(rec3, httptest.NewRequest(http.MethodGet, "/plug?x=9", nil))
|
||||
if rec3.Code != http.StatusOK || !strings.Contains(rec3.Body.String(), "9") {
|
||||
t.Fatalf("reload route status = %d, body = %s", rec3.Code, rec3.Body.String())
|
||||
}
|
||||
|
||||
infos := List()
|
||||
if len(infos) != 1 || infos[0].Short != "demo" || !infos[0].Enabled || !infos[0].Running {
|
||||
t.Fatalf("List mismatch: %+v", infos)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetEnabledFailureAutoDisablesAndRecordsError(t *testing.T) {
|
||||
withTempDataDir(t)
|
||||
gin.SetMode(gin.TestMode)
|
||||
Init(gin.New())
|
||||
|
||||
zipPath := writePluginZip(t, map[string]string{
|
||||
"komari-plugin.json": `{"name":"Bad","short":"bad","version":"1.0.0"}`,
|
||||
"script.js": `throw new Error("boom");`,
|
||||
})
|
||||
if _, err := InstallZip(zipPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err := SetEnabled("bad", true, true)
|
||||
if err == nil || !strings.Contains(err.Error(), "boom") {
|
||||
t.Fatalf("expected boom error, got %v", err)
|
||||
}
|
||||
st := global.stateStore().get("bad")
|
||||
if st.Enabled {
|
||||
t.Fatal("failed plugin must be auto-disabled")
|
||||
}
|
||||
if !strings.Contains(st.LastError, "boom") {
|
||||
t.Fatalf("last_error = %q", st.LastError)
|
||||
}
|
||||
infos := List()
|
||||
if len(infos) != 1 || infos[0].Enabled || infos[0].Running || !strings.Contains(infos[0].LastError, "boom") {
|
||||
t.Fatalf("List mismatch: %+v", infos)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadHookFailureIsReported(t *testing.T) {
|
||||
withTempDataDir(t)
|
||||
gin.SetMode(gin.TestMode)
|
||||
Init(gin.New())
|
||||
|
||||
zipPath := writePluginZip(t, map[string]string{
|
||||
"komari-plugin.json": `{"name":"Hook","short":"hook","version":"1.0.0"}`,
|
||||
"script.js": `function load() {
|
||||
throw new Error("loadhook");
|
||||
}`,
|
||||
})
|
||||
if _, err := InstallZip(zipPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err := SetEnabled("hook", true, true)
|
||||
if err == nil || !strings.Contains(err.Error(), "load() failed") || !strings.Contains(err.Error(), "loadhook") {
|
||||
t.Fatalf("unexpected load() error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadAllSkipsDisabledAndApprovesEnabled(t *testing.T) {
|
||||
withTempDataDir(t)
|
||||
gin.SetMode(gin.TestMode)
|
||||
Init(gin.New())
|
||||
|
||||
zipPath := writePluginZip(t, map[string]string{
|
||||
"komari-plugin.json": demoManifest,
|
||||
"script.js": `function load() { console.log("auto loaded"); }`,
|
||||
})
|
||||
if _, err := InstallZip(zipPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Not enabled yet: LoadAll must not load it.
|
||||
if err := LoadAll(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if inst := global.instanceFor("demo"); inst != nil {
|
||||
t.Fatal("disabled plugin was loaded")
|
||||
}
|
||||
|
||||
// Persist an enabled+approved state, then LoadAll must load it.
|
||||
hash := approvalPermissionsHash(models.PluginPermissions{AllowRoutes: true, AllowSystemRPC: true})
|
||||
global.stateStore().set("demo", PluginState{Enabled: true, ApprovedPermissionsHash: hash})
|
||||
if err := LoadAll(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
infos := List()
|
||||
if len(infos) != 1 || !infos[0].Running {
|
||||
t.Fatalf("enabled plugin not running after LoadAll: %+v", infos)
|
||||
}
|
||||
if logs := GetLogs("demo"); !strings.Contains(logs, "auto loaded") {
|
||||
t.Fatalf("logs missing output: %q", logs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadAllAutoDisablesUnapprovedPlugin(t *testing.T) {
|
||||
withTempDataDir(t)
|
||||
gin.SetMode(gin.TestMode)
|
||||
Init(gin.New())
|
||||
|
||||
zipPath := writePluginZip(t, map[string]string{
|
||||
"komari-plugin.json": demoManifest,
|
||||
"script.js": `function load() {}`,
|
||||
})
|
||||
if _, err := InstallZip(zipPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Simulate a previously enabled plugin whose approval was lost.
|
||||
global.stateStore().set("demo", PluginState{Enabled: true})
|
||||
err := LoadAll()
|
||||
if err == nil || !strings.Contains(err.Error(), "approval") {
|
||||
t.Fatalf("expected approval error, got %v", err)
|
||||
}
|
||||
st := global.stateStore().get("demo")
|
||||
if st.Enabled {
|
||||
t.Fatal("unapproved plugin must be auto-disabled")
|
||||
}
|
||||
if !strings.Contains(st.LastError, "approval") {
|
||||
t.Fatalf("last_error = %q", st.LastError)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloseAllUnloadsEverything(t *testing.T) {
|
||||
withTempDataDir(t)
|
||||
gin.SetMode(gin.TestMode)
|
||||
Init(gin.New())
|
||||
|
||||
zipPath := writePluginZip(t, map[string]string{
|
||||
"komari-plugin.json": demoManifest,
|
||||
"script.js": `function unload() { console.log("bye"); }`,
|
||||
})
|
||||
if _, err := InstallZip(zipPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := SetEnabled("demo", true, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := CloseAll(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if inst := global.instanceFor("demo"); inst != nil {
|
||||
t.Fatal("plugin still loaded after CloseAll")
|
||||
}
|
||||
if logs := GetLogs("demo"); !strings.Contains(logs, "bye") {
|
||||
t.Fatalf("unload hook not called: %q", logs)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
)
|
||||
|
||||
// urlSchemeRE matches a leading URL scheme such as "http:" or "javascript:",
|
||||
// mirroring the theme redirect validation.
|
||||
var urlSchemeRE = regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9+\-.]*:`)
|
||||
|
||||
// readManifest loads and validates the manifest of an installed plugin
|
||||
// directory.
|
||||
func readManifest(dir string) (models.Plugin, error) {
|
||||
var info models.Plugin
|
||||
data, err := os.ReadFile(filepath.Join(dir, manifestFile))
|
||||
if err != nil {
|
||||
return info, fmt.Errorf("read plugin manifest: %w", err)
|
||||
}
|
||||
if err := json.Unmarshal(data, &info); err != nil {
|
||||
return info, fmt.Errorf("parse plugin manifest: %w", err)
|
||||
}
|
||||
if err := validateManifest(&info); err != nil {
|
||||
return info, err
|
||||
}
|
||||
return info, nil
|
||||
}
|
||||
|
||||
// validateManifest checks the fields the server relies on and fills in
|
||||
// defaults.
|
||||
func validateManifest(info *models.Plugin) error {
|
||||
if info.Short == "" {
|
||||
return fmt.Errorf("plugin short is required")
|
||||
}
|
||||
if !validShort(info.Short) {
|
||||
return fmt.Errorf("plugin short %q is invalid: only letters, digits, '_' and '-' are allowed", info.Short)
|
||||
}
|
||||
if !models.IsLocalizedText(info.Name) {
|
||||
return fmt.Errorf("plugin name is required")
|
||||
}
|
||||
if info.Entry == "" {
|
||||
info.Entry = defaultEntry
|
||||
}
|
||||
if !filepath.IsLocal(info.Entry) {
|
||||
return fmt.Errorf("plugin entry %q must be a relative path inside the plugin directory", info.Entry)
|
||||
}
|
||||
if info.Icon != "" && !filepath.IsLocal(info.Icon) {
|
||||
return fmt.Errorf("plugin icon %q must be a relative path inside the plugin directory", info.Icon)
|
||||
}
|
||||
for i := range info.Pages {
|
||||
if err := validatePage(&info.Pages[i]); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validatePage checks one declared plugin page. Type and visibility default
|
||||
// to iframe/admin so existing manifests keep their behavior.
|
||||
func validatePage(page *models.PluginPage) error {
|
||||
if page.Type == "" {
|
||||
page.Type = models.PageTypeIframe
|
||||
}
|
||||
if page.Visibility == "" {
|
||||
page.Visibility = models.PageVisibilityAdmin
|
||||
}
|
||||
switch page.Visibility {
|
||||
case models.PageVisibilityAdmin, models.PageVisibilityPublic:
|
||||
default:
|
||||
return fmt.Errorf("plugin page visibility %q is invalid: use \"admin\" or \"public\"", page.Visibility)
|
||||
}
|
||||
if page.Icon != "" && !filepath.IsLocal(page.Icon) {
|
||||
return fmt.Errorf("plugin page icon %q must be a relative path inside the plugin directory", page.Icon)
|
||||
}
|
||||
switch page.Type {
|
||||
case models.PageTypeIframe:
|
||||
if page.File == "" || !filepath.IsLocal(page.File) {
|
||||
return fmt.Errorf("plugin iframe page requires a relative file path inside the plugin directory")
|
||||
}
|
||||
case models.PageTypeRedirect:
|
||||
if page.URL == "" || !isSafeInternalPath(page.URL) {
|
||||
return fmt.Errorf("plugin redirect page requires an internal site path starting with /")
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("plugin page type %q is invalid: use \"iframe\" or \"redirect\"", page.Type)
|
||||
}
|
||||
if !models.IsLocalizedText(page.Title) {
|
||||
return fmt.Errorf("plugin page %q requires a title", page.Title)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// isSafeInternalPath mirrors the theme redirect rule: only same-origin
|
||||
// relative paths are allowed, no scheme, no backslashes, no traversal.
|
||||
func isSafeInternalPath(target string) bool {
|
||||
target = strings.TrimSpace(target)
|
||||
if !strings.HasPrefix(target, "/") || strings.HasPrefix(target, "//") {
|
||||
return false
|
||||
}
|
||||
if strings.Contains(target, "\\") || urlSchemeRE.MatchString(target) {
|
||||
return false
|
||||
}
|
||||
for _, segment := range strings.Split(target, "/") {
|
||||
if segment == ".." {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/Calcium-Ion/moejs"
|
||||
"github.com/Calcium-Ion/moejs/engine"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/komari-monitor/komari/pkg/jsruntime"
|
||||
"github.com/komari-monitor/komari/pkg/jsruntime/native"
|
||||
"github.com/komari-monitor/komari/utils/messageSender"
|
||||
)
|
||||
|
||||
// registerNotificationChannel registers one channel owned by a plugin. The
|
||||
// channel is tied to the current runtime, so unloading the plugin removes it.
|
||||
func (m *Manager) registerNotificationChannel(
|
||||
short string,
|
||||
id string,
|
||||
configuration models.Configuration,
|
||||
handler moejs.Value,
|
||||
host *jsruntime.Host,
|
||||
) error {
|
||||
inst := m.instanceFor(short)
|
||||
if inst == nil {
|
||||
return fmt.Errorf("plugin %q is not loaded", short)
|
||||
}
|
||||
if host == nil {
|
||||
return fmt.Errorf("plugin %q notification runtime is unavailable", short)
|
||||
}
|
||||
notificationChannel := &pluginNotificationChannel{
|
||||
short: short,
|
||||
host: host,
|
||||
handler: handler,
|
||||
}
|
||||
inst.mu.Lock()
|
||||
defer inst.mu.Unlock()
|
||||
if inst.runtime == nil {
|
||||
return fmt.Errorf("plugin %q notification runtime is closed", short)
|
||||
}
|
||||
if err := messageSender.RegisterNotificationChannel(id, configuration, notificationChannel); err != nil {
|
||||
return err
|
||||
}
|
||||
if inst.notificationChannels == nil {
|
||||
inst.notificationChannels = make(map[string]struct{})
|
||||
}
|
||||
inst.notificationChannels[id] = struct{}{}
|
||||
return nil
|
||||
}
|
||||
|
||||
type pluginNotificationChannel struct {
|
||||
short string
|
||||
host *jsruntime.Host
|
||||
handler moejs.Value
|
||||
}
|
||||
|
||||
func (c *pluginNotificationChannel) Send(ctx context.Context, notification messageSender.Notification, config map[string]any) error {
|
||||
if c.host == nil || c.handler.IsUndefined() {
|
||||
return fmt.Errorf("plugin %q notification channel is closed", c.short)
|
||||
}
|
||||
result := make(chan error, 1)
|
||||
var once sync.Once
|
||||
finish := func(err error) {
|
||||
once.Do(func() { result <- err })
|
||||
}
|
||||
|
||||
queued := c.host.RunOnLoop(func(vm *moejs.Runtime) {
|
||||
var value moejs.Value
|
||||
runErr := c.host.RunJob(vm, "plugin notification "+c.short, func() error {
|
||||
raw, err := json.Marshal(notification)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var notificationValue map[string]any
|
||||
if err := json.Unmarshal(raw, ¬ificationValue); err != nil {
|
||||
return err
|
||||
}
|
||||
var callErr error
|
||||
value, callErr = vm.CallFunction(c.handler,
|
||||
moejs.Undefined(),
|
||||
native.From(vm, notificationValue),
|
||||
native.From(vm, config),
|
||||
)
|
||||
return callErr
|
||||
})
|
||||
if runErr != nil {
|
||||
finish(runErr)
|
||||
return
|
||||
}
|
||||
_, _, ok := moejs.PromiseResult(value)
|
||||
if !ok {
|
||||
finish(nil)
|
||||
return
|
||||
}
|
||||
then := native.Get(vm, native.Object(vm, value), "then")
|
||||
if !engine.IsCallable(then) {
|
||||
finish(fmt.Errorf("plugin %q notification channel returned a Promise without then", c.short))
|
||||
return
|
||||
}
|
||||
onFulfilled := native.Bind(vm, "notificationFulfilled", 0, func(_ *moejs.Realm, _ moejs.Value, args []moejs.Value) (moejs.Value, error) {
|
||||
finish(nil)
|
||||
return moejs.Undefined(), nil
|
||||
})
|
||||
onRejected := native.Bind(vm, "notificationRejected", 1, func(_ *moejs.Realm, _ moejs.Value, args []moejs.Value) (moejs.Value, error) {
|
||||
finish(fmt.Errorf("plugin %q notification channel failed: %s", c.short, native.String(vm, moejs.Arg(args, 0))))
|
||||
return moejs.Undefined(), nil
|
||||
})
|
||||
if _, err := vm.CallFunction(then, value, onFulfilled, onRejected); err != nil {
|
||||
finish(fmt.Errorf("plugin %q notification channel Promise failed: %w", c.short, err))
|
||||
}
|
||||
|
||||
})
|
||||
if !queued {
|
||||
return fmt.Errorf("plugin %q notification runtime is closed", c.short)
|
||||
}
|
||||
|
||||
timeout := c.host.Timeout()
|
||||
if deadline, ok := ctx.Deadline(); ok {
|
||||
if remaining := time.Until(deadline); remaining < timeout {
|
||||
timeout = remaining
|
||||
}
|
||||
}
|
||||
if timeout <= 0 {
|
||||
return context.DeadlineExceeded
|
||||
}
|
||||
timer := time.NewTimer(timeout)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case err := <-result:
|
||||
return err
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-timer.C:
|
||||
return fmt.Errorf("plugin %q notification channel timed out", c.short)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *pluginNotificationChannel) Unload() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) unregisterNotificationChannels(inst *Instance) error {
|
||||
if inst == nil {
|
||||
return nil
|
||||
}
|
||||
inst.mu.Lock()
|
||||
ids := make([]string, 0, len(inst.notificationChannels))
|
||||
for id := range inst.notificationChannels {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
inst.notificationChannels = nil
|
||||
inst.mu.Unlock()
|
||||
var firstErr error
|
||||
for _, id := range ids {
|
||||
if err := messageSender.UnregisterNotificationChannel(id); err != nil && firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
}
|
||||
return firstErr
|
||||
}
|
||||
@@ -0,0 +1,784 @@
|
||||
// Package plugin manages Komari plugins: ZIP packages with a
|
||||
// komari-plugin.json manifest, mirroring the theme package format. A plugin
|
||||
// runs in its own jsruntime instance confined to its data/plugin/<short>
|
||||
// directory, plus its long-term data directory data/plugin-data/<short>
|
||||
// exposed as __storageDir__ (which survives plugin updates), declares runtime
|
||||
// permissions in its manifest, and receives the host-injected "server" module
|
||||
// (server.route / server.call / server.hook, whose ws kinds can intercept
|
||||
// WebSocket connections and frames). Plugins run with admin authority inside
|
||||
// the system.
|
||||
//
|
||||
// Lifecycle: plugins are installed as directories under DataDir. The
|
||||
// persisted state file (state.json) records which plugins are enabled, which
|
||||
// permissions were approved, and the last load error. Enabled plugins are
|
||||
// loaded at startup; a failed load auto-disables the plugin and keeps the
|
||||
// error text visible through admin:listPlugins.
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/Calcium-Ion/moejs"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/komari-monitor/komari/internal/scheduler"
|
||||
"github.com/komari-monitor/komari/pkg/jsruntime"
|
||||
"github.com/komari-monitor/komari/pkg/jsruntime/require"
|
||||
"github.com/komari-monitor/komari/pkg/rpc"
|
||||
"github.com/komari-monitor/komari/web/connection"
|
||||
)
|
||||
|
||||
// DataDir is the on-disk root for installed plugins, mirroring the theme
|
||||
// directory layout. It is a variable so tests can redirect it.
|
||||
var DataDir = "./data/plugin"
|
||||
|
||||
// StorageDir is the on-disk root for long-term plugin data. Each plugin gets
|
||||
// StorageDir/<short>. The directory is confined into the plugin runtime as
|
||||
// __storageDir__ and survives plugin reinstalls: update removes only
|
||||
// DataDir/<short> (the code from the ZIP) while StorageDir/<short> persists.
|
||||
// Deleting a plugin removes both.
|
||||
var StorageDir = "./data/plugin-data"
|
||||
|
||||
const (
|
||||
manifestFile = "komari-plugin.json"
|
||||
defaultEntry = "script.js"
|
||||
|
||||
defaultMaxHTTPBodyBytes int64 = 32 << 20
|
||||
defaultLogBufferSize = 64 << 10
|
||||
|
||||
stateFileName = "state.json"
|
||||
)
|
||||
|
||||
// maxHookBufferBytes caps how much of a response the hook chain buffers
|
||||
// for rewriting. Larger responses pass through to the client unmodified;
|
||||
// it is a variable so tests can lower it.
|
||||
var maxHookBufferBytes = 32 << 20
|
||||
|
||||
// maxHTMLInjectBufferBytes caps how much of a text/html response the HTML
|
||||
// injector buffers before injecting plugin fragments. Larger responses pass
|
||||
// through unmodified; it is a variable so tests can lower it.
|
||||
var maxHTMLInjectBufferBytes = 32 << 20
|
||||
|
||||
const (
|
||||
maxPluginArchiveFiles = 10000
|
||||
maxPluginFileSize = 128 << 20
|
||||
maxPluginExtractedSize = 512 << 20
|
||||
maxPluginManifestSize = 1 << 20
|
||||
)
|
||||
|
||||
// ErrPermissionApprovalRequired is returned when enabling a plugin whose
|
||||
// declared permissions have not been approved, or changed since approval.
|
||||
var ErrPermissionApprovalRequired = errors.New("plugin permissions require approval")
|
||||
|
||||
var errNotLoaded = errors.New("plugin is not loaded")
|
||||
|
||||
// ErrNotInstalled is returned when deleting a plugin that is not installed.
|
||||
var ErrNotInstalled = errors.New("plugin is not installed")
|
||||
|
||||
// Info describes one installed plugin for the admin surface.
|
||||
type Info struct {
|
||||
models.Plugin
|
||||
Enabled bool `json:"enabled"`
|
||||
Running bool `json:"running"`
|
||||
LastError string `json:"last_error"`
|
||||
}
|
||||
|
||||
// Manager owns the installed plugin set: load/unload lifecycle, persisted
|
||||
// state, per-plugin logs, and the host engine used by server.route.
|
||||
type Manager struct {
|
||||
mu sync.RWMutex
|
||||
engine *gin.Engine
|
||||
instances map[string]*Instance
|
||||
routes map[string]map[string]bool // short -> "METHOD path" -> gin slot registered
|
||||
hooks []*hookEntry // http request/response hooks in registration order
|
||||
injects []*injectEntry // html head/body fragments in registration order
|
||||
|
||||
stateMu sync.Mutex
|
||||
state *State
|
||||
|
||||
logsMu sync.Mutex
|
||||
logs map[string]*LogBuffer
|
||||
}
|
||||
|
||||
// Instance is one loaded plugin runtime plus its host bindings.
|
||||
type Instance struct {
|
||||
mu sync.RWMutex
|
||||
info models.Plugin
|
||||
dir string
|
||||
runtime *jsruntime.Runtime
|
||||
host *jsruntime.Host
|
||||
handlers map[string]moejs.Value // "METHOD path" -> current route handler
|
||||
statics map[string]*staticConfig // mount path -> static folder config
|
||||
rpcMethods map[string]moejs.Value // registered RPC method -> JS handler
|
||||
cronJobs []string // scheduler job names, removed on unload
|
||||
notificationChannels map[string]struct{}
|
||||
}
|
||||
|
||||
// global is the process-wide plugin manager.
|
||||
var global = &Manager{
|
||||
instances: make(map[string]*Instance),
|
||||
routes: make(map[string]map[string]bool),
|
||||
logs: make(map[string]*LogBuffer),
|
||||
}
|
||||
|
||||
// Init wires the manager to the HTTP engine and into every WebSocket
|
||||
// connection. It must be called before any plugin is loaded.
|
||||
func Init(engine *gin.Engine) {
|
||||
global.mu.Lock()
|
||||
global.engine = engine
|
||||
global.mu.Unlock()
|
||||
connection.SetFrameInterceptor(global)
|
||||
}
|
||||
|
||||
// LoadAll loads every installed plugin whose persisted state is enabled and
|
||||
// approved. A failed load auto-disables the plugin, persists last_error and
|
||||
// is returned joined so callers can log it without stopping startup.
|
||||
func LoadAll() error {
|
||||
return global.loadAll()
|
||||
}
|
||||
|
||||
// SetEnabled is the single switch entry point. Enabling a plugin whose
|
||||
// declared permissions differ from the approved hash requires approved=true.
|
||||
func SetEnabled(short string, enabled, approved bool) error {
|
||||
return global.setEnabled(short, enabled, approved)
|
||||
}
|
||||
|
||||
// Reload unloads and loads an enabled plugin. The load hook completes before
|
||||
// this function returns; a failed load is persisted as the plugin's last
|
||||
// error and disables the plugin, matching the normal enable/startup path.
|
||||
func Reload(short string) error {
|
||||
return global.reload(short)
|
||||
}
|
||||
|
||||
// CloseAll unloads every loaded plugin. Used during server shutdown.
|
||||
func CloseAll() error {
|
||||
return global.closeAll()
|
||||
}
|
||||
|
||||
// List returns the installed plugins with their enabled/running state.
|
||||
func List() []Info {
|
||||
return global.list()
|
||||
}
|
||||
|
||||
// GetLogs returns the bounded log buffer of one plugin.
|
||||
func GetLogs(short string) string {
|
||||
return global.getLogs(short)
|
||||
}
|
||||
|
||||
// Delete removes an installed plugin and its persisted state.
|
||||
func Delete(short string) error {
|
||||
return global.delete(short)
|
||||
}
|
||||
|
||||
// WrapHandler installs the plugin HTTP hook chain around the application
|
||||
// handler. Request hooks run before the handler and may modify the request;
|
||||
// response hooks run after the handler and may modify status/headers/body.
|
||||
func WrapHandler(next http.Handler) http.Handler {
|
||||
return global.wrapHandler(next)
|
||||
}
|
||||
|
||||
// HTMLInjectHandler installs plugin HTML injection around the application
|
||||
// handler. Every text/html response is buffered and gets the fragments
|
||||
// registered through server.injectHTML inserted before </head> and </body>;
|
||||
// all other responses (JSON, assets, streams, downloads...) pass through to
|
||||
// the client unmodified.
|
||||
func HTMLInjectHandler(next http.Handler) http.Handler {
|
||||
return global.htmlInjectHandler(next)
|
||||
}
|
||||
|
||||
func (m *Manager) stateStore() *State {
|
||||
m.stateMu.Lock()
|
||||
defer m.stateMu.Unlock()
|
||||
if m.state == nil || m.state.path != filepath.Join(DataDir, stateFileName) {
|
||||
m.state = openState(filepath.Join(DataDir, stateFileName))
|
||||
}
|
||||
return m.state
|
||||
}
|
||||
|
||||
func (m *Manager) logStore(short string) *LogBuffer {
|
||||
m.logsMu.Lock()
|
||||
defer m.logsMu.Unlock()
|
||||
if m.logs == nil {
|
||||
m.logs = make(map[string]*LogBuffer)
|
||||
}
|
||||
buf := m.logs[short]
|
||||
if buf == nil {
|
||||
buf = newLogBuffer(defaultLogBufferSize)
|
||||
m.logs[short] = buf
|
||||
}
|
||||
return buf
|
||||
}
|
||||
|
||||
func (m *Manager) getLogs(short string) string {
|
||||
m.logsMu.Lock()
|
||||
defer m.logsMu.Unlock()
|
||||
if m.logs == nil {
|
||||
return ""
|
||||
}
|
||||
buf := m.logs[short]
|
||||
if buf == nil {
|
||||
return ""
|
||||
}
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
func (m *Manager) instanceFor(short string) *Instance {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return m.instances[short]
|
||||
}
|
||||
|
||||
// load runs a plugin inside its own jsruntime instance. The manager lock is
|
||||
// only held while the instance map is mutated; script evaluation and the
|
||||
// load() hook run without it so the plugin can call server.call (which takes
|
||||
// a manager read lock through rpcHandler) without deadlocking. On failure the
|
||||
// instance is dropped and its hooks and RPC methods are cleaned up.
|
||||
func (m *Manager) load(short string) error {
|
||||
dir := filepath.Join(DataDir, short)
|
||||
info, err := readManifest(dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := CheckKomariVersion(info.Komari); err != nil {
|
||||
return err
|
||||
}
|
||||
script, err := os.ReadFile(filepath.Join(dir, info.Entry))
|
||||
if err != nil {
|
||||
return fmt.Errorf("read plugin entry %s: %w", info.Entry, err)
|
||||
}
|
||||
|
||||
logs := m.logStore(short)
|
||||
logs.Reset()
|
||||
_, _ = logs.Write([]byte("[plugin] loading " + short + "\n"))
|
||||
|
||||
inst := &Instance{info: info, dir: dir, handlers: make(map[string]moejs.Value), statics: make(map[string]*staticConfig)}
|
||||
m.mu.Lock()
|
||||
if _, ok := m.instances[short]; ok {
|
||||
m.mu.Unlock()
|
||||
return fmt.Errorf("plugin %q is already loaded", short)
|
||||
}
|
||||
m.instances[short] = inst
|
||||
m.mu.Unlock()
|
||||
|
||||
storageDir := filepath.Join(StorageDir, short)
|
||||
if err := os.MkdirAll(storageDir, 0755); err != nil {
|
||||
m.dropInstance(short, inst)
|
||||
return fmt.Errorf("create plugin storage dir: %w", err)
|
||||
}
|
||||
|
||||
opts := jsruntime.Options{
|
||||
BaseDir: dir,
|
||||
StorageDir: storageDir,
|
||||
NodeJS: info.Permissions.Node,
|
||||
AllowExec: info.Permissions.AllowExec,
|
||||
AllowListen: info.Permissions.AllowListen,
|
||||
AllowAllFileAccess: info.Permissions.AllowAllFileAccess,
|
||||
MaxHTTPBodyBytes: info.Permissions.MaxHTTPBodyBytes,
|
||||
MaxChildOutputBytes: info.Permissions.MaxChildOutputBytes,
|
||||
Timeout: time.Duration(info.Permissions.TimeoutSeconds) * time.Second,
|
||||
Console: logs,
|
||||
ConfigureHost: func(host *jsruntime.Host, registry *require.Registry) {
|
||||
inst.mu.Lock()
|
||||
inst.host = host
|
||||
inst.mu.Unlock()
|
||||
m.registerServerModule(host, registry, inst)
|
||||
},
|
||||
}
|
||||
rt, err := jsruntime.New(string(script), opts)
|
||||
if err != nil {
|
||||
m.dropInstance(short, inst)
|
||||
return fmt.Errorf("initialize plugin %q: %w", short, err)
|
||||
}
|
||||
inst.mu.Lock()
|
||||
inst.runtime = rt
|
||||
inst.mu.Unlock()
|
||||
|
||||
if rt.HasFunction("load") {
|
||||
if err := rt.CallVoid("load"); err != nil {
|
||||
m.dropInstance(short, inst)
|
||||
rt.Close()
|
||||
return fmt.Errorf("plugin %q load() failed: %w", short, err)
|
||||
}
|
||||
}
|
||||
m.mu.Lock()
|
||||
stillLoaded := m.instances[short] == inst
|
||||
m.mu.Unlock()
|
||||
if !stillLoaded {
|
||||
// A concurrent unload claimed this instance while it was loading.
|
||||
rt.Close()
|
||||
return fmt.Errorf("plugin %q was unloaded while loading", short)
|
||||
}
|
||||
_, _ = logs.Write([]byte("[plugin] loaded " + short + "\n"))
|
||||
return nil
|
||||
}
|
||||
|
||||
// dropInstance removes a failed load from the manager: it detaches the
|
||||
// instance, unregisters its RPC methods, and clears its route slots and
|
||||
// hooks. Gin route slots from the failed load stay inert, mirroring the
|
||||
// unload behavior. The caller must not hold any Manager lock.
|
||||
func (m *Manager) dropInstance(short string, inst *Instance) {
|
||||
m.mu.Lock()
|
||||
if m.instances[short] != inst {
|
||||
m.mu.Unlock()
|
||||
return // replaced by a newer load or already unloaded
|
||||
}
|
||||
delete(m.instances, short)
|
||||
m.mu.Unlock()
|
||||
_ = m.unregisterNotificationChannels(inst)
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
inst.mu.Lock()
|
||||
for method := range inst.rpcMethods {
|
||||
rpc.Unregister(method)
|
||||
}
|
||||
removeCronJobs(inst.cronJobs)
|
||||
inst.runtime = nil
|
||||
inst.host = nil
|
||||
clear(inst.handlers)
|
||||
clear(inst.statics)
|
||||
clear(inst.rpcMethods)
|
||||
inst.cronJobs = nil
|
||||
inst.mu.Unlock()
|
||||
m.removeHooksLocked(short)
|
||||
m.removeInjectsLocked(short)
|
||||
}
|
||||
|
||||
// removeCronJobs cancels and forgets the scheduler jobs of one plugin load.
|
||||
// It runs with the instance lock held; scheduler.Remove only takes the
|
||||
// scheduler's own lock, so no lock cycle is possible.
|
||||
func removeCronJobs(names []string) {
|
||||
for _, name := range names {
|
||||
scheduler.Remove(name)
|
||||
}
|
||||
}
|
||||
|
||||
// unload stops one plugin. The instance is claimed (removed from the map)
|
||||
// under the manager lock, then the unload() hook runs without any lock so
|
||||
// the script can still call other registered RPC methods. After the hook the
|
||||
// remaining state is detached under the lock and the runtime is closed.
|
||||
// Calls to server.call made from unload() itself are rejected with a clean
|
||||
// "not loaded" error instead of deadlocking.
|
||||
func (m *Manager) unload(short string) error {
|
||||
m.mu.Lock()
|
||||
inst, ok := m.instances[short]
|
||||
if !ok {
|
||||
m.mu.Unlock()
|
||||
return fmt.Errorf("%w: %q", errNotLoaded, short)
|
||||
}
|
||||
delete(m.instances, short)
|
||||
m.mu.Unlock()
|
||||
|
||||
inst.mu.RLock()
|
||||
rt := inst.runtime
|
||||
inst.mu.RUnlock()
|
||||
var unloadErr error
|
||||
if rt != nil {
|
||||
if rt.HasFunction("unload") {
|
||||
if err := rt.CallVoid("unload"); err != nil {
|
||||
unloadErr = fmt.Errorf("plugin %q unload() failed: %w", short, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := m.unregisterNotificationChannels(inst); err != nil && unloadErr == nil {
|
||||
unloadErr = err
|
||||
}
|
||||
m.mu.Lock()
|
||||
inst.mu.Lock()
|
||||
for method := range inst.rpcMethods {
|
||||
rpc.Unregister(method)
|
||||
}
|
||||
removeCronJobs(inst.cronJobs)
|
||||
inst.runtime = nil
|
||||
inst.host = nil
|
||||
clear(inst.handlers)
|
||||
clear(inst.statics)
|
||||
clear(inst.rpcMethods)
|
||||
inst.cronJobs = nil
|
||||
inst.mu.Unlock()
|
||||
m.removeHooksLocked(short)
|
||||
m.removeInjectsLocked(short)
|
||||
m.mu.Unlock()
|
||||
if rt != nil {
|
||||
rt.Close()
|
||||
}
|
||||
_, _ = m.logStore(short).Write([]byte("[plugin] unloaded " + short + "\n"))
|
||||
return unloadErr
|
||||
}
|
||||
|
||||
func (m *Manager) closeAll() error {
|
||||
m.mu.RLock()
|
||||
shorts := make([]string, 0, len(m.instances))
|
||||
for short := range m.instances {
|
||||
shorts = append(shorts, short)
|
||||
}
|
||||
m.mu.RUnlock()
|
||||
var errs []error
|
||||
for _, short := range shorts {
|
||||
if err := m.unload(short); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
func (m *Manager) setEnabled(short string, enabled, approved bool) error {
|
||||
dir := filepath.Join(DataDir, short)
|
||||
info, err := readManifest(dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !enabled {
|
||||
st := m.stateStore().get(short)
|
||||
st.Enabled = false
|
||||
if err := m.unload(short); err != nil && !errors.Is(err, errNotLoaded) {
|
||||
st.LastError = err.Error()
|
||||
}
|
||||
m.stateStore().set(short, st)
|
||||
return nil
|
||||
}
|
||||
if m.instanceFor(short) != nil {
|
||||
// Already running: enabling is idempotent. Re-running load() would
|
||||
// fail with "already loaded" and auto-disable the plugin.
|
||||
st := m.stateStore().get(short)
|
||||
st.Enabled = true
|
||||
st.LastError = ""
|
||||
m.stateStore().set(short, st)
|
||||
return nil
|
||||
}
|
||||
|
||||
hash := approvalPermissionsHash(info.Permissions)
|
||||
st := m.stateStore().get(short)
|
||||
if permissionsRequireApproval(info.Permissions) && st.ApprovedPermissionsHash != hash {
|
||||
if !approved {
|
||||
return ErrPermissionApprovalRequired
|
||||
}
|
||||
st.ApprovedPermissionsHash = hash
|
||||
m.stateStore().set(short, st)
|
||||
}
|
||||
if err := m.load(short); err != nil {
|
||||
st.Enabled = false
|
||||
st.LastError = err.Error()
|
||||
m.stateStore().set(short, st)
|
||||
return err
|
||||
}
|
||||
st.Enabled = true
|
||||
st.LastError = ""
|
||||
m.stateStore().set(short, st)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) loadAll() error {
|
||||
entries, err := os.ReadDir(DataDir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
var errs []error
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
short := entry.Name()
|
||||
st := m.stateStore().get(short)
|
||||
if !st.Enabled || m.instanceFor(short) != nil {
|
||||
continue
|
||||
}
|
||||
info, err := readManifest(filepath.Join(DataDir, short))
|
||||
if err != nil {
|
||||
errs = append(errs, m.disableWithError(short, st, err))
|
||||
continue
|
||||
}
|
||||
if permissionsRequireApproval(info.Permissions) &&
|
||||
st.ApprovedPermissionsHash != approvalPermissionsHash(info.Permissions) {
|
||||
errs = append(errs, m.disableWithError(short, st, ErrPermissionApprovalRequired))
|
||||
continue
|
||||
}
|
||||
if err := m.load(short); err != nil {
|
||||
errs = append(errs, m.disableWithError(short, st, err))
|
||||
continue
|
||||
}
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
// restartPlugin brings a plugin back to its persisted enabled state after a
|
||||
// reinstall. A failed reload or a changed approval hash auto-disables the
|
||||
// plugin and keeps the error visible, mirroring the startup load path.
|
||||
func (m *Manager) restartPlugin(short string) error {
|
||||
st := m.stateStore().get(short)
|
||||
info, err := readManifest(filepath.Join(DataDir, short))
|
||||
if err != nil {
|
||||
return m.disableWithError(short, st, err)
|
||||
}
|
||||
if permissionsRequireApproval(info.Permissions) &&
|
||||
st.ApprovedPermissionsHash != approvalPermissionsHash(info.Permissions) {
|
||||
return m.disableWithError(short, st, ErrPermissionApprovalRequired)
|
||||
}
|
||||
if err := m.load(short); err != nil {
|
||||
return m.disableWithError(short, st, err)
|
||||
}
|
||||
st.Enabled = true
|
||||
st.LastError = ""
|
||||
m.stateStore().set(short, st)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) reload(short string) error {
|
||||
if !m.stateStore().get(short).Enabled {
|
||||
return nil
|
||||
}
|
||||
if err := m.unload(short); err != nil && !errors.Is(err, errNotLoaded) {
|
||||
return err
|
||||
}
|
||||
return m.restartPlugin(short)
|
||||
}
|
||||
|
||||
// disableWithError persists the auto-disable state for one plugin and wraps
|
||||
// the load failure for the startup error summary.
|
||||
func (m *Manager) disableWithError(short string, st PluginState, err error) error {
|
||||
st.Enabled = false
|
||||
st.LastError = err.Error()
|
||||
m.stateStore().set(short, st)
|
||||
return fmt.Errorf("plugin %q: %w", short, err)
|
||||
}
|
||||
|
||||
func (m *Manager) list() []Info {
|
||||
entries, err := os.ReadDir(DataDir)
|
||||
if err != nil {
|
||||
return []Info{}
|
||||
}
|
||||
infos := make([]Info, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
short := entry.Name()
|
||||
info, err := readManifest(filepath.Join(DataDir, short))
|
||||
if err != nil {
|
||||
continue // mirror the theme list: skip unreadable entries
|
||||
}
|
||||
st := m.stateStore().get(short)
|
||||
infos = append(infos, Info{
|
||||
Plugin: info,
|
||||
Enabled: st.Enabled,
|
||||
Running: m.instanceFor(short) != nil,
|
||||
LastError: st.LastError,
|
||||
})
|
||||
}
|
||||
sort.Slice(infos, func(i, j int) bool { return infos[i].Short < infos[j].Short })
|
||||
return infos
|
||||
}
|
||||
|
||||
// Delete removes an installed plugin: it unloads the runtime, deletes the
|
||||
// plugin directory and clears its persisted state. Registered gin route
|
||||
// slots stay inert (404) until the server restarts.
|
||||
func (m *Manager) delete(short string) error {
|
||||
if err := m.unload(short); err != nil && !errors.Is(err, errNotLoaded) {
|
||||
return err
|
||||
}
|
||||
if !validShort(short) {
|
||||
return fmt.Errorf("invalid plugin short %q", short)
|
||||
}
|
||||
dir := filepath.Join(DataDir, short)
|
||||
if _, err := os.Stat(dir); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return fmt.Errorf("%w: %q", ErrNotInstalled, short)
|
||||
}
|
||||
return err
|
||||
}
|
||||
if err := os.RemoveAll(dir); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.RemoveAll(filepath.Join(StorageDir, short)); err != nil {
|
||||
return err
|
||||
}
|
||||
m.stateStore().delete(short)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Manifest returns the parsed manifest of an installed plugin.
|
||||
func Manifest(short string) (models.Plugin, error) {
|
||||
if !validShort(short) {
|
||||
return models.Plugin{}, fmt.Errorf("invalid plugin short %q", short)
|
||||
}
|
||||
return readManifest(filepath.Join(DataDir, short))
|
||||
}
|
||||
|
||||
// ResolvePublicFile returns the absolute path of a file that belongs to a
|
||||
// public iframe page of an installed plugin. Public pages are served without
|
||||
// authentication: the plugin must be enabled and the requested file must live
|
||||
// inside the directory of a page declared with visibility=public, so the page
|
||||
// HTML and its relative assets are reachable without exposing the whole
|
||||
// plugin directory.
|
||||
func ResolvePublicFile(short, name string) (string, error) {
|
||||
if !validShort(short) {
|
||||
return "", fmt.Errorf("invalid plugin short %q", short)
|
||||
}
|
||||
if !global.stateStore().get(short).Enabled {
|
||||
return "", fmt.Errorf("plugin %q is not enabled", short)
|
||||
}
|
||||
info, err := readManifest(filepath.Join(DataDir, short))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
name = filepath.Clean(strings.TrimPrefix(name, "/"))
|
||||
for _, page := range info.Pages {
|
||||
if page.Visibility != models.PageVisibilityPublic || page.Type != models.PageTypeIframe {
|
||||
continue
|
||||
}
|
||||
dir := filepath.Dir(page.File)
|
||||
if dir == "." || name == dir || strings.HasPrefix(name, dir+string(os.PathSeparator)) {
|
||||
return ResolveFile(short, name)
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("plugin page %q is not public", name)
|
||||
}
|
||||
|
||||
// ResolveFile returns the absolute path of a file inside an installed plugin
|
||||
// directory, rejecting traversal. Used to serve injected plugin pages.
|
||||
func ResolveFile(short, name string) (string, error) {
|
||||
if !validShort(short) {
|
||||
return "", fmt.Errorf("invalid plugin short %q", short)
|
||||
}
|
||||
if !filepath.IsLocal(name) {
|
||||
return "", fmt.Errorf("invalid plugin file path %q", name)
|
||||
}
|
||||
dir := filepath.Join(DataDir, short)
|
||||
full := filepath.Join(dir, name)
|
||||
if !withinDir(full, dir) {
|
||||
return "", fmt.Errorf("invalid plugin file path %q", name)
|
||||
}
|
||||
if _, err := os.Stat(full); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return full, nil
|
||||
}
|
||||
|
||||
// hooksOf returns a snapshot of the hooks with the given kind.
|
||||
func (m *Manager) hooksOf(kind hookKind) []*hookEntry {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
snapshot := make([]*hookEntry, 0, len(m.hooks))
|
||||
for _, hook := range m.hooks {
|
||||
if hook.kind == kind {
|
||||
snapshot = append(snapshot, hook)
|
||||
}
|
||||
}
|
||||
return snapshot
|
||||
}
|
||||
|
||||
// registerHook appends a hook. Called from the plugin's own event loop
|
||||
// during script evaluation; it takes the manager lock itself. matcher is
|
||||
// nil for unfiltered hooks; the plugin-declared body limit is captured at
|
||||
// registration so unloaded plugins never contribute a stale limit.
|
||||
func (m *Manager) registerHook(short string, kind hookKind, fn moejs.Value, matcher *hookMatcher) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
inst := m.instances[short]
|
||||
if inst == nil {
|
||||
return
|
||||
}
|
||||
inst.mu.RLock()
|
||||
host := inst.host
|
||||
bodyLimit := inst.info.Permissions.MaxHTTPBodyBytes
|
||||
inst.mu.RUnlock()
|
||||
m.hooks = append(m.hooks, &hookEntry{short: short, kind: kind, fn: fn, host: host, matcher: matcher, bodyLimit: bodyLimit})
|
||||
}
|
||||
|
||||
// removeHooksLocked drops all hooks of one plugin. The caller must hold the
|
||||
// manager write lock.
|
||||
func (m *Manager) removeHooksLocked(short string) {
|
||||
kept := m.hooks[:0]
|
||||
for _, hook := range m.hooks {
|
||||
if hook.short != short {
|
||||
kept = append(kept, hook)
|
||||
}
|
||||
}
|
||||
m.hooks = kept
|
||||
}
|
||||
|
||||
// injectsSnapshot returns the registered HTML injection fragments in
|
||||
// registration order.
|
||||
func (m *Manager) injectsSnapshot() []*injectEntry {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return append([]*injectEntry(nil), m.injects...)
|
||||
}
|
||||
|
||||
// registerInject appends one plugin's HTML head/body fragments. Called from
|
||||
// the plugin's own event loop during script evaluation; it takes the manager
|
||||
// lock itself.
|
||||
func (m *Manager) registerInject(short, head, body string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.instances[short] == nil {
|
||||
return
|
||||
}
|
||||
m.injects = append(m.injects, &injectEntry{short: short, head: head, body: body})
|
||||
}
|
||||
|
||||
// removeInjectsLocked drops all injection fragments of one plugin. The
|
||||
// caller must hold the manager write lock.
|
||||
func (m *Manager) removeInjectsLocked(short string) {
|
||||
kept := m.injects[:0]
|
||||
for _, inject := range m.injects {
|
||||
if inject.short != short {
|
||||
kept = append(kept, inject)
|
||||
}
|
||||
}
|
||||
m.injects = kept
|
||||
}
|
||||
|
||||
// validShort mirrors the theme short-name rule.
|
||||
func validShort(short string) bool {
|
||||
if short == "" || short == "default" {
|
||||
return false
|
||||
}
|
||||
for _, r := range short {
|
||||
if !((r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') ||
|
||||
(r >= '0' && r <= '9') || r == '_' || r == '-') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// permissionsRequireApproval reports whether the manifest declares at least
|
||||
// one approval-relevant capability. Plugins without dangerous capabilities
|
||||
// (for example only declaring node modules, limits or the timeout) enable
|
||||
// without an approval step.
|
||||
func permissionsRequireApproval(p models.PluginPermissions) bool {
|
||||
return p.AllowSystemRPC || p.AllowRoutes || p.AllowHooks || p.AllowHTMLInject ||
|
||||
p.AllowExec || p.AllowListen || p.AllowAllFileAccess
|
||||
}
|
||||
|
||||
// approvalPermissionsHash canonicalizes the approval-relevant permissions so
|
||||
// approval can be compared across plugin upgrades. Runtime settings such as
|
||||
// node modules, resource limits and the execution timeout are granted by
|
||||
// default and do not participate in the approval hash.
|
||||
func approvalPermissionsHash(p models.PluginPermissions) string {
|
||||
data, _ := json.Marshal(models.PluginPermissions{
|
||||
AllowSystemRPC: p.AllowSystemRPC,
|
||||
AllowRoutes: p.AllowRoutes,
|
||||
AllowHooks: p.AllowHooks,
|
||||
AllowHTMLInject: p.AllowHTMLInject,
|
||||
AllowExec: p.AllowExec,
|
||||
AllowListen: p.AllowListen,
|
||||
AllowAllFileAccess: p.AllowAllFileAccess,
|
||||
})
|
||||
sum := sha256.Sum256(data)
|
||||
return "sha256:" + hex.EncodeToString(sum[:])
|
||||
}
|
||||
@@ -0,0 +1,507 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"net"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/Calcium-Ion/moejs"
|
||||
"github.com/Calcium-Ion/moejs/engine"
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/komari-monitor/komari/pkg/jsruntime"
|
||||
"github.com/komari-monitor/komari/pkg/jsruntime/native"
|
||||
"github.com/komari-monitor/komari/web/connection"
|
||||
)
|
||||
|
||||
const (
|
||||
routeWSOpen = 1
|
||||
routeWSClosing = 2
|
||||
routeWSClosed = 3
|
||||
routeWSMaxQueuedFrames = 64
|
||||
routeWSMaxQueuedBytes = 16 << 20
|
||||
routeWSWriteTimeout = 5 * time.Second
|
||||
routeWSCloseTimeout = 5 * time.Second
|
||||
)
|
||||
|
||||
type routeWSFrame struct {
|
||||
kind int
|
||||
data []byte
|
||||
}
|
||||
|
||||
type routeWSCloseEvent struct {
|
||||
code int
|
||||
reason string
|
||||
clean bool
|
||||
message string
|
||||
}
|
||||
|
||||
// routeWebSocket is registered before hijacking: runtime shutdown must be able
|
||||
// to close the network connection even while UpgradeSafeConn waits for a
|
||||
// synchronous wsConnect hook. No SafeConn operation or hook runs on the VM loop.
|
||||
// mu protects transport state only; jsObject belongs exclusively to the loop.
|
||||
type routeWebSocket struct {
|
||||
host *jsruntime.Host
|
||||
mu sync.Mutex
|
||||
|
||||
resourceID uint64
|
||||
netConn net.Conn
|
||||
conn *connection.SafeConn
|
||||
raw *websocket.Conn
|
||||
state int
|
||||
started bool
|
||||
finished bool
|
||||
notified bool
|
||||
result routeWSCloseEvent
|
||||
closeTimer *time.Timer
|
||||
|
||||
queue []routeWSFrame
|
||||
queuedCount int // includes the writer's in-flight frame
|
||||
queuedBytes int
|
||||
wake chan struct{}
|
||||
done chan struct{}
|
||||
|
||||
closeRequested bool
|
||||
closeCode int
|
||||
closeReason string
|
||||
closeWritten bool
|
||||
peerClosed bool
|
||||
peerCode int
|
||||
peerReason string
|
||||
pong string
|
||||
pongPending bool
|
||||
|
||||
jsObject *engine.Object
|
||||
}
|
||||
|
||||
func newRouteWebSocket(host *jsruntime.Host) *routeWebSocket {
|
||||
ws := &routeWebSocket{
|
||||
host: host, state: routeWSClosed,
|
||||
wake: make(chan struct{}, 1), done: make(chan struct{}),
|
||||
}
|
||||
// AddResource may call abort synchronously, or shutdown may race with its
|
||||
// return. Never publish the ID without checking whether teardown already ran.
|
||||
id := host.AddResource(ws.abort)
|
||||
ws.mu.Lock()
|
||||
ws.resourceID = id
|
||||
finished := ws.finished
|
||||
ws.mu.Unlock()
|
||||
if finished {
|
||||
host.RemoveResource(id)
|
||||
}
|
||||
return ws
|
||||
}
|
||||
|
||||
// captureConn is called by the HTTP writer's Hijack method, before the
|
||||
// upgrader can enter any hooks. It also handles shutdown winning that race.
|
||||
func (ws *routeWebSocket) captureConn(conn net.Conn) {
|
||||
ws.mu.Lock()
|
||||
finished := ws.finished
|
||||
if !finished {
|
||||
ws.netConn = conn
|
||||
}
|
||||
ws.mu.Unlock()
|
||||
if finished && conn != nil {
|
||||
_ = conn.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// attach runs on the HTTP pump, never the VM loop: GetConn takes SafeConn's
|
||||
// write mutex. The underlying Gorilla connection is private to these pumps.
|
||||
func (ws *routeWebSocket) attach(conn *connection.SafeConn) bool {
|
||||
if conn == nil {
|
||||
return false
|
||||
}
|
||||
raw := conn.GetConn()
|
||||
raw.SetReadLimit(maxWSFrameBytes)
|
||||
raw.SetCloseHandler(func(code int, reason string) error {
|
||||
ws.mu.Lock()
|
||||
if !ws.finished {
|
||||
ws.peerClosed, ws.peerCode, ws.peerReason = true, code, reason
|
||||
// The peer cannot consume further data. Drop queued frames and
|
||||
// prioritize the close reply (the in-flight writer stays counted).
|
||||
for _, frame := range ws.queue {
|
||||
ws.queuedCount--
|
||||
ws.queuedBytes -= len(frame.data)
|
||||
}
|
||||
ws.queue = nil
|
||||
ws.pong, ws.pongPending = "", false
|
||||
if !ws.closeRequested {
|
||||
ws.requestCloseLocked(code, reason)
|
||||
}
|
||||
}
|
||||
ws.mu.Unlock()
|
||||
ws.signal()
|
||||
return nil // the writer, not the reader, sends the close reply
|
||||
})
|
||||
raw.SetPingHandler(func(data string) error {
|
||||
ws.mu.Lock()
|
||||
if !ws.finished {
|
||||
// A ping flood must not allocate an unbounded control queue. RFC 6455
|
||||
// allows replying only to the most recently processed ping.
|
||||
ws.pong, ws.pongPending = data, true
|
||||
}
|
||||
ws.mu.Unlock()
|
||||
ws.signal()
|
||||
return nil
|
||||
})
|
||||
|
||||
ws.mu.Lock()
|
||||
if ws.finished || ws.conn != nil {
|
||||
ws.mu.Unlock()
|
||||
_ = raw.Close()
|
||||
go func() { _ = conn.Close() }()
|
||||
return false
|
||||
}
|
||||
ws.conn, ws.raw, ws.state = conn, raw, routeWSOpen
|
||||
if ws.netConn == nil {
|
||||
ws.netConn = raw.UnderlyingConn()
|
||||
}
|
||||
ws.mu.Unlock()
|
||||
return true
|
||||
}
|
||||
|
||||
// abort is a non-waiting resource callback. Close the captured network socket
|
||||
// first to unblock I/O; SafeConn.Close may wait for same-plugin hooks, so it
|
||||
// must happen separately, after the socket is already closed.
|
||||
func (ws *routeWebSocket) abort() {
|
||||
ws.finish(routeWSCloseEvent{code: websocket.CloseAbnormalClosure})
|
||||
}
|
||||
|
||||
func (ws *routeWebSocket) finish(result routeWSCloseEvent) {
|
||||
ws.mu.Lock()
|
||||
if ws.finished {
|
||||
ws.mu.Unlock()
|
||||
return
|
||||
}
|
||||
ws.finished, ws.state, ws.result = true, routeWSClosed, result
|
||||
ws.queue = nil
|
||||
ws.queuedCount, ws.queuedBytes = 0, 0
|
||||
if ws.closeTimer != nil {
|
||||
ws.closeTimer.Stop()
|
||||
}
|
||||
netConn, conn, id := ws.netConn, ws.conn, ws.resourceID
|
||||
notify := ws.started && !ws.notified
|
||||
if notify {
|
||||
ws.notified = true
|
||||
}
|
||||
ws.mu.Unlock()
|
||||
|
||||
if netConn != nil {
|
||||
_ = netConn.Close()
|
||||
}
|
||||
ws.host.RemoveResource(id)
|
||||
close(ws.done)
|
||||
if conn != nil {
|
||||
go func() { _ = conn.Close() }()
|
||||
}
|
||||
if notify {
|
||||
ws.notifyClose(result)
|
||||
}
|
||||
}
|
||||
|
||||
// start is called on a later loop turn than promise resolution. This barrier
|
||||
// lets .then/await reactions install handlers before any data or close event.
|
||||
func (ws *routeWebSocket) start() {
|
||||
ws.mu.Lock()
|
||||
if ws.started {
|
||||
ws.mu.Unlock()
|
||||
return
|
||||
}
|
||||
ws.started = true
|
||||
if ws.finished {
|
||||
notify, result := !ws.notified, ws.result
|
||||
ws.notified = true
|
||||
ws.mu.Unlock()
|
||||
if notify {
|
||||
ws.notifyClose(result)
|
||||
}
|
||||
return
|
||||
}
|
||||
attached := ws.conn != nil
|
||||
ws.mu.Unlock()
|
||||
if !attached || !ws.host.ResourcesOpen() {
|
||||
ws.abort()
|
||||
return
|
||||
}
|
||||
go ws.writePump()
|
||||
go ws.readPump()
|
||||
}
|
||||
|
||||
func (ws *routeWebSocket) signal() {
|
||||
select {
|
||||
case ws.wake <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func (ws *routeWebSocket) requestCloseLocked(code int, reason string) {
|
||||
ws.state, ws.closeRequested = routeWSClosing, true
|
||||
ws.closeCode, ws.closeReason = code, reason
|
||||
ws.closeTimer = time.AfterFunc(routeWSCloseTimeout, func() {
|
||||
ws.fail(errors.New("WebSocket close handshake timed out"))
|
||||
})
|
||||
}
|
||||
|
||||
func (ws *routeWebSocket) fail(err error) {
|
||||
ws.finish(routeWSCloseEvent{code: websocket.CloseAbnormalClosure, message: err.Error()})
|
||||
}
|
||||
|
||||
func (ws *routeWebSocket) writePump() {
|
||||
for {
|
||||
ws.mu.Lock()
|
||||
if ws.finished {
|
||||
ws.mu.Unlock()
|
||||
return
|
||||
}
|
||||
if ws.closeWritten && ws.peerClosed {
|
||||
result := routeWSCloseEvent{code: ws.peerCode, reason: ws.peerReason, clean: true}
|
||||
ws.mu.Unlock()
|
||||
ws.finish(result)
|
||||
return
|
||||
}
|
||||
var frame routeWSFrame
|
||||
control, haveFrame := false, false
|
||||
switch {
|
||||
case ws.pongPending && !ws.closeWritten:
|
||||
frame = routeWSFrame{kind: websocket.PongMessage, data: []byte(ws.pong)}
|
||||
ws.pong, ws.pongPending, control, haveFrame = "", false, true, true
|
||||
case len(ws.queue) != 0:
|
||||
frame, haveFrame = ws.queue[0], true
|
||||
ws.queue[0] = routeWSFrame{}
|
||||
ws.queue = ws.queue[1:]
|
||||
case ws.closeRequested && !ws.closeWritten:
|
||||
frame = routeWSFrame{kind: websocket.CloseMessage, data: websocket.FormatCloseMessage(ws.closeCode, ws.closeReason)}
|
||||
control, haveFrame = true, true
|
||||
}
|
||||
conn, raw := ws.conn, ws.raw
|
||||
ws.mu.Unlock()
|
||||
if !haveFrame {
|
||||
select {
|
||||
case <-ws.wake:
|
||||
case <-ws.done:
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
deadline := time.Now().Add(routeWSWriteTimeout)
|
||||
var err error
|
||||
if control {
|
||||
err = conn.WriteControl(frame.kind, frame.data, deadline)
|
||||
} else {
|
||||
err = raw.SetWriteDeadline(deadline)
|
||||
if err == nil {
|
||||
err = conn.WriteMessage(frame.kind, frame.data)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
ws.fail(err)
|
||||
return
|
||||
}
|
||||
ws.mu.Lock()
|
||||
if !ws.finished {
|
||||
if frame.kind == websocket.CloseMessage {
|
||||
ws.closeWritten = true
|
||||
} else if !control {
|
||||
ws.queuedCount--
|
||||
ws.queuedBytes -= len(frame.data)
|
||||
}
|
||||
}
|
||||
ws.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func (ws *routeWebSocket) readPump() {
|
||||
for {
|
||||
kind, data, err := ws.conn.ReadMessage()
|
||||
if err != nil {
|
||||
ws.mu.Lock()
|
||||
peerClosed := ws.peerClosed
|
||||
ws.mu.Unlock()
|
||||
if peerClosed {
|
||||
ws.signal() // the writer completes the close handshake
|
||||
} else {
|
||||
ws.fail(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if len(data) > maxWSFrameBytes {
|
||||
ws.fail(fmt.Errorf("WebSocket message exceeds %d bytes", maxWSFrameBytes))
|
||||
return
|
||||
}
|
||||
if kind != websocket.TextMessage && kind != websocket.BinaryMessage {
|
||||
continue
|
||||
}
|
||||
if kind == websocket.TextMessage && !utf8.Valid(data) {
|
||||
ws.fail(errors.New("WebSocket text message is not valid UTF-8"))
|
||||
return
|
||||
}
|
||||
ack := make(chan struct{})
|
||||
if !ws.host.RunOnLoop(func(vm *moejs.Runtime) {
|
||||
defer close(ack)
|
||||
jobErr := ws.host.RunJob(vm, "plugin WebSocket.onmessage", func() error {
|
||||
ws.mu.Lock()
|
||||
open := ws.state == routeWSOpen
|
||||
ws.mu.Unlock()
|
||||
if !open || ws.jsObject == nil {
|
||||
return nil
|
||||
}
|
||||
var value any = string(data)
|
||||
if kind == websocket.BinaryMessage {
|
||||
buffer, err := vm.Realm().NewArrayBuffer(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
value = engine.ObjectValue(buffer)
|
||||
}
|
||||
event := vm.Realm().NewObject()
|
||||
if err := native.Set(vm, event, "data", value); err != nil {
|
||||
return err
|
||||
}
|
||||
return ws.callHandler(vm, "onmessage", event)
|
||||
})
|
||||
if jobErr != nil {
|
||||
ws.fail(jobErr)
|
||||
}
|
||||
}) {
|
||||
ws.abort()
|
||||
return
|
||||
}
|
||||
// Only one inbound payload may wait for JavaScript. Cancellation must
|
||||
// also release this reader when queued jobs are discarded on shutdown.
|
||||
select {
|
||||
case <-ack:
|
||||
case <-ws.done:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// All methods below that touch jsObject or JS values run only on the host loop.
|
||||
func (ws *routeWebSocket) object(vm *moejs.Runtime) *engine.Object {
|
||||
if ws.jsObject != nil {
|
||||
return ws.jsObject
|
||||
}
|
||||
obj := vm.Realm().NewObject()
|
||||
ws.jsObject = obj
|
||||
for _, name := range []string{"onmessage", "onerror", "onclose"} {
|
||||
_ = native.Set(vm, obj, name, moejs.Null())
|
||||
}
|
||||
getter := native.Bind(vm, "get readyState", 0, func(_ *moejs.Realm, _ moejs.Value, _ []moejs.Value) (moejs.Value, error) {
|
||||
ws.mu.Lock()
|
||||
state := ws.state
|
||||
ws.mu.Unlock()
|
||||
return native.From(vm, state), nil
|
||||
})
|
||||
desc := engine.AccessorDescriptor(getter.AsObject(), nil, 0)
|
||||
desc.SetEnumerable(true)
|
||||
if err := obj.DefinePropertyOrThrow(vm.Realm(), vm.Realm().KeyFromGoString("readyState"), desc); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
_ = native.Function(vm, obj, "send", func(_ *moejs.Realm, _ moejs.Value, args []moejs.Value) (moejs.Value, error) {
|
||||
value := moejs.Arg(args, 0)
|
||||
kind := websocket.BinaryMessage
|
||||
var data []byte
|
||||
if value.IsString() {
|
||||
kind = websocket.TextMessage
|
||||
text := native.String(vm, value)
|
||||
if len(text) > maxWSFrameBytes {
|
||||
return moejs.Undefined(), native.TypeError(vm, "WebSocket message exceeds %d bytes", maxWSFrameBytes)
|
||||
}
|
||||
data = []byte(text)
|
||||
} else if value.IsObject() {
|
||||
var ok bool
|
||||
data, ok = value.AsObject().BufferData()
|
||||
if !ok {
|
||||
return moejs.Undefined(), native.TypeError(vm, "WebSocket.send requires a string, ArrayBuffer or ArrayBuffer view")
|
||||
}
|
||||
} else {
|
||||
return moejs.Undefined(), native.TypeError(vm, "WebSocket.send requires a string, ArrayBuffer or ArrayBuffer view")
|
||||
}
|
||||
if len(data) > maxWSFrameBytes {
|
||||
return moejs.Undefined(), native.TypeError(vm, "WebSocket message exceeds %d bytes", maxWSFrameBytes)
|
||||
}
|
||||
ws.mu.Lock()
|
||||
if ws.state != routeWSOpen {
|
||||
ws.mu.Unlock()
|
||||
return moejs.Undefined(), native.TypeError(vm, "WebSocket is not open")
|
||||
}
|
||||
if ws.queuedCount >= routeWSMaxQueuedFrames || len(data) > routeWSMaxQueuedBytes-ws.queuedBytes {
|
||||
ws.mu.Unlock()
|
||||
return moejs.Undefined(), native.TypeError(vm, "WebSocket send queue is full")
|
||||
}
|
||||
ws.queue = append(ws.queue, routeWSFrame{kind: kind, data: append([]byte(nil), data...)})
|
||||
ws.queuedCount++
|
||||
ws.queuedBytes += len(data)
|
||||
ws.mu.Unlock()
|
||||
ws.signal()
|
||||
return moejs.Undefined(), nil
|
||||
})
|
||||
_ = native.Function(vm, obj, "close", func(_ *moejs.Realm, _ moejs.Value, args []moejs.Value) (moejs.Value, error) {
|
||||
code := websocket.CloseNormalClosure
|
||||
if v := moejs.Arg(args, 0); !v.IsUndefined() {
|
||||
number := native.Float(vm, v)
|
||||
if math.IsNaN(number) || math.IsInf(number, 0) || math.Trunc(number) != number || (number != 1000 && (number < 3000 || number > 4999)) {
|
||||
return moejs.Undefined(), native.TypeError(vm, "WebSocket close code must be 1000 or between 3000 and 4999")
|
||||
}
|
||||
code = int(number)
|
||||
}
|
||||
reason := ""
|
||||
if v := moejs.Arg(args, 1); !v.IsUndefined() {
|
||||
reason = native.String(vm, v)
|
||||
}
|
||||
if len(reason) > 123 || !utf8.ValidString(reason) {
|
||||
return moejs.Undefined(), native.TypeError(vm, "WebSocket close reason must be valid UTF-8 and at most 123 bytes")
|
||||
}
|
||||
ws.mu.Lock()
|
||||
if ws.state == routeWSOpen {
|
||||
ws.requestCloseLocked(code, reason)
|
||||
}
|
||||
ws.mu.Unlock()
|
||||
ws.signal()
|
||||
return moejs.Undefined(), nil
|
||||
})
|
||||
return obj
|
||||
}
|
||||
|
||||
func (ws *routeWebSocket) callHandler(vm *moejs.Runtime, name string, event *engine.Object) error {
|
||||
if ws.jsObject == nil {
|
||||
return nil
|
||||
}
|
||||
callback, err := vm.Get(engine.ObjectValue(ws.jsObject), name)
|
||||
if err != nil || !engine.IsCallable(callback) {
|
||||
return err
|
||||
}
|
||||
_, err = vm.CallFunction(callback, engine.ObjectValue(ws.jsObject), engine.ObjectValue(event))
|
||||
return err
|
||||
}
|
||||
|
||||
func (ws *routeWebSocket) notifyClose(result routeWSCloseEvent) {
|
||||
ws.host.RunOnLoop(func(vm *moejs.Runtime) {
|
||||
if ws.jsObject == nil {
|
||||
return
|
||||
}
|
||||
defer func() { ws.jsObject = nil }()
|
||||
if result.message != "" {
|
||||
_ = ws.host.RunJob(vm, "plugin WebSocket.onerror", func() error {
|
||||
event := vm.Realm().NewObject()
|
||||
if err := native.Set(vm, event, "message", result.message); err != nil {
|
||||
return err
|
||||
}
|
||||
return ws.callHandler(vm, "onerror", event)
|
||||
})
|
||||
}
|
||||
// A throwing onerror must not suppress the final onclose callback.
|
||||
_ = ws.host.RunJob(vm, "plugin WebSocket.onclose", func() error {
|
||||
event := vm.Realm().NewObject()
|
||||
_ = native.Set(vm, event, "code", result.code)
|
||||
_ = native.Set(vm, event, "reason", result.reason)
|
||||
_ = native.Set(vm, event, "wasClean", result.clean)
|
||||
return ws.callHandler(vm, "onclose", event)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Calcium-Ion/moejs"
|
||||
"github.com/komari-monitor/komari/pkg/jsruntime/native"
|
||||
"github.com/komari-monitor/komari/pkg/rpc"
|
||||
)
|
||||
|
||||
// registerRPC registers a plugin-owned RPC method. The handler is a
|
||||
// JavaScript function receiving the JSON-RPC params and returning the result
|
||||
// (or throwing an Error with optional code/data). Called from the plugin's
|
||||
// own event loop during script evaluation; it takes the manager lock itself.
|
||||
func (m *Manager) registerRPC(short, method string, handler moejs.Value) error {
|
||||
method = strings.TrimSpace(method)
|
||||
if method == "" || strings.HasPrefix(method, "rpc.") {
|
||||
return fmt.Errorf("invalid RPC method name %q", method)
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
inst, ok := m.instances[short]
|
||||
if !ok {
|
||||
return fmt.Errorf("plugin %q is not loaded", short)
|
||||
}
|
||||
inst.mu.Lock()
|
||||
if inst.rpcMethods == nil {
|
||||
inst.rpcMethods = make(map[string]moejs.Value)
|
||||
}
|
||||
if _, exists := inst.rpcMethods[method]; exists {
|
||||
inst.mu.Unlock()
|
||||
return nil // already registered in this load
|
||||
}
|
||||
inst.rpcMethods[method] = handler
|
||||
inst.mu.Unlock()
|
||||
if err := rpc.Register(method, m.rpcHandler(short, method)); err != nil {
|
||||
inst.mu.Lock()
|
||||
delete(inst.rpcMethods, method)
|
||||
inst.mu.Unlock()
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// rpcHandler bridges one registered RPC call into the plugin event loop.
|
||||
func (m *Manager) rpcHandler(short, method string) rpc.Handler {
|
||||
return func(ctx context.Context, req *rpc.JsonRpcRequest) (any, *rpc.JsonRpcError) {
|
||||
inst := m.instanceFor(short)
|
||||
if inst == nil {
|
||||
return nil, &rpc.JsonRpcError{Code: rpc.Unavailable, Message: fmt.Sprintf("plugin %q is not loaded", short)}
|
||||
}
|
||||
inst.mu.RLock()
|
||||
handler := inst.rpcMethods[method]
|
||||
host := inst.host
|
||||
alive := inst.runtime != nil
|
||||
inst.mu.RUnlock()
|
||||
if !alive || handler.IsUndefined() || host == nil {
|
||||
return nil, &rpc.JsonRpcError{Code: rpc.MethodNotFound, Message: "method not found", Data: method}
|
||||
}
|
||||
|
||||
resultCh := make(chan any, 1)
|
||||
errCh := make(chan *rpc.JsonRpcError, 1)
|
||||
queued := host.RunOnLoop(func(vm *moejs.Runtime) {
|
||||
_ = host.RunJob(vm, "plugin rpc "+method, func() error {
|
||||
params := native.From(vm, req.Params)
|
||||
if params.IsUndefined() {
|
||||
params = moejs.Null()
|
||||
}
|
||||
value, callErr := vm.CallFunction(handler, moejs.Undefined(), params)
|
||||
if callErr != nil {
|
||||
errCh <- jsErrorToRPCError(vm, callErr)
|
||||
return nil // error is captured, do not double-report
|
||||
}
|
||||
exported, exportErr := exportJSValue(vm, value)
|
||||
if exportErr != nil {
|
||||
errCh <- &rpc.JsonRpcError{Code: rpc.InternalError, Message: exportErr.Error()}
|
||||
return nil
|
||||
}
|
||||
resultCh <- exported
|
||||
return nil
|
||||
})
|
||||
})
|
||||
if !queued {
|
||||
return nil, &rpc.JsonRpcError{Code: rpc.Unavailable, Message: "plugin runtime is closed"}
|
||||
}
|
||||
timer := time.NewTimer(host.Timeout())
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case result := <-resultCh:
|
||||
return result, nil
|
||||
case rpcErr := <-errCh:
|
||||
return nil, rpcErr
|
||||
case <-timer.C:
|
||||
return nil, &rpc.JsonRpcError{Code: rpc.DeadlineExceeded, Message: "plugin RPC handler timed out"}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// exportJSValue exports a native JavaScript value into a JSON-RPC result. Circular
|
||||
// structures and other export failures become an error instead of panicking
|
||||
// inside the event loop.
|
||||
func exportJSValue(vm *moejs.Runtime, value moejs.Value) (result any, err error) {
|
||||
defer func() {
|
||||
if recovered := recover(); recovered != nil {
|
||||
result = nil
|
||||
err = fmt.Errorf("export JavaScript value: %v", recovered)
|
||||
}
|
||||
}()
|
||||
return vm.ToGo(value)
|
||||
}
|
||||
|
||||
// jsErrorToRPCError converts a thrown JavaScript error into a JSON-RPC error,
|
||||
// honoring optional code/data properties.
|
||||
func jsErrorToRPCError(vm *moejs.Runtime, err error) *rpc.JsonRpcError {
|
||||
code := rpc.InternalError
|
||||
message := err.Error()
|
||||
var data any
|
||||
var exception *moejs.Exception
|
||||
if errors.As(err, &exception) {
|
||||
if exception.Value.IsObject() {
|
||||
obj := exception.Value.AsObject()
|
||||
if value := native.Get(vm, obj, "code"); !value.IsUndefined() {
|
||||
if n := native.Int(vm, value); n != 0 {
|
||||
code = int(n)
|
||||
}
|
||||
}
|
||||
if value := native.Get(vm, obj, "message"); !value.IsUndefined() && native.String(vm, value) != "" {
|
||||
message = native.String(vm, value)
|
||||
}
|
||||
if value := native.Get(vm, obj, "data"); !value.IsUndefined() {
|
||||
if exported, exportErr := exportJSValue(vm, value); exportErr == nil {
|
||||
data = exported
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return &rpc.JsonRpcError{Code: code, Message: message, Data: data}
|
||||
}
|
||||
@@ -0,0 +1,907 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/Calcium-Ion/moejs"
|
||||
"github.com/Calcium-Ion/moejs/engine"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/komari-monitor/komari/database/models"
|
||||
"github.com/komari-monitor/komari/pkg/jsruntime"
|
||||
"github.com/komari-monitor/komari/pkg/jsruntime/httpbody"
|
||||
"github.com/komari-monitor/komari/pkg/jsruntime/native"
|
||||
"github.com/komari-monitor/komari/pkg/jsruntime/require"
|
||||
"github.com/komari-monitor/komari/pkg/rpc"
|
||||
"github.com/komari-monitor/komari/web/api"
|
||||
)
|
||||
|
||||
// registerServerModule registers the "server" native module for one plugin
|
||||
// instance. The module exposes:
|
||||
//
|
||||
// server.route(method, path, handler) register an HTTP route on the host
|
||||
// engine; handler receives (req, res)
|
||||
// and finishes via res.end() or an
|
||||
// awaited req.upgradeWebSocket()
|
||||
// server.static(path, dir, opts) serve a folder from the plugin
|
||||
// directory at a mount path; opts.spa
|
||||
// makes unmatched paths fall back to
|
||||
// index.html
|
||||
// server.hook(kind, fn) register a request or response hook;
|
||||
// an optional "METHOD /path", "/path"
|
||||
// or "/path/*" filter limits the hook
|
||||
// to matching requests. The ws kinds
|
||||
// (wsConnect / wsMessage / wsSend /
|
||||
// wsClose) intercept WebSocket
|
||||
// connections and frames on matching
|
||||
// endpoints; they share the
|
||||
// allowHooks permission and accept
|
||||
// only path matchers
|
||||
// server.injectHTML(head, body) register HTML fragments embedded
|
||||
// into every text/html response:
|
||||
// head before </head>, body before
|
||||
// </body> (all pages, including the
|
||||
// admin and terminal pages)
|
||||
// server.call(method, params...) call a registered RPC method with
|
||||
// admin authority; resolves to the RPC
|
||||
// result or rejects with an Error
|
||||
// carrying code/message/data
|
||||
// server.registerRPC(method, handler) register a plugin-owned RPC method
|
||||
// server.registerNotificationChannel(id, configuration, handler)
|
||||
// register a notification channel
|
||||
// server.getConfig() resolve the saved plugin configuration
|
||||
// server.cron(expr, fn) run fn on the plugin event loop each
|
||||
// time the cron expression fires
|
||||
//
|
||||
// server.registerRPC, server.getConfig, server.cron and filesystem access
|
||||
// confined to the plugin directory are always granted without a manifest
|
||||
// declaration.
|
||||
// server.route, server.hook, server.injectHTML and server.call require the
|
||||
// allowRoutes, allowHooks, allowHTMLInject and allowSystemRPC permissions
|
||||
// respectively; a missing permission fails at load time (route/hook/inject)
|
||||
// or rejects the call.
|
||||
//
|
||||
// The host engine keeps a registered route slot after unload; requests then
|
||||
// receive 404 until the plugin is loaded again.
|
||||
func (m *Manager) registerServerModule(host *jsruntime.Host, registry *require.Registry, inst *Instance) {
|
||||
registry.RegisterNativeModule("server", func(vm *moejs.Runtime, module *engine.Object) {
|
||||
exports := vm.Realm().NewObject()
|
||||
_ = native.Function(vm, exports, "route", func(_ *moejs.Realm, _ moejs.Value, args []moejs.Value) (moejs.Value, error) {
|
||||
method := strings.ToUpper(strings.TrimSpace(native.String(vm, moejs.Arg(args, 0))))
|
||||
path := native.String(vm, moejs.Arg(args, 1))
|
||||
handler := moejs.Arg(args, 2)
|
||||
ok := engine.IsCallable(handler)
|
||||
if method == "" {
|
||||
return moejs.Undefined(), native.TypeError(vm, "server.route requires an HTTP method")
|
||||
}
|
||||
if path == "" || !strings.HasPrefix(path, "/") {
|
||||
return moejs.Undefined(), native.TypeError(vm, "server.route requires a path starting with /")
|
||||
}
|
||||
if !ok {
|
||||
return moejs.Undefined(), native.TypeError(vm, "server.route requires a function handler")
|
||||
}
|
||||
if !inst.info.Permissions.AllowRoutes {
|
||||
return moejs.Undefined(), native.TypeError(vm, "server.route requires the \"route\" permission (allowRoutes)")
|
||||
}
|
||||
if err := m.registerRoute(inst.info.Short, method, path, handler); err != nil {
|
||||
return moejs.Undefined(), err
|
||||
}
|
||||
return moejs.Undefined(), nil
|
||||
})
|
||||
_ = native.Function(vm, exports, "static", func(_ *moejs.Realm, _ moejs.Value, args []moejs.Value) (moejs.Value, error) {
|
||||
mount := strings.TrimSpace(native.String(vm, moejs.Arg(args, 0)))
|
||||
mount = strings.TrimRight(mount, "/")
|
||||
dir := strings.TrimSpace(native.String(vm, moejs.Arg(args, 1)))
|
||||
if mount == "" || mount == "/" || !strings.HasPrefix(mount, "/") {
|
||||
return moejs.Undefined(), native.TypeError(vm, "server.static requires a mount path starting with / (and not \"/\")")
|
||||
}
|
||||
if dir == "" {
|
||||
return moejs.Undefined(), native.TypeError(vm, "server.static requires a folder name")
|
||||
}
|
||||
if !filepath.IsLocal(dir) {
|
||||
return moejs.Undefined(), native.TypeError(vm, "server.static folder must be a relative path inside the plugin directory")
|
||||
}
|
||||
if !inst.info.Permissions.AllowRoutes {
|
||||
return moejs.Undefined(), native.TypeError(vm, "server.static requires the \"route\" permission (allowRoutes)")
|
||||
}
|
||||
var opts map[string]any
|
||||
if !moejs.Arg(args, 2).IsUndefined() && !moejs.Arg(args, 2).IsNull() {
|
||||
if err := native.ExportTo(vm, moejs.Arg(args, 2), &opts); err != nil {
|
||||
return moejs.Undefined(), native.TypeError(vm, "server.static options must be an object like { spa: true }")
|
||||
}
|
||||
}
|
||||
spa := false
|
||||
if v, ok := opts["spa"]; ok {
|
||||
spa, _ = v.(bool)
|
||||
}
|
||||
if err := m.registerStatic(inst.info.Short, mount, dir, spa); err != nil {
|
||||
return moejs.Undefined(), err
|
||||
}
|
||||
return moejs.Undefined(), nil
|
||||
})
|
||||
_ = native.Function(vm, exports, "call", func(_ *moejs.Realm, _ moejs.Value, args []moejs.Value) (moejs.Value, error) {
|
||||
method := strings.TrimSpace(native.String(vm, moejs.Arg(args, 0)))
|
||||
if method == "" {
|
||||
return moejs.Undefined(), native.TypeError(vm, "server.call requires an RPC method name")
|
||||
}
|
||||
params := callParams(vm, args)
|
||||
promise, resolve, reject := vm.NewPromise()
|
||||
go func() {
|
||||
if !inst.info.Permissions.AllowSystemRPC {
|
||||
host.RunOnLoop(func(vm *moejs.Runtime) {
|
||||
_ = reject(native.GoError(vm, errors.New("server.call requires the \"system RPC\" permission (allowSystemRPC)")))
|
||||
})
|
||||
return
|
||||
}
|
||||
meta := &rpc.ContextMeta{Permission: rpc.RoleAdmin, Principal: rpc.PrincipalFromRole(rpc.RoleAdmin)}
|
||||
ctx := rpc.NewContextWithMeta(context.Background(), meta)
|
||||
resp := rpc.CallWithContext(ctx, nil, method, params)
|
||||
host.RunOnLoop(func(vm *moejs.Runtime) {
|
||||
if resp.Error != nil {
|
||||
_ = reject(engine.ObjectValue(jsRPCError(vm, resp.Error)))
|
||||
return
|
||||
}
|
||||
result, err := normalizeRPCValue(resp.Result)
|
||||
if err != nil {
|
||||
_ = reject(native.GoError(vm, err))
|
||||
return
|
||||
}
|
||||
_ = resolve(native.From(vm, result))
|
||||
})
|
||||
}()
|
||||
return promise, nil
|
||||
})
|
||||
_ = native.Function(vm, exports, "hook", func(_ *moejs.Realm, _ moejs.Value, args []moejs.Value) (moejs.Value, error) {
|
||||
kind := strings.ToLower(strings.TrimSpace(native.String(vm, moejs.Arg(args, 0))))
|
||||
switch kind {
|
||||
case "request", "response", string(hookWSConnect), string(hookWSMessage), string(hookWSSend), string(hookWSClose):
|
||||
if !inst.info.Permissions.AllowHooks {
|
||||
return moejs.Undefined(), native.TypeError(vm, "server.hook requires the \"hook\" permission (allowHooks)")
|
||||
}
|
||||
default:
|
||||
return moejs.Undefined(), native.TypeError(vm, "server.hook kind must be \"request\", \"response\", \"wsConnect\", \"wsMessage\", \"wsSend\" or \"wsClose\"")
|
||||
}
|
||||
// server.hook(kind, fn) or server.hook(kind, matcher, fn) where
|
||||
// matcher is "METHOD /path", "/path" or "/path/*". The ws kinds
|
||||
// only accept a path matcher: every upgrade is a GET request.
|
||||
fnValue := moejs.Arg(args, 1)
|
||||
var matcher *hookMatcher
|
||||
if fnValue.IsString() {
|
||||
parsed, err := parseHookMatcher(native.String(vm, fnValue))
|
||||
if err != nil {
|
||||
return moejs.Undefined(), native.TypeError(vm, "%s", err.Error())
|
||||
}
|
||||
if isWSHookKind(kind) && parsed.method != "" {
|
||||
return moejs.Undefined(), native.TypeError(vm, "server.hook %q only accepts a path matcher like /api/*", kind)
|
||||
}
|
||||
matcher = parsed
|
||||
fnValue = moejs.Arg(args, 2)
|
||||
}
|
||||
fn := fnValue
|
||||
ok := engine.IsCallable(fn)
|
||||
if !ok {
|
||||
return moejs.Undefined(), native.TypeError(vm, "server.hook requires a function handler")
|
||||
}
|
||||
m.registerHook(inst.info.Short, hookKind(kind), fn, matcher)
|
||||
return moejs.Undefined(), nil
|
||||
})
|
||||
_ = native.Function(vm, exports, "injectHTML", func(_ *moejs.Realm, _ moejs.Value, args []moejs.Value) (moejs.Value, error) {
|
||||
if !inst.info.Permissions.AllowHTMLInject {
|
||||
return moejs.Undefined(), native.TypeError(vm, "server.injectHTML requires the \"HTML inject\" permission (allowHTMLInject)")
|
||||
}
|
||||
m.registerInject(inst.info.Short, native.String(vm, moejs.Arg(args, 0)), native.String(vm, moejs.Arg(args, 1)))
|
||||
return moejs.Undefined(), nil
|
||||
})
|
||||
_ = native.Function(vm, exports, "cron", func(_ *moejs.Realm, _ moejs.Value, args []moejs.Value) (moejs.Value, error) {
|
||||
spec := strings.TrimSpace(native.String(vm, moejs.Arg(args, 0)))
|
||||
fn := moejs.Arg(args, 1)
|
||||
ok := engine.IsCallable(fn)
|
||||
if spec == "" {
|
||||
return moejs.Undefined(), native.TypeError(vm, "server.cron requires a cron expression")
|
||||
}
|
||||
if !ok {
|
||||
return moejs.Undefined(), native.TypeError(vm, "server.cron requires a function handler")
|
||||
}
|
||||
if err := m.registerCron(inst.info.Short, spec, fn); err != nil {
|
||||
return moejs.Undefined(), err
|
||||
}
|
||||
return moejs.Undefined(), nil
|
||||
})
|
||||
_ = native.Function(vm, exports, "registerRPC", func(_ *moejs.Realm, _ moejs.Value, args []moejs.Value) (moejs.Value, error) {
|
||||
method := strings.TrimSpace(native.String(vm, moejs.Arg(args, 0)))
|
||||
fn := moejs.Arg(args, 1)
|
||||
ok := engine.IsCallable(fn)
|
||||
if method == "" {
|
||||
return moejs.Undefined(), native.TypeError(vm, "server.registerRPC requires an RPC method name")
|
||||
}
|
||||
if !ok {
|
||||
return moejs.Undefined(), native.TypeError(vm, "server.registerRPC requires a function handler")
|
||||
}
|
||||
if err := m.registerRPC(inst.info.Short, method, fn); err != nil {
|
||||
return moejs.Undefined(), err
|
||||
}
|
||||
return moejs.Undefined(), nil
|
||||
})
|
||||
_ = native.Function(vm, exports, "registerNotificationChannel", func(_ *moejs.Realm, _ moejs.Value, args []moejs.Value) (moejs.Value, error) {
|
||||
id := strings.TrimSpace(native.String(vm, moejs.Arg(args, 0)))
|
||||
handler := moejs.Arg(args, 2)
|
||||
ok := engine.IsCallable(handler)
|
||||
if id == "" {
|
||||
return moejs.Undefined(), native.TypeError(vm, "server.registerNotificationChannel requires a channel id")
|
||||
}
|
||||
if !ok {
|
||||
return moejs.Undefined(), native.TypeError(vm, "server.registerNotificationChannel requires a function handler")
|
||||
}
|
||||
rawConfiguration, err := exportJSValue(vm, moejs.Arg(args, 1))
|
||||
if err != nil {
|
||||
return moejs.Undefined(), native.TypeError(vm, "server.registerNotificationChannel requires a managed configuration object")
|
||||
}
|
||||
configurationJSON, err := json.Marshal(rawConfiguration)
|
||||
if err != nil {
|
||||
return moejs.Undefined(), native.TypeError(vm, "server.registerNotificationChannel requires a managed configuration object")
|
||||
}
|
||||
var configuration models.Configuration
|
||||
if err := json.Unmarshal(configurationJSON, &configuration); err != nil {
|
||||
return moejs.Undefined(), native.TypeError(vm, "server.registerNotificationChannel requires a managed configuration object")
|
||||
}
|
||||
if err := m.registerNotificationChannel(inst.info.Short, id, configuration, handler, host); err != nil {
|
||||
return moejs.Undefined(), err
|
||||
}
|
||||
return moejs.Undefined(), nil
|
||||
})
|
||||
_ = native.Function(vm, exports, "getConfig", func(_ *moejs.Realm, _ moejs.Value, args []moejs.Value) (moejs.Value, error) {
|
||||
promise, resolve, reject := vm.NewPromise()
|
||||
go func() {
|
||||
data, err := GetConfiguration(inst.info.Short)
|
||||
host.RunOnLoop(func(vm *moejs.Runtime) {
|
||||
if err != nil {
|
||||
_ = reject(native.GoError(vm, err))
|
||||
return
|
||||
}
|
||||
_ = resolve(native.From(vm, data))
|
||||
})
|
||||
}()
|
||||
return promise, nil
|
||||
})
|
||||
_ = native.Set(vm, module, "exports", exports)
|
||||
})
|
||||
}
|
||||
|
||||
// callParams converts JS arguments into JSON-RPC params: no params, a single
|
||||
// value, or a positional array. Values that cannot be exported (for example
|
||||
// circular objects) are skipped so the call rejects instead of panicking.
|
||||
func callParams(vm *moejs.Runtime, args []moejs.Value) any {
|
||||
switch n := len(args); {
|
||||
case n <= 1:
|
||||
return nil
|
||||
case n == 2:
|
||||
exported, _ := exportJSValue(vm, moejs.Arg(args, 1))
|
||||
return exported
|
||||
default:
|
||||
params := make([]any, 0, n-1)
|
||||
for i := 1; i < n; i++ {
|
||||
exported, err := exportJSValue(vm, moejs.Arg(args, i))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
params = append(params, exported)
|
||||
}
|
||||
return params
|
||||
}
|
||||
}
|
||||
|
||||
func jsRPCError(vm *moejs.Runtime, e *rpc.JsonRpcError) *engine.Object {
|
||||
obj := native.Object(vm, native.GoError(vm, fmt.Errorf("%s", e.Message)))
|
||||
_ = native.Set(vm, obj, "code", e.Code)
|
||||
if e.Data != nil {
|
||||
_ = native.Set(vm, obj, "data", e.Data)
|
||||
}
|
||||
return obj
|
||||
}
|
||||
|
||||
func normalizeRPCValue(value any) (any, error) {
|
||||
data, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("serialize RPC result: %w", err)
|
||||
}
|
||||
|
||||
var normalized any
|
||||
if err := json.Unmarshal(data, &normalized); err != nil {
|
||||
return nil, fmt.Errorf("decode RPC result: %w", err)
|
||||
}
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
// registerRoute registers (or reuses) a gin route slot for one plugin. It is
|
||||
// called from the plugin's own event loop during script evaluation and takes
|
||||
// the manager lock itself. The critical section is short and never waits on
|
||||
// the event loop, so no lock cycle is possible.
|
||||
func (m *Manager) registerRoute(short, method, path string, handler moejs.Value) (err error) {
|
||||
key := method + " " + path
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
inst, ok := m.instances[short]
|
||||
if !ok {
|
||||
return fmt.Errorf("plugin %q is not loaded", short)
|
||||
}
|
||||
if m.engine == nil {
|
||||
return fmt.Errorf("plugin manager has no HTTP engine")
|
||||
}
|
||||
if m.routes[short] == nil {
|
||||
m.routes[short] = make(map[string]bool)
|
||||
}
|
||||
slotExists := m.routes[short][key]
|
||||
inst.mu.Lock()
|
||||
// A missing map entry yields a zero moejs.Value, not JavaScript undefined.
|
||||
if _, registered := inst.handlers[key]; !slotExists && registered {
|
||||
inst.mu.Unlock()
|
||||
return nil // already registered in this load
|
||||
}
|
||||
inst.handlers[key] = handler
|
||||
inst.mu.Unlock()
|
||||
if slotExists {
|
||||
return nil // gin slot from an earlier load stays; the handler slot is refreshed
|
||||
}
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
inst.mu.Lock()
|
||||
delete(inst.handlers, key)
|
||||
inst.mu.Unlock()
|
||||
err = fmt.Errorf("register route %s %s: %v", method, path, r)
|
||||
}
|
||||
}()
|
||||
m.engine.Handle(method, path, m.routeHandler(short))
|
||||
m.routes[short][key] = true
|
||||
return nil
|
||||
}
|
||||
|
||||
// routeHandler bridges one gin request into the plugin event loop. The
|
||||
// handler receives (req, res); the response is written once res.end() runs
|
||||
// or the runtime timeout elapses.
|
||||
func (m *Manager) routeHandler(short string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
inst := m.instanceFor(short)
|
||||
if inst == nil {
|
||||
c.Status(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
key := c.Request.Method + " " + c.FullPath()
|
||||
inst.mu.RLock()
|
||||
handler := inst.handlers[key]
|
||||
host := inst.host
|
||||
alive := inst.runtime != nil
|
||||
limit := inst.info.Permissions.MaxHTTPBodyBytes
|
||||
inst.mu.RUnlock()
|
||||
if !alive || handler.IsUndefined() || host == nil {
|
||||
c.Status(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if limit < 1 {
|
||||
limit = defaultMaxHTTPBodyBytes
|
||||
}
|
||||
body, err := httpbody.ReadAll(c.Request.Body, limit)
|
||||
if err != nil {
|
||||
c.Status(http.StatusRequestEntityTooLarge)
|
||||
_, _ = c.Writer.WriteString(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
state := &routeResponse{
|
||||
headers: make(http.Header),
|
||||
statusCode: http.StatusOK,
|
||||
done: make(chan struct{}),
|
||||
chunks: make(chan []byte, 4),
|
||||
abortCh: make(chan struct{}),
|
||||
}
|
||||
state.upgrades = make(chan *routeUpgrade, 1)
|
||||
defer state.seal()
|
||||
// Gin recycles c when the request returns, including on a timeout while
|
||||
// this turn is still queued. Only the HTTP pump uses the original.
|
||||
snapshot := c.Copy()
|
||||
snapshot.Request = c.Request.Clone(c.Request.Context())
|
||||
if principal := rpcPrincipalFromGin(c); principal != nil {
|
||||
copy := *principal
|
||||
copy.Roles = append([]string(nil), principal.Roles...)
|
||||
snapshot.Set(principalContextKey, ©)
|
||||
}
|
||||
queued := host.RunOnLoop(func(vm *moejs.Runtime) {
|
||||
req := routeRequest(vm, snapshot, body)
|
||||
res := routeResponseObject(vm, state)
|
||||
bindRouteUpgrade(vm, req, res, state, host, snapshot.Request)
|
||||
runErr := host.RunJob(vm, "plugin route "+short, func() error {
|
||||
_, callErr := vm.CallFunction(handler, moejs.Undefined(), engine.ObjectValue(req), engine.ObjectValue(res))
|
||||
return callErr
|
||||
})
|
||||
if runErr != nil {
|
||||
state.finish(http.StatusInternalServerError, nil)
|
||||
}
|
||||
})
|
||||
if !queued {
|
||||
c.Status(http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
writeFinal := func(status int, body []byte) {
|
||||
state.mu.Lock()
|
||||
if status != 0 {
|
||||
state.statusCode = status
|
||||
}
|
||||
if body != nil {
|
||||
_, _ = state.body.Write(body)
|
||||
}
|
||||
for name, values := range state.headers {
|
||||
for _, value := range values {
|
||||
c.Header(name, value)
|
||||
}
|
||||
}
|
||||
statusCode := state.statusCode
|
||||
responseBody := append([]byte(nil), state.body.Bytes()...)
|
||||
state.mu.Unlock()
|
||||
c.Status(statusCode)
|
||||
_, _ = c.Writer.Write(responseBody)
|
||||
}
|
||||
|
||||
// Streaming pump: with res.streaming = true every res.write is sent back
|
||||
// to the client and flushed immediately; normal mode buffers and writes
|
||||
// once at res.end(). When the client disconnects, aborted is set so the
|
||||
// plugin script can stop producing via res.isAborted().
|
||||
idle := time.NewTimer(host.Timeout())
|
||||
defer idle.Stop()
|
||||
committed := false
|
||||
for {
|
||||
select {
|
||||
case upgrade := <-state.upgrades:
|
||||
state.mu.Lock()
|
||||
state.sealed = true
|
||||
state.upgradeClaimed = true
|
||||
state.mu.Unlock()
|
||||
idle.Stop()
|
||||
if c.Request.Context().Err() != nil || !host.ResourcesOpen() {
|
||||
upgrade.ws.abort()
|
||||
upgrade.complete(errors.New("WebSocket upgrade canceled"))
|
||||
return
|
||||
}
|
||||
stopCancel := context.AfterFunc(c.Request.Context(), upgrade.ws.abort)
|
||||
original := c.Writer
|
||||
c.Writer = &routeUpgradeWriter{ResponseWriter: original, ws: upgrade.ws}
|
||||
socket, upgradeErr := api.UpgradeSafeConn(c, func(upgrader *websocket.Upgrader) {
|
||||
upgrader.HandshakeTimeout = host.Timeout()
|
||||
})
|
||||
c.Writer = original
|
||||
stopCancel()
|
||||
if upgradeErr != nil {
|
||||
upgrade.ws.abort()
|
||||
upgrade.complete(upgradeErr)
|
||||
return
|
||||
}
|
||||
if !upgrade.ws.attach(socket) {
|
||||
upgrade.complete(errors.New("WebSocket upgrade canceled"))
|
||||
return
|
||||
}
|
||||
upgrade.complete(nil)
|
||||
return
|
||||
case chunk := <-state.chunks:
|
||||
if !committed {
|
||||
state.mu.Lock()
|
||||
for name, values := range state.headers {
|
||||
for _, value := range values {
|
||||
c.Header(name, value)
|
||||
}
|
||||
}
|
||||
statusCode := state.statusCode
|
||||
state.mu.Unlock()
|
||||
c.Status(statusCode)
|
||||
committed = true
|
||||
}
|
||||
_, _ = c.Writer.Write(chunk)
|
||||
if flusher, ok := c.Writer.(http.Flusher); ok {
|
||||
flusher.Flush()
|
||||
}
|
||||
if !idle.Stop() {
|
||||
select {
|
||||
case <-idle.C:
|
||||
default:
|
||||
}
|
||||
}
|
||||
idle.Reset(host.Timeout())
|
||||
case <-state.done:
|
||||
state.seal()
|
||||
if !committed {
|
||||
writeFinal(0, nil)
|
||||
}
|
||||
return
|
||||
case <-c.Request.Context().Done():
|
||||
state.seal()
|
||||
return
|
||||
case <-idle.C:
|
||||
state.seal()
|
||||
if state.isStreaming() {
|
||||
// Streaming idle timeout: end the stream and notify JS
|
||||
return
|
||||
}
|
||||
writeFinal(http.StatusGatewayTimeout, []byte("plugin route handler timed out"))
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// staticConfig is the current static folder config of one mount path. It is
|
||||
// stored on the Instance so the gin slot (which survives unload) always reads
|
||||
// the config of the latest load.
|
||||
type staticConfig struct {
|
||||
dir string // absolute folder path inside the plugin directory
|
||||
spa bool // fall back to index.html for unmatched paths
|
||||
}
|
||||
|
||||
// registerStatic serves a folder from the plugin directory at a mount path.
|
||||
// It registers GET and HEAD slots for both the mount path and its wildcard
|
||||
// so /ui and /ui/anything both reach the folder. Like registerRoute, the gin
|
||||
// slots survive unload (they return 404) and are reused on reload; the
|
||||
// handler always resolves the current config from the instance.
|
||||
func (m *Manager) registerStatic(short, mount, dir string, spa bool) (err error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
inst, ok := m.instances[short]
|
||||
if !ok {
|
||||
return fmt.Errorf("plugin %q is not loaded", short)
|
||||
}
|
||||
if m.engine == nil {
|
||||
return fmt.Errorf("plugin manager has no HTTP engine")
|
||||
}
|
||||
absDir := filepath.Join(inst.dir, dir)
|
||||
if !withinDir(absDir, inst.dir) {
|
||||
return fmt.Errorf("static folder %q must be inside the plugin directory", dir)
|
||||
}
|
||||
fi, statErr := os.Stat(absDir)
|
||||
if statErr != nil || !fi.IsDir() {
|
||||
return fmt.Errorf("static folder %q does not exist", dir)
|
||||
}
|
||||
if m.routes[short] == nil {
|
||||
m.routes[short] = make(map[string]bool)
|
||||
}
|
||||
inst.mu.Lock()
|
||||
inst.statics[mount] = &staticConfig{dir: absDir, spa: spa}
|
||||
inst.mu.Unlock()
|
||||
|
||||
key := "STATIC " + mount
|
||||
if m.routes[short][key] {
|
||||
return nil // gin slots from an earlier load stay; the config was refreshed above
|
||||
}
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
inst.mu.Lock()
|
||||
delete(inst.statics, mount)
|
||||
inst.mu.Unlock()
|
||||
err = fmt.Errorf("register static %s: %v", mount, r)
|
||||
}
|
||||
}()
|
||||
handler := m.staticHandler(short, mount)
|
||||
for _, method := range []string{http.MethodGet, http.MethodHead} {
|
||||
m.engine.Handle(method, mount, handler)
|
||||
m.engine.Handle(method, mount+"/*filepath", handler)
|
||||
}
|
||||
m.routes[short][key] = true
|
||||
return nil
|
||||
}
|
||||
|
||||
// staticHandler serves files from a static folder. Requests to the mount
|
||||
// path resolve to index.html; requests into subpaths resolve to the matching
|
||||
// file. With spa enabled, any unmatched path (including directories without
|
||||
// an index.html) falls back to index.html so client-side routers work.
|
||||
func (m *Manager) staticHandler(short, mount string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
inst := m.instanceFor(short)
|
||||
if inst == nil {
|
||||
c.Status(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
inst.mu.RLock()
|
||||
cfg := inst.statics[mount]
|
||||
inst.mu.RUnlock()
|
||||
if cfg == nil {
|
||||
c.Status(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
name := strings.TrimPrefix(c.Request.URL.Path, mount)
|
||||
name = strings.TrimPrefix(name, "/")
|
||||
target, err := resolveStaticFile(cfg.dir, name)
|
||||
if err != nil {
|
||||
if !cfg.spa {
|
||||
c.Status(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
target = filepath.Join(cfg.dir, "index.html")
|
||||
}
|
||||
c.File(target)
|
||||
}
|
||||
}
|
||||
|
||||
// resolveStaticFile maps a request path to a file inside a static folder,
|
||||
// rejecting traversal. A directory resolves to its index.html.
|
||||
func resolveStaticFile(dir, name string) (string, error) {
|
||||
if name == "" {
|
||||
name = "index.html"
|
||||
}
|
||||
if !filepath.IsLocal(name) {
|
||||
return "", fmt.Errorf("invalid static path %q", name)
|
||||
}
|
||||
full := filepath.Join(dir, name)
|
||||
if !withinDir(full, dir) {
|
||||
return "", fmt.Errorf("invalid static path %q", name)
|
||||
}
|
||||
fi, err := os.Stat(full)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if fi.IsDir() {
|
||||
full = filepath.Join(full, "index.html")
|
||||
if _, err := os.Stat(full); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
return full, nil
|
||||
}
|
||||
|
||||
// routeResponse is the Go side of the JS response object. It mirrors the
|
||||
// minimal nodeHTTPResponse pattern from the jsruntime http module. When
|
||||
// res.streaming is enabled, chunks are pushed through a channel and written
|
||||
// back to the client immediately instead of being buffered.
|
||||
type routeResponse struct {
|
||||
mu sync.Mutex
|
||||
headers http.Header
|
||||
body bytes.Buffer
|
||||
statusCode int
|
||||
ended bool
|
||||
done chan struct{}
|
||||
|
||||
streaming bool
|
||||
chunks chan []byte
|
||||
aborted atomic.Bool
|
||||
abortCh chan struct{}
|
||||
|
||||
wrote bool
|
||||
sealed bool
|
||||
upgradeClaimed bool
|
||||
upgrade *routeUpgrade
|
||||
upgrades chan *routeUpgrade
|
||||
}
|
||||
|
||||
// routeUpgrade keeps the Promise on its original loop. No network goroutine
|
||||
// touches the resolver or socket object directly.
|
||||
type routeUpgrade struct {
|
||||
ws *routeWebSocket
|
||||
complete func(error)
|
||||
}
|
||||
|
||||
type routeUpgradeWriter struct {
|
||||
gin.ResponseWriter
|
||||
ws *routeWebSocket
|
||||
}
|
||||
|
||||
func (w *routeUpgradeWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
|
||||
conn, rw, err := w.ResponseWriter.Hijack()
|
||||
if err == nil {
|
||||
w.ws.captureConn(conn)
|
||||
}
|
||||
return conn, rw, err
|
||||
}
|
||||
|
||||
func bindRouteUpgrade(vm *moejs.Runtime, req, res *engine.Object, state *routeResponse, host *jsruntime.Host, request *http.Request) {
|
||||
_ = native.Function(vm, req, "upgradeWebSocket", func(_ *moejs.Realm, _ moejs.Value, _ []moejs.Value) (moejs.Value, error) {
|
||||
promise, resolve, reject := vm.NewPromise()
|
||||
fail := func(message string) (moejs.Value, error) {
|
||||
_ = reject(native.GoError(vm, errors.New(message)))
|
||||
return native.From(vm, promise), nil
|
||||
}
|
||||
streaming := native.Bool(native.Get(vm, res, "streaming"))
|
||||
state.mu.Lock()
|
||||
if state.sealed || state.ended || state.upgrade != nil || state.wrote || state.streaming || streaming {
|
||||
state.mu.Unlock()
|
||||
return fail("HTTP response is no longer available for WebSocket upgrade")
|
||||
}
|
||||
if request.Method != http.MethodGet || !websocket.IsWebSocketUpgrade(request) {
|
||||
state.mu.Unlock()
|
||||
return fail("require a GET WebSocket upgrade request")
|
||||
}
|
||||
ws := newRouteWebSocket(host)
|
||||
u := &routeUpgrade{ws: ws}
|
||||
u.complete = func(upgradeErr error) {
|
||||
if !host.RunOnLoop(func(vm *moejs.Runtime) {
|
||||
if !host.ResourcesOpen() {
|
||||
ws.abort()
|
||||
return
|
||||
}
|
||||
err := host.RunJob(vm, "WebSocket upgrade", func() error {
|
||||
if upgradeErr != nil {
|
||||
return reject(native.GoError(vm, upgradeErr))
|
||||
}
|
||||
return resolve(engine.ObjectValue(ws.object(vm)))
|
||||
})
|
||||
if err != nil || upgradeErr != nil {
|
||||
ws.abort()
|
||||
return
|
||||
}
|
||||
// Promise continuations install handlers before a later loop turn
|
||||
// starts delivering messages (including an immediate peer send).
|
||||
if !host.RunOnLoop(func(_ *moejs.Runtime) { ws.start() }) {
|
||||
ws.abort()
|
||||
}
|
||||
}) {
|
||||
ws.abort()
|
||||
}
|
||||
}
|
||||
state.upgrade = u
|
||||
state.mu.Unlock()
|
||||
state.upgrades <- u
|
||||
return native.From(vm, promise), nil
|
||||
})
|
||||
}
|
||||
|
||||
// seal prevents queued JavaScript from writing or upgrading a returned request.
|
||||
func (state *routeResponse) seal() {
|
||||
state.mu.Lock()
|
||||
wasSealed := state.sealed
|
||||
state.sealed = true
|
||||
pending := state.upgrade
|
||||
claimed := state.upgradeClaimed
|
||||
state.mu.Unlock()
|
||||
if state.aborted.CompareAndSwap(false, true) {
|
||||
close(state.abortCh)
|
||||
}
|
||||
if pending != nil && !claimed && !wasSealed {
|
||||
pending.ws.abort()
|
||||
pending.complete(errors.New("WebSocket upgrade canceled"))
|
||||
}
|
||||
}
|
||||
|
||||
func (state *routeResponse) isStreaming() bool {
|
||||
state.mu.Lock()
|
||||
defer state.mu.Unlock()
|
||||
return state.streaming
|
||||
}
|
||||
|
||||
// pushChunk delivers a streamed chunk to the HTTP pump. It returns false
|
||||
// when the client connection is gone so the script can stop producing.
|
||||
func (state *routeResponse) pushChunk(p []byte) bool {
|
||||
select {
|
||||
case state.chunks <- p:
|
||||
return true
|
||||
case <-state.abortCh:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (state *routeResponse) finish(status int, body []byte) {
|
||||
state.mu.Lock()
|
||||
defer state.mu.Unlock()
|
||||
if state.ended || state.sealed || state.upgrade != nil {
|
||||
return
|
||||
}
|
||||
if status != 0 {
|
||||
state.statusCode = status
|
||||
}
|
||||
if body != nil {
|
||||
_, _ = state.body.Write(body)
|
||||
}
|
||||
state.ended = true
|
||||
close(state.done)
|
||||
}
|
||||
|
||||
func routeRequest(vm *moejs.Runtime, c *gin.Context, body []byte) *engine.Object {
|
||||
req := hookRequestObject(vm, c.Request, body)
|
||||
_ = native.Set(vm, req, "context", routeRequestContext(vm, c))
|
||||
return req
|
||||
}
|
||||
|
||||
func routeResponseObject(vm *moejs.Runtime, state *routeResponse) *engine.Object {
|
||||
res := vm.Realm().NewObject()
|
||||
_ = native.Set(vm, res, "statusCode", http.StatusOK)
|
||||
_ = native.Set(vm, res, "statusMessage", "")
|
||||
_ = native.Set(vm, res, "streaming", false)
|
||||
_ = native.Function(vm, res, "isAborted", func(_ *moejs.Realm, _ moejs.Value, args []moejs.Value) (moejs.Value, error) {
|
||||
return native.From(vm, state.aborted.Load()), nil
|
||||
})
|
||||
_ = native.Function(vm, res, "setHeader", func(_ *moejs.Realm, _ moejs.Value, args []moejs.Value) (moejs.Value, error) {
|
||||
name, value := native.String(vm, moejs.Arg(args, 0)), moejs.Arg(args, 1)
|
||||
var values []string
|
||||
if err := native.ExportTo(vm, value, &values); err != nil {
|
||||
values = []string{native.String(vm, value)}
|
||||
}
|
||||
state.mu.Lock()
|
||||
defer state.mu.Unlock()
|
||||
if state.upgrade != nil {
|
||||
return moejs.Undefined(), native.TypeError(vm, "HTTP response is unavailable after WebSocket upgrade")
|
||||
}
|
||||
if !state.sealed {
|
||||
state.headers[http.CanonicalHeaderKey(name)] = values
|
||||
}
|
||||
return engine.ObjectValue(res), nil
|
||||
})
|
||||
_ = native.Function(vm, res, "getHeader", func(_ *moejs.Realm, _ moejs.Value, args []moejs.Value) (moejs.Value, error) {
|
||||
state.mu.Lock()
|
||||
defer state.mu.Unlock()
|
||||
values := state.headers.Values(native.String(vm, moejs.Arg(args, 0)))
|
||||
if len(values) == 0 {
|
||||
return moejs.Undefined(), nil
|
||||
}
|
||||
if len(values) == 1 {
|
||||
return native.From(vm, values[0]), nil
|
||||
}
|
||||
return native.From(vm, values), nil
|
||||
})
|
||||
_ = native.Function(vm, res, "removeHeader", func(_ *moejs.Realm, _ moejs.Value, args []moejs.Value) (moejs.Value, error) {
|
||||
name := native.String(vm, moejs.Arg(args, 0))
|
||||
state.mu.Lock()
|
||||
defer state.mu.Unlock()
|
||||
if state.upgrade != nil {
|
||||
return moejs.Undefined(), native.TypeError(vm, "HTTP response is unavailable after WebSocket upgrade")
|
||||
}
|
||||
if !state.sealed {
|
||||
state.headers.Del(name)
|
||||
}
|
||||
return moejs.Undefined(), nil
|
||||
})
|
||||
_ = native.Function(vm, res, "write", func(_ *moejs.Realm, _ moejs.Value, args []moejs.Value) (moejs.Value, error) {
|
||||
value := moejs.Arg(args, 0)
|
||||
var data []byte
|
||||
if err := native.ExportTo(vm, value, &data); err != nil {
|
||||
data = []byte(native.String(vm, value))
|
||||
}
|
||||
streaming := native.Bool(native.Get(vm, res, "streaming"))
|
||||
state.mu.Lock()
|
||||
if state.upgrade != nil {
|
||||
state.mu.Unlock()
|
||||
return moejs.Undefined(), native.TypeError(vm, "HTTP response is unavailable after WebSocket upgrade")
|
||||
}
|
||||
if state.sealed || state.ended {
|
||||
state.mu.Unlock()
|
||||
return native.From(vm, false), nil
|
||||
}
|
||||
state.wrote = true
|
||||
if streaming {
|
||||
state.streaming = true
|
||||
state.mu.Unlock()
|
||||
return native.From(vm, state.pushChunk(data)), nil
|
||||
}
|
||||
_, _ = state.body.Write(data)
|
||||
state.mu.Unlock()
|
||||
return native.From(vm, true), nil
|
||||
})
|
||||
_ = native.Function(vm, res, "end", func(_ *moejs.Realm, _ moejs.Value, args []moejs.Value) (moejs.Value, error) {
|
||||
var tail string
|
||||
if len(args) > 0 && !native.IsNil(args[0]) {
|
||||
tail = native.String(vm, args[0])
|
||||
}
|
||||
status := int(native.Int(vm, native.Get(vm, res, "statusCode")))
|
||||
state.mu.Lock()
|
||||
defer state.mu.Unlock()
|
||||
if state.upgrade != nil {
|
||||
return moejs.Undefined(), native.TypeError(vm, "HTTP response is unavailable after WebSocket upgrade")
|
||||
}
|
||||
if state.sealed || state.ended {
|
||||
return engine.ObjectValue(res), nil
|
||||
}
|
||||
_, _ = state.body.WriteString(tail)
|
||||
state.statusCode = status
|
||||
if !state.ended {
|
||||
state.ended = true
|
||||
close(state.done)
|
||||
}
|
||||
return engine.ObjectValue(res), nil
|
||||
})
|
||||
return res
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// State persists the enabled flag, the approved permissions hash and the
|
||||
// last error for each installed plugin in DataDir/state.json. Keeping it
|
||||
// next to the plugin directories keeps plugin management self-contained.
|
||||
type State struct {
|
||||
mu sync.Mutex
|
||||
path string
|
||||
current map[string]PluginState
|
||||
}
|
||||
|
||||
// PluginState is the persisted state of one plugin.
|
||||
type PluginState struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
ApprovedPermissionsHash string `json:"approved_permissions_hash"`
|
||||
LastError string `json:"last_error"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func openState(path string) *State {
|
||||
return &State{path: path}
|
||||
}
|
||||
|
||||
func (s *State) get(short string) PluginState {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.ensure()
|
||||
return s.current[short]
|
||||
}
|
||||
|
||||
func (s *State) set(short string, st PluginState) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.ensure()
|
||||
st.UpdatedAt = time.Now().UTC()
|
||||
s.current[short] = st
|
||||
s.saveLocked()
|
||||
}
|
||||
|
||||
func (s *State) delete(short string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.ensure()
|
||||
if _, ok := s.current[short]; ok {
|
||||
delete(s.current, short)
|
||||
s.saveLocked()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *State) ensure() {
|
||||
if s.current != nil {
|
||||
return
|
||||
}
|
||||
s.current = map[string]PluginState{}
|
||||
data, err := os.ReadFile(s.path)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var file struct {
|
||||
Plugins map[string]PluginState `json:"plugins"`
|
||||
}
|
||||
if json.Unmarshal(data, &file) == nil && file.Plugins != nil {
|
||||
s.current = file.Plugins
|
||||
}
|
||||
}
|
||||
|
||||
func (s *State) saveLocked() {
|
||||
file := struct {
|
||||
Plugins map[string]PluginState `json:"plugins"`
|
||||
}{Plugins: s.current}
|
||||
data, _ := json.MarshalIndent(file, "", " ")
|
||||
_ = os.WriteFile(s.path, data, 0644)
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/jpeg"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func encodeTestJPEG(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
img := image.NewRGBA(image.Rect(0, 0, 32, 32))
|
||||
var buf bytes.Buffer
|
||||
if err := jpeg.Encode(&buf, img, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
// TestBuiltinStyleStreamingWithResponseHooks 模拟普通 Gin handler 风格的流式
|
||||
// 路由(直接写 c.Writer + Flush)在存在 response hook 时仍然能把数据推给客户端。
|
||||
// 回归:bufferedResponseWriter 此前会吞掉所有 Write,直到 handler 返回(流式
|
||||
// handler 永不返回),客户端因此收不到任何数据(黑屏)。
|
||||
func TestBuiltinStyleStreamingWithResponseHooks(t *testing.T) {
|
||||
withTempDataDir(t)
|
||||
gin.SetMode(gin.TestMode)
|
||||
engine := gin.New()
|
||||
Init(engine)
|
||||
|
||||
hookZip := writePluginZip(t, map[string]string{
|
||||
"komari-plugin.json": `{"name":"Hook","short":"hook","version":"1.0.0","permissions":{"timeout":5,"allowHooks":true}}`,
|
||||
"script.js": `const server = require("server");
|
||||
function load() {
|
||||
server.hook("response", (req, res) => { res.headers["x-hooked"] = "1"; });
|
||||
}
|
||||
`,
|
||||
})
|
||||
if _, err := InstallZip(hookZip); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := SetEnabled("hook", true, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = SetEnabled("hook", false, false) }()
|
||||
|
||||
frame := encodeTestJPEG(t)
|
||||
engine.GET("/live", func(c *gin.Context) {
|
||||
c.Header("Content-Type", "multipart/x-mixed-replace; boundary=frame")
|
||||
for i := 0; i < 5; i++ {
|
||||
_, _ = c.Writer.WriteString("--frame\r\nContent-Type: image/jpeg\r\n\r\n")
|
||||
_, _ = c.Writer.Write(frame)
|
||||
_, _ = c.Writer.WriteString("\r\n")
|
||||
c.Writer.Flush()
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
})
|
||||
|
||||
wrapped := WrapHandler(engine)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, "http://127.0.0.1/live", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
wrapped.ServeHTTP(rec, req)
|
||||
body := rec.Body.Bytes()
|
||||
|
||||
if ct := rec.Header().Get("Content-Type"); !strings.Contains(ct, "multipart/x-mixed-replace") {
|
||||
t.Fatalf("content-type = %q, want multipart stream", ct)
|
||||
}
|
||||
if !bytes.Contains(body, []byte("--frame")) || !bytes.Contains(body, []byte{0xFF, 0xD8}) {
|
||||
t.Fatalf("streamed body missing frames: len=%d", len(body))
|
||||
}
|
||||
// 流式响应不能被 response hook 改写,也不应带上 hook 头
|
||||
if got := rec.Header().Get("X-Hooked"); got != "" {
|
||||
t.Fatalf("streaming response unexpectedly rewritten by response hook: x-hooked=%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRouteStreamingMJPEG 用本地“摄像头”(单张 JPEG)验证流式路由:
|
||||
// multipart 帧即时写出、客户端断开后脚本通过 isAborted 退出循环。
|
||||
func TestRouteStreamingMJPEG(t *testing.T) {
|
||||
withTempDataDir(t)
|
||||
gin.SetMode(gin.TestMode)
|
||||
engine := gin.New()
|
||||
Init(engine)
|
||||
|
||||
frame := encodeTestJPEG(t)
|
||||
camera := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "image/jpeg")
|
||||
_, _ = w.Write(frame)
|
||||
}))
|
||||
defer camera.Close()
|
||||
|
||||
script := fmt.Sprintf(`
|
||||
const server = require("server");
|
||||
function load() {
|
||||
server.route("GET", "/stream", async (req, res) => {
|
||||
res.streaming = true;
|
||||
res.setHeader("Content-Type", "multipart/x-mixed-replace; boundary=frame");
|
||||
res.setHeader("Cache-Control", "no-cache");
|
||||
while (!res.isAborted()) {
|
||||
try {
|
||||
const resp = await fetch("%s");
|
||||
const data = await resp.arrayBuffer();
|
||||
res.write("--frame\r\nContent-Type: image/jpeg\r\nContent-Length: " + data.byteLength + "\r\n\r\n");
|
||||
res.write(data);
|
||||
res.write("\r\n");
|
||||
} catch (e) {
|
||||
console.log("frame error: " + e.message);
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
console.log("stream ended");
|
||||
});
|
||||
}
|
||||
`, camera.URL)
|
||||
zipPath := writePluginZip(t, map[string]string{
|
||||
"komari-plugin.json": `{"name":"Mjpeg","short":"mjpeg","version":"1.0.0","permissions":{"node":true,"timeout":5,"allowRoutes":true}}`,
|
||||
"script.js": script,
|
||||
})
|
||||
if _, err := InstallZip(zipPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := SetEnabled("mjpeg", true, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = SetEnabled("mjpeg", false, false) }()
|
||||
|
||||
srv := httptest.NewServer(engine)
|
||||
defer srv.Close()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, srv.URL+"/stream", nil)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if ct := resp.Header.Get("Content-Type"); !strings.Contains(ct, "multipart/x-mixed-replace") {
|
||||
t.Fatalf("content-type = %q", ct)
|
||||
}
|
||||
|
||||
// 读取第一个完整帧并校验 JPEG magic 与长度
|
||||
reader := bufio.NewReader(resp.Body)
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
found := false
|
||||
for time.Now().Before(deadline) {
|
||||
line, err := reader.ReadBytes(10)
|
||||
if err != nil {
|
||||
t.Fatalf("read stream: %v", err)
|
||||
}
|
||||
if !bytes.Contains(line, []byte("--frame")) {
|
||||
continue
|
||||
}
|
||||
_, _ = reader.ReadBytes(10) // Content-Type
|
||||
lengthLine, err := reader.ReadBytes(10)
|
||||
if err != nil {
|
||||
t.Fatalf("read length line: %v", err)
|
||||
}
|
||||
length, _ := strconv.Atoi(strings.TrimSpace(strings.TrimPrefix(string(lengthLine), "Content-Length:")))
|
||||
_, _ = reader.ReadBytes(10) // 空行
|
||||
head := make([]byte, 2)
|
||||
if _, err := io.ReadFull(reader, head); err != nil {
|
||||
t.Fatalf("read jpeg head: %v", err)
|
||||
}
|
||||
if head[0] != 0xFF || head[1] != 0xD8 {
|
||||
t.Fatalf("jpeg magic = % X", head)
|
||||
}
|
||||
rest := make([]byte, length-2)
|
||||
if _, err := io.ReadFull(reader, rest); err != nil {
|
||||
t.Fatalf("read jpeg body: %v", err)
|
||||
}
|
||||
found = true
|
||||
break
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("no frame received within 5s")
|
||||
}
|
||||
|
||||
// 客户端断开 → 脚本 isAborted 退出循环
|
||||
cancel()
|
||||
deadline = time.Now().Add(5 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if logs := GetLogs("mjpeg"); strings.Contains(logs, "stream ended") {
|
||||
return
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("stream did not stop after disconnect; logs = %q", GetLogs("mjpeg"))
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// TestIsUpgradeRequest 覆盖升级请求判定:真正的协议升级必须同时携带
|
||||
// Connection: upgrade 记号与 Upgrade 目标协议头(RFC 9110 §7.8);仅带
|
||||
// 其中一个头的普通请求(如反代模板给每个请求无条件附加
|
||||
// Connection: upgrade)不得被视为升级。
|
||||
func TestIsUpgradeRequest(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
connection string
|
||||
upgrade string
|
||||
want bool
|
||||
}{
|
||||
{"websocket upgrade with both headers", "upgrade", "websocket", true},
|
||||
{"case insensitive", "Upgrade", "WebSocket", true},
|
||||
{"connection list keeps other tokens", "keep-alive, upgrade", "websocket", true},
|
||||
{"bare connection upgrade is not an upgrade", "upgrade", "", false},
|
||||
{"upgrade header alone is not an upgrade", "", "websocket", false},
|
||||
{"plain request", "", "", false},
|
||||
{"keep-alive only", "keep-alive", "", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
if tc.connection != "" {
|
||||
req.Header.Set("Connection", tc.connection)
|
||||
}
|
||||
if tc.upgrade != "" {
|
||||
req.Header.Set("Upgrade", tc.upgrade)
|
||||
}
|
||||
if got := isUpgradeRequest(req); got != tc.want {
|
||||
t.Fatalf("isUpgradeRequest(Connection=%q, Upgrade=%q) = %v, want %v",
|
||||
tc.connection, tc.upgrade, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestInjectSurvivesBareConnectionUpgradeHeader 回归:部分反代模板(如 1Panel
|
||||
// 的 OpenResty 站点模板)会给每个代理请求无条件附加 Connection: upgrade。
|
||||
// 这类普通页面请求必须照常获得插件 HTML 注入;只有同时携带两个头的真实升级
|
||||
// 才走放行路径。
|
||||
func TestInjectSurvivesBareConnectionUpgradeHeader(t *testing.T) {
|
||||
withTempDataDir(t)
|
||||
gin.SetMode(gin.TestMode)
|
||||
engine := gin.New()
|
||||
engine.GET("/admin/dashboard", func(c *gin.Context) {
|
||||
c.Data(http.StatusOK, "text/html; charset=utf-8",
|
||||
[]byte("<!DOCTYPE html><html><head></head><body></body></html>"))
|
||||
})
|
||||
Init(engine)
|
||||
|
||||
zipPath := writePluginZip(t, map[string]string{
|
||||
"komari-plugin.json": `{"name":"Guard","short":"guard","version":"1.0.0","permissions":{"allowHTMLInject":true,"timeout":5}}`,
|
||||
"script.js": `
|
||||
const server = require("server");
|
||||
function load() {
|
||||
server.injectHTML("", "<script>window.__guard = 1;</script>");
|
||||
}
|
||||
`,
|
||||
})
|
||||
if _, err := InstallZip(zipPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := SetEnabled("guard", true, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = SetEnabled("guard", false, false) }()
|
||||
|
||||
handler := HTMLInjectHandler(engine)
|
||||
|
||||
// 仅 Connection: upgrade(反代常见行为)→ 注入照常生效
|
||||
req := httptest.NewRequest(http.MethodGet, "/admin/dashboard", nil)
|
||||
req.Header.Set("Connection", "upgrade")
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
if !strings.Contains(rec.Body.String(), "__guard") {
|
||||
t.Fatalf("bare Connection: upgrade must not skip injection, body = %q", rec.Body.String())
|
||||
}
|
||||
|
||||
// 两个头齐全的真实升级 → 放行,不注入
|
||||
req2 := httptest.NewRequest(http.MethodGet, "/admin/dashboard", nil)
|
||||
req2.Header.Set("Connection", "upgrade")
|
||||
req2.Header.Set("Upgrade", "websocket")
|
||||
rec2 := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec2, req2)
|
||||
if strings.Contains(rec2.Body.String(), "__guard") {
|
||||
t.Fatalf("real upgrade must skip injection, body = %q", rec2.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/komari-monitor/komari/utils"
|
||||
)
|
||||
|
||||
// CheckKomariVersion validates a manifest komari constraint against the
|
||||
// running server version. Supported constraints: empty (any version),
|
||||
// "x.y.z" (exact), and ">=x.y.z", ">x.y.z", "<=x.y.z", "<x.y.z". An optional
|
||||
// leading "v" is accepted.
|
||||
func CheckKomariVersion(constraint string) error {
|
||||
constraint = strings.TrimSpace(constraint)
|
||||
if constraint == "" {
|
||||
return nil
|
||||
}
|
||||
rest := constraint
|
||||
op := ""
|
||||
for _, candidate := range []string{">=", "<=", ">", "<", "="} {
|
||||
if strings.HasPrefix(rest, candidate) {
|
||||
op = candidate
|
||||
rest = strings.TrimSpace(strings.TrimPrefix(rest, candidate))
|
||||
break
|
||||
}
|
||||
}
|
||||
want, err := parseSemver(rest)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid komari version constraint %q: %w", constraint, err)
|
||||
}
|
||||
have, err := parseSemver(utils.CurrentVersion)
|
||||
if err != nil {
|
||||
// A malformed server version must not block plugin loading.
|
||||
return nil
|
||||
}
|
||||
if !satisfies(compareSemver(have, want), op) {
|
||||
return fmt.Errorf("plugin requires komari %s, running %s", constraint, utils.CurrentVersion)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func satisfies(cmp int, op string) bool {
|
||||
switch op {
|
||||
case ">=":
|
||||
return cmp >= 0
|
||||
case ">":
|
||||
return cmp > 0
|
||||
case "<=":
|
||||
return cmp <= 0
|
||||
case "<":
|
||||
return cmp < 0
|
||||
default: // exact
|
||||
return cmp == 0
|
||||
}
|
||||
}
|
||||
|
||||
func parseSemver(s string) ([3]int, error) {
|
||||
s = strings.TrimSpace(strings.TrimPrefix(s, "v"))
|
||||
if s == "" {
|
||||
return [3]int{}, fmt.Errorf("empty version")
|
||||
}
|
||||
parts := strings.Split(s, ".")
|
||||
if len(parts) > 3 {
|
||||
return [3]int{}, fmt.Errorf("too many version parts")
|
||||
}
|
||||
var v [3]int
|
||||
for i, part := range parts {
|
||||
n, err := strconv.Atoi(part)
|
||||
if err != nil || n < 0 {
|
||||
return [3]int{}, fmt.Errorf("invalid version part %q", part)
|
||||
}
|
||||
v[i] = n
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func compareSemver(a, b [3]int) int {
|
||||
for i := 0; i < 3; i++ {
|
||||
if a[i] < b[i] {
|
||||
return -1
|
||||
}
|
||||
if a[i] > b[i] {
|
||||
return 1
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package plugin
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestCheckKomariVersion(t *testing.T) {
|
||||
tests := []struct {
|
||||
constraint string
|
||||
wantErr bool
|
||||
}{
|
||||
{"", false},
|
||||
{"0.0.1", false},
|
||||
{"v0.0.1", false},
|
||||
{">=0.0.1", false},
|
||||
{">0.0.1", true},
|
||||
{"<1.0.0", false},
|
||||
{"<=0.0.1", false},
|
||||
{">=99.0.0", true},
|
||||
{"0.1", true}, // 0.1.0 > 0.0.1
|
||||
{"1", true}, // 1.0.0 > 0.0.1
|
||||
{"1.2.3.4", true},
|
||||
{"abc", true},
|
||||
{">", true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
err := CheckKomariVersion(tt.constraint)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("CheckKomariVersion(%q) error = %v, wantErr %v", tt.constraint, err, tt.wantErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/Calcium-Ion/moejs"
|
||||
"github.com/Calcium-Ion/moejs/engine"
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/komari-monitor/komari/pkg/jsruntime/native"
|
||||
"github.com/komari-monitor/komari/web/connection"
|
||||
)
|
||||
|
||||
// WebSocket hook kinds. wsConnect runs right after the upgrade and may deny
|
||||
// the connection; wsMessage/wsSend see every inbound/outbound frame;
|
||||
// wsClose fires once when the connection ends. Path filtering reuses the
|
||||
// HTTP matcher syntax without the method prefix, since every upgrade is a
|
||||
// GET. Like the HTTP kinds, the input is lowercased, so any casing works
|
||||
// from JS.
|
||||
const (
|
||||
hookWSConnect hookKind = "wsconnect"
|
||||
hookWSMessage hookKind = "wsmessage"
|
||||
hookWSSend hookKind = "wssend"
|
||||
hookWSClose hookKind = "wsclose"
|
||||
)
|
||||
|
||||
// wsFrameHookTimeout bounds one frame-level callback. The agent read pumps
|
||||
// run on the agent read deadline; a frame hook must never stall
|
||||
// them, so the wait is cut short and the frame passes through untouched.
|
||||
const wsFrameHookTimeout = time.Second
|
||||
|
||||
// maxWSFrameBytes caps the payload copied into the plugin event loop; larger
|
||||
// frames pass through without invoking the hooks.
|
||||
const maxWSFrameBytes = 8 << 20
|
||||
|
||||
// isWSHookKind reports whether kind is one of the WebSocket hook kinds.
|
||||
func isWSHookKind(kind string) bool {
|
||||
switch kind {
|
||||
case string(hookWSConnect), string(hookWSMessage), string(hookWSSend), string(hookWSClose):
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// OnConnect implements connection.FrameInterceptor. It runs the wsConnect
|
||||
// hooks registered for the connection path; the first denial rejects the
|
||||
// connection.
|
||||
func (m *Manager) OnConnect(info *connection.ConnInfo) (deny bool, reason string) {
|
||||
for _, h := range m.wsHooksFor(hookWSConnect, info.Path) {
|
||||
var denied bool
|
||||
var why string
|
||||
queued, timedOut := runHookTurn(h.host, "plugin wsConnect hook "+h.short, func(vm *moejs.Runtime) {
|
||||
_ = h.host.RunJob(vm, "plugin wsConnect hook "+h.short, func() error {
|
||||
res, err := vm.CallFunction(h.fn, moejs.Undefined(), engine.ObjectValue(wsHookContext(vm, info)))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if res.IsUndefined() || res.IsNull() {
|
||||
return nil
|
||||
}
|
||||
obj := native.Object(vm, res)
|
||||
if v := native.Get(vm, obj, "deny"); native.Bool(v) {
|
||||
denied = true
|
||||
}
|
||||
if v := native.Get(vm, obj, "reason"); !v.IsUndefined() {
|
||||
why = native.String(vm, v)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
})
|
||||
if !queued {
|
||||
continue // runtime closed; later plugins still get a chance
|
||||
}
|
||||
if timedOut {
|
||||
m.logWSHookTimeout(h)
|
||||
continue
|
||||
}
|
||||
if denied {
|
||||
return true, why
|
||||
}
|
||||
}
|
||||
return false, ""
|
||||
}
|
||||
|
||||
// OnMessage implements connection.FrameInterceptor for inbound frames.
|
||||
func (m *Manager) OnMessage(info *connection.ConnInfo, frameType int, data []byte) (int, []byte, bool) {
|
||||
return m.runFrameHooks(hookWSMessage, info, frameType, data)
|
||||
}
|
||||
|
||||
// OnSend implements connection.FrameInterceptor for outbound frames.
|
||||
func (m *Manager) OnSend(info *connection.ConnInfo, frameType int, data []byte) (int, []byte, bool) {
|
||||
return m.runFrameHooks(hookWSSend, info, frameType, data)
|
||||
}
|
||||
|
||||
// runFrameHooks runs the wsMessage/wsSend chain for one frame in
|
||||
// registration order; each hook sees the previous hook's replacement. A drop
|
||||
// wins over later hooks. Timeouts, errors and oversized frames pass the
|
||||
// frame through.
|
||||
func (m *Manager) runFrameHooks(kind hookKind, info *connection.ConnInfo, frameType int, data []byte) (int, []byte, bool) {
|
||||
hooks := m.wsHooksFor(kind, info.Path)
|
||||
if len(hooks) == 0 || len(data) > maxWSFrameBytes {
|
||||
return frameType, data, false
|
||||
}
|
||||
next := data
|
||||
for _, h := range hooks {
|
||||
var drop bool
|
||||
var hookErr error
|
||||
queued, timedOut := runHookTurnTimeout(h.host, "plugin "+string(kind)+" hook "+h.short, wsFrameHookTimeout, func(vm *moejs.Runtime) {
|
||||
msg := wsFrameObject(vm, info, frameType, next)
|
||||
hookErr = h.host.RunJob(vm, "plugin "+string(kind)+" hook "+h.short, func() error {
|
||||
res, err := vm.CallFunction(h.fn, moejs.Undefined(), engine.ObjectValue(wsHookContext(vm, info)), engine.ObjectValue(msg))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if res.IsUndefined() || res.IsNull() {
|
||||
return nil
|
||||
}
|
||||
obj := native.Object(vm, res)
|
||||
if v := native.Get(vm, obj, "drop"); native.Bool(v) {
|
||||
drop = true
|
||||
return nil
|
||||
}
|
||||
if v := native.Get(vm, obj, "type"); !v.IsUndefined() {
|
||||
frameType = int(native.Int(vm, v))
|
||||
}
|
||||
if v := native.Get(vm, obj, "data"); !v.IsUndefined() && !v.IsNull() {
|
||||
if data := wsFrameBytes(vm, v); data != nil {
|
||||
next = data
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
})
|
||||
if !queued {
|
||||
return frameType, next, false // runtime closed: pass the frame on
|
||||
}
|
||||
if timedOut {
|
||||
m.logWSHookTimeout(h)
|
||||
continue
|
||||
}
|
||||
if hookErr != nil {
|
||||
m.logHookError(h, hookErr)
|
||||
continue
|
||||
}
|
||||
if drop {
|
||||
return frameType, nil, true
|
||||
}
|
||||
}
|
||||
return frameType, next, false
|
||||
}
|
||||
|
||||
// OnClose implements connection.FrameInterceptor for connection teardown.
|
||||
func (m *Manager) OnClose(info *connection.ConnInfo) {
|
||||
for _, h := range m.wsHooksFor(hookWSClose, info.Path) {
|
||||
var hookErr error
|
||||
queued, timedOut := runHookTurn(h.host, "plugin wsClose hook "+h.short, func(vm *moejs.Runtime) {
|
||||
hookErr = h.host.RunJob(vm, "plugin wsClose hook "+h.short, func() error {
|
||||
_, err := vm.CallFunction(h.fn, moejs.Undefined(), engine.ObjectValue(wsHookContext(vm, info)))
|
||||
return err
|
||||
})
|
||||
})
|
||||
if !queued {
|
||||
return
|
||||
}
|
||||
if timedOut {
|
||||
m.logWSHookTimeout(h)
|
||||
continue
|
||||
}
|
||||
if hookErr != nil {
|
||||
m.logHookError(h, hookErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// wsHooksFor returns the hooks of one ws kind whose matcher matches the
|
||||
// connection path, in registration order.
|
||||
func (m *Manager) wsHooksFor(kind hookKind, path string) []*hookEntry {
|
||||
all := m.hooksOf(kind)
|
||||
kept := all[:0]
|
||||
for _, h := range all {
|
||||
if h.matcher == nil || h.matcher.matchesPath(path) {
|
||||
kept = append(kept, h)
|
||||
}
|
||||
}
|
||||
return kept
|
||||
}
|
||||
|
||||
// wsHookContext builds the immutable per-connection context object.
|
||||
func wsHookContext(vm *moejs.Runtime, info *connection.ConnInfo) *engine.Object {
|
||||
ctx := vm.Realm().NewObject()
|
||||
_ = native.Set(vm, ctx, "path", info.Path)
|
||||
_ = native.Set(vm, ctx, "connId", info.ID)
|
||||
_ = native.Set(vm, ctx, "remoteIp", info.RemoteIP)
|
||||
_ = native.Set(vm, ctx, "userAgent", info.UserAgent)
|
||||
if info.ClientUUID != "" {
|
||||
_ = native.Set(vm, ctx, "clientUuid", info.ClientUUID)
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
// wsFrameObject builds the frame object handed to wsMessage/wsSend hooks.
|
||||
// Text frames arrive as strings, binary frames as ArrayBuffers.
|
||||
func wsFrameObject(vm *moejs.Runtime, info *connection.ConnInfo, frameType int, data []byte) *engine.Object {
|
||||
msg := vm.Realm().NewObject()
|
||||
_ = native.Set(vm, msg, "type", frameType)
|
||||
if frameType == websocket.BinaryMessage {
|
||||
_ = native.Set(vm, msg, "data", newFrameBuffer(vm, data))
|
||||
} else {
|
||||
_ = native.Set(vm, msg, "data", string(data))
|
||||
}
|
||||
_ = native.Set(vm, msg, "connId", info.ID)
|
||||
_ = native.Set(vm, msg, "path", info.Path)
|
||||
return msg
|
||||
}
|
||||
|
||||
// wsFrameBytes converts a hook-returned payload (string or ArrayBuffer) back
|
||||
// to bytes; anything else reports nil and leaves the frame untouched.
|
||||
func wsFrameBytes(vm *moejs.Runtime, v moejs.Value) []byte {
|
||||
if v.IsString() {
|
||||
return []byte(native.String(vm, v))
|
||||
}
|
||||
var data []byte
|
||||
if err := native.ExportTo(vm, v, &data); err == nil {
|
||||
return data
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// logWSHookTimeout records a timed-out ws hook for the plugin console.
|
||||
func (m *Manager) logWSHookTimeout(h *hookEntry) {
|
||||
_, _ = m.logStore(h.short).Write([]byte("[plugin] " + string(h.kind) + " hook timed out; the message passed through unmodified\n"))
|
||||
}
|
||||
|
||||
// The Manager is the process-wide WebSocket frame interceptor.
|
||||
var _ connection.FrameInterceptor = (*Manager)(nil)
|
||||
|
||||
func newFrameBuffer(vm *moejs.Runtime, data []byte) moejs.Value {
|
||||
obj, err := vm.Realm().NewArrayBuffer(append([]byte(nil), data...))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return engine.ObjectValue(obj)
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/komari-monitor/komari/web/connection"
|
||||
)
|
||||
|
||||
// wsManifest is a plugin manifest that declares the hooks permission (the
|
||||
// ws kinds reuse allowHooks).
|
||||
const wsManifest = `{"name":"Ws","short":"ws","version":"1.0.0","permissions":{"allowHooks":true,"timeout":5}}`
|
||||
|
||||
func wsInfo(path string) *connection.ConnInfo {
|
||||
return &connection.ConnInfo{ID: 1, Path: path, RemoteIP: "203.0.113.9", UserAgent: "test"}
|
||||
}
|
||||
|
||||
// TestWSHookConnectDenyAndAllow 验证 wsConnect 钩子:返回 {deny:true,reason}
|
||||
// 拒绝连接,返回 undefined 放行。
|
||||
func TestWSHookConnectDenyAndAllow(t *testing.T) {
|
||||
withTempDataDir(t)
|
||||
gin.SetMode(gin.TestMode)
|
||||
Init(gin.New())
|
||||
|
||||
zipPath := writePluginZip(t, map[string]string{
|
||||
"komari-plugin.json": wsManifest,
|
||||
"script.js": `
|
||||
const server = require("server");
|
||||
function load() {
|
||||
server.hook("wsConnect", (ctx) => {
|
||||
if (ctx.path === "/deny") return { deny: true, reason: "no agents here" };
|
||||
});
|
||||
}
|
||||
`,
|
||||
})
|
||||
if _, err := InstallZip(zipPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := SetEnabled("ws", true, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = SetEnabled("ws", false, false) }()
|
||||
|
||||
if deny, reason := global.OnConnect(wsInfo("/deny")); !deny || reason != "no agents here" {
|
||||
t.Fatalf("deny = %v, reason = %q", deny, reason)
|
||||
}
|
||||
if deny, reason := global.OnConnect(wsInfo("/api/clients/v2/rpc")); deny || reason != "" {
|
||||
t.Fatalf("unmatched path must be allowed, deny = %v, reason = %q", deny, reason)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWSHookMessageChainReplaceAndDrop 验证 wsMessage 钩子链:按注册顺序链式
|
||||
// 传递替换结果,drop 优先于后续钩子。
|
||||
func TestWSHookMessageChainReplaceAndDrop(t *testing.T) {
|
||||
withTempDataDir(t)
|
||||
gin.SetMode(gin.TestMode)
|
||||
Init(gin.New())
|
||||
|
||||
zipPath := writePluginZip(t, map[string]string{
|
||||
"komari-plugin.json": wsManifest,
|
||||
"script.js": `
|
||||
const server = require("server");
|
||||
function load() {
|
||||
server.hook("wsMessage", (ctx, msg) => {
|
||||
if (msg.data === "hello") return { data: "hi" };
|
||||
});
|
||||
server.hook("wsMessage", (ctx, msg) => {
|
||||
if (msg.data === "dropme" || msg.data === "hi") return { drop: true };
|
||||
});
|
||||
}
|
||||
`,
|
||||
})
|
||||
if _, err := InstallZip(zipPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := SetEnabled("ws", true, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = SetEnabled("ws", false, false) }()
|
||||
|
||||
info := wsInfo("/api/clients/v2/rpc")
|
||||
// 第一个钩子改 hello->hi,第二个钩子丢弃 hi:结果应为 drop。
|
||||
if _, _, drop := global.OnMessage(info, 1, []byte("hello")); !drop {
|
||||
t.Fatal("chained replace followed by drop must drop")
|
||||
}
|
||||
// 第一个钩子不匹配,第二个钩子丢弃 dropme。
|
||||
if _, _, drop := global.OnMessage(info, 1, []byte("dropme")); !drop {
|
||||
t.Fatal("direct drop must drop")
|
||||
}
|
||||
// 两个钩子都不匹配:原样放行。
|
||||
typ, data, drop := global.OnMessage(info, 1, []byte("other"))
|
||||
if drop || typ != 1 || string(data) != "other" {
|
||||
t.Fatalf("pass-through = type %d data %q drop %v", typ, data, drop)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWSHookSendReplacesType 验证 wsSend 钩子可以同时替换帧类型与载荷。
|
||||
func TestWSHookSendReplacesType(t *testing.T) {
|
||||
withTempDataDir(t)
|
||||
gin.SetMode(gin.TestMode)
|
||||
Init(gin.New())
|
||||
|
||||
zipPath := writePluginZip(t, map[string]string{
|
||||
"komari-plugin.json": wsManifest,
|
||||
"script.js": `
|
||||
const server = require("server");
|
||||
function load() {
|
||||
server.hook("wsSend", (ctx, msg) => {
|
||||
if (msg.data === "text") return { type: 2, data: "bin" };
|
||||
});
|
||||
}
|
||||
`,
|
||||
})
|
||||
if _, err := InstallZip(zipPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := SetEnabled("ws", true, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = SetEnabled("ws", false, false) }()
|
||||
|
||||
typ, data, drop := global.OnSend(wsInfo("/api/rpc2"), 1, []byte("text"))
|
||||
if drop || typ != 2 || string(data) != "bin" {
|
||||
t.Fatalf("send rewrite = type %d data %q drop %v", typ, data, drop)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWSHookClose 验证 wsClose 钩子在连接结束时触发。
|
||||
func TestWSHookClose(t *testing.T) {
|
||||
withTempDataDir(t)
|
||||
gin.SetMode(gin.TestMode)
|
||||
Init(gin.New())
|
||||
|
||||
zipPath := writePluginZip(t, map[string]string{
|
||||
"komari-plugin.json": `{"name":"Ws","short":"ws","version":"1.0.0","permissions":{"node":true,"allowHooks":true,"timeout":5}}`,
|
||||
"script.js": `
|
||||
const fs = require("fs");
|
||||
const server = require("server");
|
||||
function load() {
|
||||
server.hook("wsClose", (ctx) => {
|
||||
fs.appendFileSync("closes.txt", ctx.path + "\n");
|
||||
});
|
||||
}
|
||||
`,
|
||||
})
|
||||
if _, err := InstallZip(zipPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := SetEnabled("ws", true, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = SetEnabled("ws", false, false) }()
|
||||
|
||||
global.OnClose(wsInfo("/api/clients/v2/rpc"))
|
||||
global.OnClose(wsInfo("/api/clients/v2/rpc"))
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(DataDir, "ws", "closes.txt"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := string(data); strings.Count(got, "/api/clients/v2/rpc") != 2 {
|
||||
t.Fatalf("wsClose fired %d times: %q", strings.Count(got, "/api/clients/v2/rpc"), got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWSHookPathFiltering 验证 ws kind 的路径 matcher 只拦截匹配端点的连接。
|
||||
func TestWSHookPathFiltering(t *testing.T) {
|
||||
withTempDataDir(t)
|
||||
gin.SetMode(gin.TestMode)
|
||||
Init(gin.New())
|
||||
|
||||
zipPath := writePluginZip(t, map[string]string{
|
||||
"komari-plugin.json": wsManifest,
|
||||
"script.js": `
|
||||
const server = require("server");
|
||||
function load() {
|
||||
server.hook("wsMessage", "/api/clients/v2/rpc", (ctx, msg) => {
|
||||
return { data: "filtered" };
|
||||
});
|
||||
}
|
||||
`,
|
||||
})
|
||||
if _, err := InstallZip(zipPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := SetEnabled("ws", true, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = SetEnabled("ws", false, false) }()
|
||||
|
||||
typ, data, _ := global.OnMessage(wsInfo("/api/clients/v2/rpc"), 1, []byte("x"))
|
||||
if string(data) != "filtered" {
|
||||
t.Fatalf("matching path not intercepted: %q", data)
|
||||
}
|
||||
typ, data, _ = global.OnMessage(wsInfo("/api/rpc2"), 1, []byte("x"))
|
||||
if typ != 1 || string(data) != "x" {
|
||||
t.Fatalf("non-matching path intercepted: type %d data %q", typ, data)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWSHookMethodPrefixRejected 验证 ws kind 的 matcher 不接受方法前缀。
|
||||
func TestWSHookMethodPrefixRejected(t *testing.T) {
|
||||
withTempDataDir(t)
|
||||
gin.SetMode(gin.TestMode)
|
||||
Init(gin.New())
|
||||
|
||||
zipPath := writePluginZip(t, map[string]string{
|
||||
"komari-plugin.json": wsManifest,
|
||||
"script.js": `
|
||||
const server = require("server");
|
||||
function load() {
|
||||
server.hook("wsMessage", "POST /api/x", (ctx, msg) => {});
|
||||
}
|
||||
`,
|
||||
})
|
||||
if _, err := InstallZip(zipPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := SetEnabled("ws", true, true); err == nil || !strings.Contains(err.Error(), "path matcher") {
|
||||
t.Fatalf("method prefix must fail loading: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWSHookRequiresPermission 验证 ws kind 缺 allowHooks 时加载失败(与
|
||||
// 请求/响应钩子共用同一权限)。
|
||||
func TestWSHookRequiresPermission(t *testing.T) {
|
||||
withTempDataDir(t)
|
||||
gin.SetMode(gin.TestMode)
|
||||
Init(gin.New())
|
||||
|
||||
zipPath := writePluginZip(t, map[string]string{
|
||||
"komari-plugin.json": `{"name":"Ws","short":"ws","version":"1.0.0","permissions":{"timeout":5}}`,
|
||||
"script.js": `
|
||||
const server = require("server");
|
||||
function load() {
|
||||
server.hook("wsConnect", (ctx) => {});
|
||||
}
|
||||
`,
|
||||
})
|
||||
if _, err := InstallZip(zipPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := SetEnabled("ws", true, true); err == nil || !strings.Contains(err.Error(), "allowHooks") {
|
||||
t.Fatalf("missing allowHooks must fail loading: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWSHookUnloadRemovesHooks 验证卸载后 ws 钩子全部移除。
|
||||
func TestWSHookUnloadRemovesHooks(t *testing.T) {
|
||||
withTempDataDir(t)
|
||||
gin.SetMode(gin.TestMode)
|
||||
Init(gin.New())
|
||||
|
||||
zipPath := writePluginZip(t, map[string]string{
|
||||
"komari-plugin.json": wsManifest,
|
||||
"script.js": `
|
||||
const server = require("server");
|
||||
function load() {
|
||||
server.hook("wsMessage", (ctx, msg) => { return { data: "hooked" }; });
|
||||
server.hook("wsConnect", (ctx) => { return { deny: true }; });
|
||||
}
|
||||
`,
|
||||
})
|
||||
if _, err := InstallZip(zipPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := SetEnabled("ws", true, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if deny, _ := global.OnConnect(wsInfo("/x")); !deny {
|
||||
t.Fatal("wsConnect must deny while loaded")
|
||||
}
|
||||
if err := SetEnabled("ws", false, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if deny, _ := global.OnConnect(wsInfo("/x")); deny {
|
||||
t.Fatal("wsConnect must allow after unload")
|
||||
}
|
||||
typ, data, _ := global.OnMessage(wsInfo("/x"), 1, []byte("plain"))
|
||||
if typ != 1 || string(data) != "plain" {
|
||||
t.Fatalf("frame hook still active after unload: %q", data)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWSHookOversizedFramePassesThrough 验证超过帧上限的载荷不经过钩子。
|
||||
func TestWSHookOversizedFramePassesThrough(t *testing.T) {
|
||||
withTempDataDir(t)
|
||||
gin.SetMode(gin.TestMode)
|
||||
Init(gin.New())
|
||||
|
||||
zipPath := writePluginZip(t, map[string]string{
|
||||
"komari-plugin.json": wsManifest,
|
||||
"script.js": `
|
||||
const server = require("server");
|
||||
function load() {
|
||||
server.hook("wsMessage", (ctx, msg) => { return { data: "replaced" }; });
|
||||
}
|
||||
`,
|
||||
})
|
||||
if _, err := InstallZip(zipPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := SetEnabled("ws", true, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = SetEnabled("ws", false, false) }()
|
||||
|
||||
big := make([]byte, maxWSFrameBytes+1)
|
||||
for i := range big {
|
||||
big[i] = 'x'
|
||||
}
|
||||
typ, data, _ := global.OnMessage(wsInfo("/x"), 1, big)
|
||||
if typ != 1 || len(data) != len(big) {
|
||||
t.Fatalf("oversized frame must pass through untouched, got %d bytes", len(data))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
// Package sampling provides deterministic proportional downsampling used by
|
||||
// history responses. It is independent of transport and storage.
|
||||
package sampling
|
||||
|
||||
import (
|
||||
"math"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// Group is one named set of candidate points.
|
||||
type Group[K comparable] struct {
|
||||
Key K
|
||||
Length int
|
||||
}
|
||||
|
||||
type remainder struct {
|
||||
idx int
|
||||
frac float64
|
||||
}
|
||||
|
||||
// AllocateTargets splits maxTotal across groups proportionally.
|
||||
func AllocateTargets[K comparable](groups []Group[K], maxTotal int) map[K]int {
|
||||
result := make(map[K]int, len(groups))
|
||||
if maxTotal < 0 {
|
||||
for _, g := range groups {
|
||||
result[g.Key] = g.Length
|
||||
}
|
||||
return result
|
||||
}
|
||||
total := 0
|
||||
for _, g := range groups {
|
||||
total += g.Length
|
||||
}
|
||||
if total <= maxTotal {
|
||||
for _, g := range groups {
|
||||
result[g.Key] = g.Length
|
||||
}
|
||||
return result
|
||||
}
|
||||
remainders := make([]remainder, 0, len(groups))
|
||||
sumTargets := 0
|
||||
for i, g := range groups {
|
||||
if g.Length <= 0 {
|
||||
result[g.Key] = 0
|
||||
continue
|
||||
}
|
||||
raw := float64(g.Length) * float64(maxTotal) / float64(total)
|
||||
t := int(math.Floor(raw))
|
||||
if t > g.Length {
|
||||
t = g.Length
|
||||
}
|
||||
result[groups[i].Key] = t
|
||||
sumTargets += t
|
||||
remainders = append(remainders, remainder{i, raw - float64(t)})
|
||||
}
|
||||
if sumTargets < maxTotal {
|
||||
need := maxTotal - sumTargets
|
||||
sortRemainders(remainders, false)
|
||||
for _, r := range remainders {
|
||||
if need == 0 {
|
||||
break
|
||||
}
|
||||
g := groups[r.idx]
|
||||
if result[g.Key] < g.Length {
|
||||
result[g.Key]++
|
||||
need--
|
||||
}
|
||||
}
|
||||
for need > 0 {
|
||||
for _, g := range groups {
|
||||
if need == 0 {
|
||||
break
|
||||
}
|
||||
if result[g.Key] < g.Length {
|
||||
result[g.Key]++
|
||||
need--
|
||||
}
|
||||
}
|
||||
if need > 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
} else if sumTargets > maxTotal {
|
||||
over := sumTargets - maxTotal
|
||||
sortRemainders(remainders, true)
|
||||
for _, r := range remainders {
|
||||
if over == 0 {
|
||||
break
|
||||
}
|
||||
g := groups[r.idx]
|
||||
if result[g.Key] > 0 {
|
||||
result[g.Key]--
|
||||
over--
|
||||
}
|
||||
}
|
||||
for over > 0 {
|
||||
for _, g := range groups {
|
||||
if over == 0 {
|
||||
break
|
||||
}
|
||||
if result[g.Key] > 0 {
|
||||
result[g.Key]--
|
||||
over--
|
||||
}
|
||||
}
|
||||
if over > 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func sortRemainders(r []remainder, asc bool) {
|
||||
sort.Slice(r, func(i, j int) bool {
|
||||
if asc {
|
||||
return r[i].frac < r[j].frac
|
||||
}
|
||||
return r[i].frac > r[j].frac
|
||||
})
|
||||
}
|
||||
|
||||
// SampleEvenly selects k points including both endpoints.
|
||||
func SampleEvenly[T any](in []T, k int) []T {
|
||||
n := len(in)
|
||||
if k <= 0 || n == 0 {
|
||||
return []T{}
|
||||
}
|
||||
if k >= n {
|
||||
return in
|
||||
}
|
||||
out := make([]T, 0, k)
|
||||
if k == 1 {
|
||||
out = append(out, in[n-1])
|
||||
return out
|
||||
}
|
||||
for i := 0; i < k; i++ {
|
||||
idx := int(math.Round(float64(i) * float64(n-1) / float64(k-1)))
|
||||
if idx < 0 {
|
||||
idx = 0
|
||||
} else if idx >= n {
|
||||
idx = n - 1
|
||||
}
|
||||
out = append(out, in[idx])
|
||||
}
|
||||
return out
|
||||
}
|
||||
Loaded 100 of 369 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user