Compare commits

...
492 Commits
Author SHA1 Message Date
Akizon77 c828653d20 refactor: improve metric store lifecycle management with context-aware close and operation gate 2026-07-12 16:52:50 +08:00
Akizon77 ff358edd8b Fix metric rollup compaction and retention 2026-07-12 12:36:27 +08:00
Akizon77 3b48339209 fix(metrics): select compatible rollup intervals 2026-07-12 01:01:03 +08:00
Akizon77 42ef993cd6 feat(metricstore): add downsampling toggle, raise default retention to 90d, and cleanup expired data on compact 2026-07-11 23:48:58 +08:00
Akizon77 39c5f7bb69 Include commits in release notes 2026-07-11 22:38:09 +08:00
Akizon77 9831df0af8 feat(metric): preserve series identity in rollup queries
Add PreserveSeries support to rollup aggregation so rollup and mixed raw paths can keep entity/tag identities instead of always merging matched series. Thread series-aware rollup keys through folding and FillEmpty handling to keep output points aligned with raw aggregation behavior.
2026-07-11 22:00:49 +08:00
Akizon77 29eecf55f7 Use stable release base for snapshot notes 2026-07-11 21:49:39 +08:00
Akizon77 0660ee74d4 Read release note workflow inputs 2026-07-11 21:24:01 +08:00
Akizon77 5ade710f32 Improve release notes generation 2026-07-11 21:07:34 +08:00
Akizon77 855ff82f97 feat(metricstore): track ping packet loss in metrics
Add a ping loss gauge and write it alongside ping latency records so failed ping samples are captured as loss indicators.

Update ping deletion and aggregation paths to include loss data, keeping cleanup and public ping stats consistent across latency and packet loss metrics.
2026-07-11 20:00:01 +08:00
Akizon77 8910c2e9c7 ci: generate AI-assisted release notes from stable tags 2026-07-11 18:14:32 +08:00
Akizon77 b2fe9ab068 feat(metricstore): aggregate record queries via series rollups
Refactor record loading to query metric series with calculated downsample intervals and shared metric mapping. Add all-client time-range loading by discovering record entity IDs, and centralize record sorting to keep large range queries efficient and ordered.
2026-07-11 17:28:13 +08:00
Akizon77 8a529b4eb0 ci: reuse frontend and toolchain actions in workflows
Extract repeated frontend build, frontend restore, and Zig setup steps into shared GitHub Actions so CI workflows use the same artifact and toolchain setup path. Update binary and Docker builds to depend on the frontend artifact, and require an explicit Docker dispatch version for controlled image tagging.
2026-07-11 17:10:01 +08:00
Akizon77 46b4e37178 fix(metricstore): rotate compaction cursor per metric
Track compaction progress across individual metric definitions instead of treating the metric store as one operation. This preserves the rotating cursor through full cycles, resumes from the failed metric on errors, and reports how many rollup buckets were written when compaction fails.

Add a regression test covering cursor stability after a complete rotated compaction pass.
2026-07-11 16:48:10 +08:00
Akizon77 5ad791fe50 feat(metricstore): add scheduled rollup compaction 2026-07-11 16:16:38 +08:00
Akizon77 8911c08a5b fix(metric): preserve series identity during aggregation
Group aggregate buckets by metric, entity, and tag fingerprint so mixed-series inputs do not collapse into one bucket. Carry cloned tags into aggregate output and filled empty buckets to keep results deterministic and isolated by series.
2026-07-11 15:52:29 +08:00
Akizon77 7d95240bc1 feat: add metric retention policy management 2026-07-11 15:08:00 +08:00
Akizon77 7e0d542694 refactor(metrics): use metric retention for cleanup settings
Move scheduled cleanup to MetricStoreConfig.RetentionDays and apply the same cutoff to records, task results, and ping history. Expose derived legacy public settings for theme compatibility while removing record retention fields from Settings.
2026-07-10 23:38:46 +08:00
Akizon77 84130dfff5 fix: run metric store migrations before startup migration
Run one-shot metric store migrations after the metric store is initialized, before backend-switch startup migration begins.

Move legacy monitoring table cleanup out of dbcore initialization so old records are imported through the migration flow before being removed.
2026-07-09 22:55:46 +08:00
Akizon77 e0094447a6 Use UTC+8 for snapshot versions 2026-07-08 23:09:09 +08:00
Akizon77 ab31fc69fe refactor(oauth): remove Cloudflare Access provider support
Remove the Cloudflare Access provider registration and clean up any persisted CloudflareAccess OIDC config during initialization so deployments do not keep referencing the removed provider.

Add a database helper for deleting OIDC configs by name and drop now-unused OIDC/oauth2 dependencies from the module files.
2026-07-08 21:04:46 +08:00
Akizon77 73071ff1e8 refactor: remove built-in Cloudflare tunnel support
Remove cloudflared from the Docker image and server startup lifecycle so the app no longer downloads, starts, or shuts down a managed tunnel process.

Drop the Cloudflare tunnel token setting from config, migrations, and admin routing to keep configuration aligned with the removed integration.
2026-07-08 19:38:30 +08:00
Akizon77 4ad190a7f4 feat(metrics): always enable metric store with startup migration and version-based backup
Enable the metric store unconditionally, dropping the deprecated
metric_store_enabled toggle. When unconfigured it uses SQLite
(./data/metrics.db), otherwise the configured MySQL/PostgreSQL backend.
Initialization failure now aborts startup instead of silently falling
back to the legacy records table.

On startup, run a one-time migration that moves data from the previous
metrics target to the current one when the storage backend changes
(e.g. SQLite to MySQL). Migration failure also aborts startup with a
clear error. The legacy komari.db monitoring tables are fully retired.

Inject a build version ID via dbcore.SetVersionID before Initialize so
dbcore can auto-backup ./data on version upgrades. The version is now
persisted in the configs table (system_version key), replacing the
./data/.komari-version file so it travels with config backups.

Remove records.CompactRecord calls and drop obsolete report cache tests.
2026-07-08 08:37:20 +08:00
Akizon77 6ddb143365 refactor: split startup into app lifecycle stages 2026-07-07 21:44:53 +08:00
airium eb4ca83a13 ci: align snapshot workflow behavior 2026-07-07 18:12:11 +08:00
Akizon77 43c80d238b fix(migration): 恢复迁移时保留已迁移进度计数,避免从0重新累计 2026-07-01 23:28:01 +08:00
Akizon77 416ea871be feat(metrics): 支持迁移暂停/恢复,进度持久化与重启后孤儿状态修复 2026-07-01 23:06:07 +08:00
Akizon77 5c2bd4ec74 perf: 优化 metrics 迁移性能 - 批量写入 + keyset 分页 2026-07-01 22:24:21 +08:00
Akizon77 be6af33d85 merge: metrics DSN autodetect 2026-06-30 23:44:14 +08:00
Akizon77 d02d9347dc feat(metrics): infer DB driver from DSN automatically 2026-06-30 23:43:47 +08:00
Akizon77 64998b527c fix: avoid sqlite table locks during metric migration 2026-06-30 22:43:43 +08:00
Akizon77 9e5b6df196 feat(metrics): hot-reload metrics DB config with connection test before save 2026-06-30 21:28:04 +08:00
Akizon77 332053d8ec fix: avoid sqlite database is locked failures for record writes 2026-06-30 19:28:00 +08:00
Akizon b6fcdd396d Merge pull request #583 from Torther/main
fix(telegram): 非 200 响应时返回 API 错误描述而非仅状态码
2026-06-30 18:39:06 +08:00
Akizonandguocn 92ec774249 fix(theme): prevent path traversal in theme operations (#584)
DeleteTheme/UpdateTheme/SetTheme used the user-supplied theme name
(short) directly in filepath.Join("./data/theme", short) before
filesystem operations (os.RemoveAll / os.Stat). Without validation,
a crafted short value like "../../" normalizes outside the theme
directory, letting an authenticated admin delete or access arbitrary
directories within the process working directory.

Reuse the existing isValidThemeShort validator (allowlist of
[a-zA-Z0-9_-]) in all three handlers before the path is built.
Add a regression test covering traversal payloads and absolute paths.

Co-authored-by: guocn <guocn@users.noreply.github.com>
2026-06-30 18:38:20 +08:00
guocn 404d97d846 fix(theme): prevent path traversal in theme operations
DeleteTheme/UpdateTheme/SetTheme used the user-supplied theme name
(short) directly in filepath.Join("./data/theme", short) before
filesystem operations (os.RemoveAll / os.Stat). Without validation,
a crafted short value like "../../" normalizes outside the theme
directory, letting an authenticated admin delete or access arbitrary
directories within the process working directory.

Reuse the existing isValidThemeShort validator (allowlist of
[a-zA-Z0-9_-]) in all three handlers before the path is built.
Add a regression test covering traversal payloads and absolute paths.
2026-06-29 23:12:42 +08:00
Akizon77 0132d1cbd8 Merge branch 'refactor/metrics-unify-storage': unify storage through metric store + getNodes map 2026-06-29 21:54:00 +08:00
Akizon77 ff6daa6ce3 fix(rpc): common:getNodes returns uuid-keyed map instead of array
Each value still contains its own uuid field. Single-uuid query keeps returning a single object.
2026-06-29 21:52:16 +08:00
Akizon77 6a5f3ef04d refactor(metrics): route all storage through metric store when enabled
- Fix all-clients query bypassing metric store (empty uuid path)

- Write records/GPU to metric store when enabled; traffic baseline from metric store

- Fix traffic report bypassing metric store (would report zero traffic)

- Use dedicated gpu.device.usage metric to avoid collision with entity-level gpu.usage

- Remove legacy GPU tables (gpu_records/gpu_records_long_term) and related code

- Remove never-used dead code: RecordOne/RecordGPU/GetLatestRecord/GetAllRecords/SaveClientReport/SaveReport/ParseReport
2026-06-29 21:46:53 +08:00
Torther da047cf612 fix(telegram): include API error description in non-200 responses
SendTextMessage checked resp.StatusCode before decoding the JSON body, so non-200 responses returned only the status code without the error reason.

Move JSON decode before the status check. When the status is not 200, use the description field from the API response as the error message. Fall back to the original status code message if decode fails or description is empty.
2026-06-29 10:12:38 +08:00
Akizon77 f0877d6d32 fix(database): use FromTime() for time queries and improve cache cleanup 2026-06-28 22:28:57 +08:00
Akizon77 f2c46dbfb1 feat: route metric reads and writes to dedicated store 2026-06-28 22:08:35 +08:00
Akizon77 5f50d1ba00 Merge branch 'fix/temp-share-private-site' 2026-06-28 21:00:52 +08:00
Akizon77 7e19909dd6 fix: allow temp-share guests to bypass private site block
Private site dispatch only checked anonymous principal against the login whitelist and ignored meta.TempShareValid, so valid temp_key share links were rejected with -32041 on the analysis methods. Add !meta.TempShareValid to the gate; CheckPrincipal still limits anonymous callers to public:* (guest).
2026-06-28 21:00:41 +08:00
Akizon77 9db4daac10 ci: limit build workflow to PRs and fix snapshot release creation 2026-06-28 20:14:17 +08:00
Akizon 0a4cd6bd8c Merge pull request #578 from komari-monitor/feat/vacuum-database
feat: add database vacuum and size API endpoints
2026-06-28 19:33:44 +08:00
Akizon77 20c865ed20 feat(install): add TUI support with whiptail/dialog and text fallback 2026-06-28 19:32:07 +08:00
Akizon77 e740bf2797 feat(install): add stable/snapshot channel selection for binary install and upgrade 2026-06-28 19:28:43 +08:00
Akizon77 074958fad7 feat: add database vacuum and size API endpoints
Add admin RPC methods getDatabaseSize and vacuumDatabase, and register /api/admin/database/size and /api/admin/database/vacuum routes. VACUUM compacts the SQLite database to reclaim disk space.
2026-06-28 18:59:16 +08:00
Akizon77 11d3ba8236 fix(rpc): allow login-page endpoints in private-site mode
Fixes #567 where enabling private site blocked the login page itself,
making it impossible to log in from external IPs.

The Dispatch private-site check was too broad, blocking all anonymous
requests including the meta endpoints needed to render the login page
(getMe, getPublicSettings, getVersion). Now exempt these from the
private-site block via whitelist.

Adds regression test to guard the required whitelist entries.
2026-06-28 00:38:29 +08:00
Akizon77 ce41745edb refactor(rpc): migrate ACL to capability-based Principal model 2026-06-28 00:19:48 +08:00
Akizon77 036121c148 refactor(rpc): unify identity into Principal model 2026-06-27 23:50:21 +08:00
Akizonandyuanhhs dd0539392b Preserve RPC node order (#566)
Co-authored-by: yuanhhs <yuanhhs@users.noreply.github.com>
2026-06-27 23:23:36 +08:00
Akizon77 50ba8310ca Merge PR #571: fix(rpc): enforce sensitive-operation 2FA at the RPC boundary 2026-06-27 23:20:35 +08:00
carbofish 50fe286528 fix(rpc): enforce sensitive-operation 2FA at the RPC boundary
Sensitive methods (admin:exec) were gated by sensitive-operation 2FA only through REST route middleware. Move the enforcement to the JSON-RPC boundary so every entrypoint applies the same check:

  - pkg/rpc: add a transport-agnostic sensitive-method registry (MarkSensitive / IsSensitive).
  - web/rpc/jsonrpc: verify sensitive-operation 2FA in dispatchWithSensitive before dispatch, covering the REST bridge, /api/rpc2 POST and WebSocket.
  - mark admin:exec sensitive.

API-key callers and the existing no-2FA-configured semantics are preserved; non-sensitive methods are unaffected.
2026-06-27 05:18:45 +08:00
yuanhhs 144efdc8ad Preserve RPC node order 2026-06-22 07:42:49 +08:00
Akizon77 e446bee900 ci: expand release notes 2026-06-21 20:53:30 +08:00
Akizon77 a980befd60 fix(metric): isolate compaction scan and delete to prevent data loss
Add compactTxOptions to the dialect interface so the raw scan and raw
deletion in CompactMetric observe a consistent view. PostgreSQL and
MySQL escalate to SERIALIZABLE, ensuring a point inserted between the
scan and the deletion cannot be removed before it is rolled up; on
PostgreSQL this surfaces as a serialization failure that is retried,
while InnoDB blocks the conflicting insert via locking reads. SQLite
returns nil since its single write connection already serializes writes.

Add regression tests covering partial fine-row overwrites of coarse
rollups during compaction.
2026-06-21 17:00:01 +08:00
Akizon77 2e0c598dae feat(metric): support arbitrary percentile aggregations 2026-06-21 01:11:07 +08:00
Akizon77 771a192a3d chore(deps): upgrade Go and add SQL database drivers
Bump the module target to Go 1.25 and refresh dependency metadata.

Add MySQL, PostgreSQL, and SQLite drivers as direct dependencies to support SQL backends, while updating related transitive modules and checksums.
2026-06-20 20:11:16 +08:00
Akizon77 95cc65e28d docs: add abuse warning and remote control disclosure 2026-06-17 19:53:46 +08:00
airium b6e36ee0c4 Merge pull request #555 from airium/fix-traffic-report
fix: 修复流量报告为 0 B 问题
2026-06-17 19:21:30 +08:00
Akizon 25f3e1d2bd Merge pull request #551 from komari-monitor/fix/rpc2-route-contract-regressions
fix(router): 修复 RPC2 迁移引入的接口契约回归
2026-06-11 17:06:51 +08:00
Akizon77andClaude Opus 4.8 091eafa743 fix(router): 修复 RPC2 迁移引入的接口契约回归
对照 1.1.7 与重构前基线逐路由核对,修复 3 处契约破坏:

- /api/clients 是 WebSocket 端点(api.GetClients,客户端发 "get"/"get <uuid>"
  拉取在线列表与最新上报),迁移时误绑为 REST public:getNodesInformation,
  导致 wss:// 连接失败。还原为 api.GetClients。
- POST /api/admin/settings/oidc 与 /message-sender:原响应为
  {status,message:"",data:{message:"..."}},误用 WithFlat() 变成
  {status,message:"..."}(丢失 data 层)。移除 WithFlat,恢复 data.message 契约。

其余路由(WS/流端点、响应渲染器、完整路径)经与 1.1.7 全量比对确认一致。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 17:03:24 +08:00
Akizon cbe665591f Merge pull request #550 from komari-monitor/refactor/rpc2-unified-pipeline
refactor(rpc): 统一 RPC2 管道并全量迁移 REST 接口
2026-06-11 16:36:57 +08:00
Akizon77andClaude Opus 4.8 0abd9ff9bc refactor(rpc): 统一 RPC2 管道并全量迁移 REST 接口
将混乱的「Web 接口 → 业务逻辑」收敛为统一管道:
接口 → 生成 RPC 请求 → Dispatch(权限+命名空间) → RPC handler(业务),
为后续 JS 插件接入打地基(插件本身未实现)。

核心:
- pkg/rpc/permission.go:声明式 ACL + 通配符权限,按特异性裁决
  (精确 > 字面前缀更长的通配 > 全局 *)。Allow/RegisterNamespace 供插件扩展。
- pkg/rpc:新增 Unregister(动态注销,供插件卸载);ContextMeta 增 SessionToken/TempShareValid。
- web/rpc/jsonrpc 分层:dispatch(统一 Dispatch+OnInternalRequest)、
  transport(HTTP/WS+CallFromGin)、register、bridge(声明式路由桥)。
  WS 改同步写,删除旧 JsonRpc.go 的死代码 registry。

全量迁移:
- 所有 JSON 接口迁为 RPC2 方法(admin/public/client 三命名空间),
  router 经 bridge.Bind 直接绑定,删除整个 per-resource gin handler 层。
- 渲染器覆盖全部响应形状:standard/flat/raw/message。
- agent v1/v2 上报逻辑统一到 web/api/client/ingest.go。

文件收敛:
- web/api/admin 从 ~20 文件+5 子目录降到 6 个 REST-binary 文件
  (2fa/theme/download/upload/update/oauth),update 子包合并。
- 二进制/流/重定向/特殊鉴权类保留为 REST。

开发者文档见 web/rpc/jsonrpc/README.md。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 15:05:49 +08:00
airium 618ced3b8f test(auth): remove obsolete dev cookie bypass coverage (#547) 2026-06-11 12:22:27 +08:00
Akizon77 f7fd459723 fix(auth): only set secure session cookie over HTTPS 2026-06-10 20:48:43 +08:00
Akizon77 bb22a818bf fix(client): fill GeoIP region for v2 basic info 2026-06-10 01:12:02 +08:00
Akizon77 b66fe47487 fix(config): rename CORS toggle to origin check setting
Replace the legacy allow_cors setting with cors_origin_check_enabled
throughout server setup, public info output, and configuration constants.
This clarifies that the option controls API CORS origin validation rather
than broadly enabling CORS.
2026-06-09 17:27:06 +08:00
Akizon77 98122fa4d1 ci: generate release notes from GitHub 2026-06-09 16:46:33 +08:00
Akizon77 c3f65b1a32 fix go tests 2026-06-08 21:14:27 +08:00
Akizon77 7da616ce4c feat(security): add configurable CORS and origin checks
Add settings for allowed CORS origins and WebSocket origin validation so
deployments can restrict cross-origin access instead of using a global
wildcard policy.

Move CORS handling into the security middleware and update report cache
usage to use the dedicated report package.
2026-06-08 20:45:00 +08:00
airium 9d914e70b9 fix: harden traffic report accounting and validation (#542)
- Store per-record traffic deltas and sum them for scheduled reports
- Preserve traffic delta totals during long-term record compaction
- Compact only complete 15-minute buckets to avoid partial-bucket undercounts
- Serialize report saves per client so concurrent uploads cannot double-count deltas
- Enforce enabled traffic report cadence validation across edit and enable paths
- Add regression coverage for compaction boundaries, cadence validation, and client save locking
2026-06-08 11:26:13 +08:00
Akizon77 6f0624e9b4 Merge branch 'main' into codex/theme-configuration-types 2026-06-08 01:58:52 +08:00
Akizon77 3cc67e11cd fix: align theme redirect and html language handling 2026-06-08 01:53:28 +08:00
Akizon77 742a5e953e feat: support raw and redirect themes 2026-06-08 01:53:28 +08:00
Akizon77 3b82cee4b1 fix: align theme redirect and html language handling 2026-06-08 01:53:20 +08:00
Akizon77 2b010954a3 feat: support raw and redirect themes 2026-06-08 00:48:37 +08:00
Akizon77 6f23565517 refactor: isolate startup migrations 2026-06-08 00:01:32 +08:00
airium 7c2896c3fe fix(remote-exec): preserve command text during validation (#543)
Reject whitespace-only remote exec commands without trimming the submitted
command body, so multiline scripts and meaningful leading/trailing content are
preserved through dispatch.
2026-06-07 23:54:43 +08:00
Akizon77 721d61bc4e docs: update default theme build path 2026-06-06 22:59:38 +08:00
kylvenp 014b236745 feat: add network test rpc protocol structs 2026-06-06 21:34:48 +08:00
Akizon77 80c8d7e5ee Merge pull request #537 from airium/fix-http-development 2026-06-06 20:05:30 +08:00
Akizon77 8423881cc4 Merge pull request #541 from airium/support-phy-cpu-count 2026-06-06 19:59:16 +08:00
Akizon77 a1d644977c Merge pull request #538 from airium/fix-theme-path 2026-06-06 19:59:05 +08:00
Akizon77 301fb536f2 Merge pull request #539 from aijwfsibari/main 2026-06-06 19:53:17 +08:00
airium 49a8d772c8 feat: support physical CPU core count 2026-06-06 19:15:03 +08:00
aijwfsibari a9adae29d2 add Traffic report patch 2026-06-05 15:04:21 +08:00
airium d6e23baa19 fix: correct defaultTheme path across CI and docs
The Go embed directive in web/public/public.go resolves defaultTheme
relative to the package directory (web/public/defaultTheme/),
but all CI workflows and documentation referenced public/defaultTheme/
which would have caused builds to fail or embed an empty directory.
2026-06-02 13:24:23 +08:00
airium 658e7603f8 fix: allow insecure session cookies in development build 2026-06-02 02:31:01 +08:00
Akizon77 fb0b9b9910 feat(agent): add v2 pull events for queued agent tasks
Introduce agent.pull protocol types with event metadata and ACK fields.
Route ping dispatch through the agent API so tasks can be queued for
pull-based clients instead of only sent over active WebSocket sessions.
2026-05-31 19:01:02 +08:00
Akizon77 e149e8b8b7 refactor: remove legacy client APIs and support v2 pings
Remove deprecated client database helpers tied to common protocol types and
update report handling to use protocol/v1 models. Send ping tasks with the
v2 JSON-RPC payload for v2 clients while preserving existing behavior for
legacy websocket clients.
2026-05-31 16:50:04 +08:00
Akizon77 d2cc1503de refactor: centralize scheduled jobs with corn package
Replace ad-hoc ticker loops with named cron-based scheduled tasks for cleanup,
minute reporting, and expiration checks. Add scheduler shutdown handling and
log errors when schedules fail to reload or register.

Update imports to use the relocated pkg/config package across affected code.
2026-05-31 15:54:23 +08:00
Akizon77 f86803feff refactor(web): move HTTP route setup out of server
Relocate API routing, OAuth, and Nezha compatibility wiring into the web package to keep server startup focused on initialization concerns and improve code organization.
2026-05-31 15:35:47 +08:00
flyflas a2e3c7218d Merge pull request #525 from flyflas/feat/addXtermjsSetting
Feat/add xtermjs setting
2026-05-31 14:14:16 +08:00
Akizon77 567d2b66fb fix(sessions): 添加移除过期会话功能并更新定时任务 2026-05-08 12:44:37 +08:00
Akizon 473dbb9057 Merge pull request #519 from iamsxm/feature/choose-all
feat(ping): 延迟监测增加默认开启选项
2026-05-07 20:57:29 +08:00
Akizon 89bd47d5df Merge pull request #510 from KanameMadoka520/feature/cloudflared-followup
feat: improve built-in cloudflared tunnel management
2026-05-07 20:52:04 +08:00
Akizon 10c4a9dafc Merge pull request #511 from Lixsp11/db-opt
fix: Optimize database indexes and improve performance on I/O-constrained hosts
2026-05-07 20:51:49 +08:00
Akizon f4d73bf8c9 Merge pull request #504 from paulzzh/patch-1
fix: mjpeg 价格周期与剩余时间显示错误
2026-05-07 20:48:16 +08:00
xiaomi 441e2e9194 feat(ping): 延迟监测增加默认开启选项 2026-05-01 23:21:40 +08:00
lixsp11 c15881c463 fix: Avoid rewriting the database at startup, and create composite indexes to boost query. 2026-04-16 23:04:05 +08:00
KanameMadoka520 f5acbd2efe refactor: narrow cloudflared backend PR scope 2026-04-15 21:49:59 +08:00
KanameMadoka520 e07378c92b docs: document built-in cloudflared support 2026-04-15 07:28:21 +08:00
KanameMadoka520 cd38d32a7d build: bundle cloudflared in the Docker image 2026-04-15 07:18:20 +08:00
KanameMadoka520 53f326390e feat: add managed cloudflared backend APIs 2026-04-15 07:18:10 +08:00
Akizon 651d24128c Merge pull request #509 from airium/fix-timeout-if-empty-exe-result
fix: 防止无返回文本的远程执行结果被后端拒绝而变成超时
2026-04-14 01:32:22 +08:00
airium 2660026917 fix: make task result field optional in request 2026-04-13 21:40:59 +08:00
paulzzh ac7ace27cb refactor: 更名翻译键 2026-04-12 04:43:30 +08:00
paulzzh 1de008269b fix: mjpeg 剩余时间显示错误 2026-04-12 04:32:53 +08:00
paulzzh 4bdbff0377 fix: mjpeg 价格周期显示错误 2026-04-12 04:21:10 +08:00
Akizon 3415c06256 Merge pull request #485 from airium/main
feat: 支持自定义排序延迟检测任务
2026-03-30 16:38:14 +08:00
Akizon 9f3da4c17a Merge pull request #495 from tranquac/fix/ssrf-theme-download
fix: prevent SSRF in theme download by blocking private/internal IPs
2026-03-30 16:37:43 +08:00
quactv 3d0c83557e fix: prevent SSRF in theme download by blocking private/internal IPs
Signed-off-by: tranquac <tranquac@users.noreply.github.com>
2026-03-27 21:50:41 +07:00
airium e319eb7f20 fix: harden ping task ordering update semantics 2026-03-14 23:31:27 +08:00
airium 7f007b7eb9 feat: Add reorder API to ping tasks 2026-03-13 18:18:57 +08:00
Akizon 9e665005e6 Merge pull request #484 from komari-monitor/fix/流量限制溢出
fix: #393 验证流量限制字段为有效的非负整数值
2026-03-10 15:41:33 +08:00
Akizon77 b5b32b5f64 fix: #393 验证流量限制字段为有效的非负整数值 2026-03-10 15:40:53 +08:00
Akizon 89a699b6cb Merge pull request #483 from komari-monitor/fix/IP地址乱码
fix: #363 IP地址出现乱码
2026-03-10 15:29:22 +08:00
Akizon77 79cbf2d2d1 fix: #363 IP地址出现乱码 2026-03-10 15:28:38 +08:00
Akizon ab00c9529e refactor: 按照业务整理api接口 (#482)
* refactor(wip): 按照业务整理API接口

* refactor: 整合认证中间件
fix: #481 Api 鉴权获取UUID为空
2026-03-10 15:23:54 +08:00
Akizon77 21500a6727 refactor: 整合认证中间件
fix: #481 Api 鉴权获取UUID为空
2026-03-10 15:21:02 +08:00
Akizon77 066b944789 refactor(wip): 按照业务整理API接口 2026-03-10 14:37:22 +08:00
Akizon d7dd816a26 Merge pull request #478 from Real-Wanger/main
fix: reset traffic cache when traffic drops
2026-03-04 14:17:34 +08:00
Wanger c5b464c000 fix: reset traffic cache when traffic drops
修复当节点流量被清零重置时,内存中的告警进度缓存未同步清理,导致无法触发后续阈值通知的 Bug。
2026-03-02 14:27:42 +08:00
Akizon 645c733393 Merge pull request #468 from wayjam/main
feat: import theme api #467
2026-02-26 19:46:09 +08:00
Akizon 85088e64ca Merge pull request #471 from vhvy/feat/keepalive-via-post
feat: 支持 HTTP POST 上报实时信息和在线状态管理,添加 HTTP 接口获取 Ping 任务和提交 Ping 结果
2026-02-26 19:40:05 +08:00
Akizon77 896b3e45fc fix: 添加 base_scripts_url 字段到 Legacy 结构体 #472 2026-02-26 19:04:18 +08:00
vhvy 490f951b59 feat: 添加 HTTP 接口获取 Ping 任务和提交 Ping 结果 2026-02-23 21:27:03 +08:00
vhvy 69c96255d7 feat: 支持 HTTP POST 上报实时信息保活及在线状态管理 2026-02-23 21:24:14 +08:00
WayJam So 487542e55d feat: import theme api #467 2026-02-21 16:55:50 +08:00
Akizon ca8fe76c06 Merge pull request #463 from komari-monitor/refactor/增强临时分享的兼容性
refactor: 增强临时分享的兼容性
2026-02-19 00:15:29 +08:00
Akizon77 e6e3619cd2 refactor: 增强临时分享的兼容性 2026-02-19 00:14:23 +08:00
Akizon77 92ab67d5f3 feat: 添加临时访问许可功能,允许使用临时密钥访问私有站点 2026-02-16 00:34:43 +08:00
Akizon77 77f5bef1a5 fix: 配置默认值 2026-02-15 14:17:03 +08:00
Akizon77 58e43a0862 fix: 增强 WebSocket 升级错误消息,包含详细错误信息 2026-02-12 13:41:01 +08:00
Akizon 80c1f2747a Merge pull request #455 from komari-monitor/fix/expire-notification-panic
fix: 修复过期通知的通知提前天数类型转换错误
2026-02-12 13:13:12 +08:00
Akizon77 b7b2d8b050 fix: 修复过期通知的通知提前天数类型转换错误 2026-02-12 13:12:16 +08:00
Akizon77 b9eabd7cdc docs: 添加 1Panel 应用商店的安装说明 2026-02-10 22:54:48 +08:00
Akizon77 0a19465140 docs: 添加破碎工坊云的介绍和链接 2026-02-09 23:08:57 +08:00
Akizon e874b5db18 Merge pull request #451 from komari-monitor/fix/config-panic
Fix/config panic
2026-02-08 15:49:00 +08:00
Akizon77 23e7d97e3c fix: 流量提醒 2026-02-08 15:48:02 +08:00
Akizon77 a222de2c55 fix: 修复 GetAs 函数中的默认值类型不匹配错误 2026-02-08 13:26:43 +08:00
Akizon bb1eed1ccb Merge pull request #448 from komari-monitor/fix-path-traversal-vulnerability
fix: 修复路径穿透安全漏洞
2026-02-07 23:39:57 +08:00
Akizon77 c34bc8da69 fix: 修复路径穿透安全漏洞
- 添加 isSafePath 函数验证路径是否在允许的目录内
- 在 getFileContent 中验证主题 ID 和文件路径
- 清理所有用户输入的路径,防止 ../ 等路径穿透攻击
- 确保嵌入式文件系统路径也经过验证

Security: 防止攻击者通过构造特殊路径访问系统任意文件
2026-02-07 23:39:08 +08:00
Akizon77 249c4376f1 fix: 异步发送登录信息 2026-02-07 23:02:45 +08:00
Akizon77 d36bde7d94 fix: 默认主题不显示 2026-02-07 22:59:50 +08:00
Akizon 493d2a2707 Merge pull request #447 from komari-monitor/feat/default-theme
feat: default theme
2026-02-07 20:39:53 +08:00
Akizon77 93a9fbe5cc feat: default theme 2026-02-04 22:20:45 +08:00
Akizon b070f33bf9 Merge pull request #445 from komari-monitor/refactor/config-structure
refactor: 调整配置结构
2026-02-04 19:46:01 +08:00
Akizon ee5480dba7 Merge pull request #442 from flyflas/add-email-html-support
feat:add_email_html_support
2026-02-04 19:43:09 +08:00
Akizon77 2e9ef3d5ba refactor: 调整配置结构 2026-02-04 19:39:48 +08:00
xiaobai 1061b84087 feat:add_email_html_support 2026-01-29 14:44:58 +08:00
Akizon77 0dc80f7696 更新依赖项 2026-01-29 01:17:36 +08:00
Akizon77 212e3c689b feat: 添加网络流量指标支持 2026-01-29 01:05:49 +08:00
Akizon77 488ca451ae feat: RPC 内部请求,MJPEG流 2026-01-29 00:44:19 +08:00
Akizon77 f50c167b37 fix: 当您修改历史数据保存时间相关设置时,需要重新启动服务端才会生效 2026-01-29 00:33:29 +08:00
Akizon77 4e91d62d48 feat: 添加 WAL 检查点优化数据库性能 2025-11-14 16:44:48 +08:00
Akizon a1b0b1e177 Merge pull request #355 from komari-monitor/dev
docs: 添加镜像站链接 https://github.com/komari-monitor/komari-document/issue…
2025-11-09 11:52:36 +08:00
Akizon77 147dd2011a docs: 添加镜像站链接 https://github.com/komari-monitor/komari-document/issues/10 2025-11-09 11:43:05 +08:00
Akizon77 d24b71d4af docs: 移除 ClawRun - 云平台未更新维护且开发者无法更改内容 2025-11-04 23:16:14 +08:00
Akizon 483e3a94e9 remove Claw Run 2025-11-04 22:10:48 +08:00
Akizon e612f5ed8f Add badges for GitHub and DeepWiki to README 2025-11-04 21:38:45 +08:00
github-actions[bot] 78f3c8fd4b Merge pull request #350 from komari-monitor/dev
fix: #344 引入更宽松的认证方式
2025-11-04 06:50:01 +00:00
Akizon77 5a4e073d63 fix: #344 引入更宽松的认证方式 2025-11-04 14:49:34 +08:00
Akizon77 22cbfecfa9 feat: 整合日志功能 2025-11-04 14:36:40 +08:00
github-actions[bot] d1b059fc2f Merge pull request #349 from komari-monitor/dev
feat: 添加对 Server酱Turbo & Server酱³ 推送通道支持(SendKey)
2025-11-04 05:02:09 +00:00
Akizon cf5c410aa6 Merge branch 'main' into dev 2025-11-04 13:01:53 +08:00
Akizon 9c46673a1a Merge pull request #348 from RSJWY/dev
feat: 添加对 Server酱Turbo & Server酱³ 推送通道支持(SendKey)
2025-11-04 00:50:10 +08:00
RSJWY 45c27ae981 feat: 添加对 Server酱Turbo & Server酱³ 推送通道支持(SendKey) 2025-11-03 22:29:55 +08:00
akizon77 b62fb1f708 feat: 添加 JavaScript 消息发送器,支持 fetch 和 XMLHttpRequest API 2025-10-25 20:43:14 +08:00
github-actions[bot] 579c196643 Merge pull request #335 from komari-monitor/dev
feat: 添加 NOTICE 文件,包含版权和许可证信息
2025-10-18 09:16:09 +00:00
Akizon77 e8da6e213a feat: 添加 NOTICE 文件,包含版权和许可证信息 2025-10-18 17:15:20 +08:00
github-actions[bot] d9426e43a9 Merge pull request #333 from komari-monitor/dev
feat: 添加 EULA 接受字段到配置模型
2025-10-17 17:15:38 +00:00
Akizon77 aad0274d56 feat: 添加 EULA 接受字段到配置模型 2025-10-18 00:15:05 +08:00
github-actions[bot] d65f7ca924 Merge pull request #331 from komari-monitor/dev
fix: 重复注册Bark
2025-10-15 05:14:01 +00:00
Akizon77 a4d95640a5 fix: 重复注册Bark 2025-10-15 13:13:31 +08:00
Akizon77 adda3a2488 docs: 移除Crowdin 2025-10-15 13:09:18 +08:00
github-actions[bot] 34654c0969 Merge pull request #320 from komari-monitor/dev
fix: #319 伪随机 token 可预测
2025-10-09 11:09:44 +00:00
Akizon77 b708f9817d fix: 伪随机 token 可预测 2025-10-09 19:09:11 +08:00
github-actions[bot] 1210a1cbd9 Merge pull request #314 from komari-monitor/dev
refactor: 重写备份上传功能
2025-10-06 08:10:07 +00:00
Akizon77 24f1a673b9 refactor: 重写备份上传功能 2025-10-06 16:07:36 +08:00
github-actions[bot] a3162e1992 Merge pull request #313 from komari-monitor/dev
feat: 添加配置项以控制是否向访客发送 IP 地址
2025-10-06 07:31:40 +00:00
Akizon77 17f904e4ab feat: 添加配置项以控制是否向访客发送 IP 地址 2025-10-06 13:52:12 +08:00
github-actions[bot] 1324f4fa21 Merge pull request #312 from komari-monitor/dev
refactor: 重写下载备份功能
2025-10-06 05:06:40 +00:00
Akizon77 cf15144bb9 refactor: 重写下载备份功能 2025-10-06 13:06:01 +08:00
github-actions[bot] 946b27b3b8 Merge pull request #309 from komari-monitor/dev
feat: 启用SQLite的WAL模式
2025-10-03 06:12:48 +00:00
Akizon77 aed5254cf0 feat: 启用SQLite的WAL模式 2025-10-03 14:11:47 +08:00
github-actions[bot] 7b8922b7ac Merge pull request #296 from komari-monitor/dev
feat: 添加自定义脚本连接域名配置
2025-09-26 13:58:45 +00:00
Akizon77 733ad4c6c0 feat: 添加自定义脚本连接域名配置 2025-09-26 21:58:01 +08:00
github-actions[bot] ef98ddd1d3 Merge pull request #295 from komari-monitor/dev
fix: 优化默认通知模板
2025-09-26 13:55:08 +00:00
Akizon77 ca28c40aca fix: 优化默认通知模板 2025-09-26 21:54:34 +08:00
github-actions[bot] 0af5903bfe Merge pull request #283 from komari-monitor/dev
fix: 增强模板替换功能,支持 JSON 内容类型的占位符转义
2025-09-21 14:09:52 +00:00
akizon77 717b7b4a88 fix: 增强模板替换功能,支持 JSON 内容类型的占位符转义 2025-09-21 22:08:54 +08:00
github-actions[bot] 05eec9515b Merge pull request #282 from komari-monitor/dev
fix: 处理QQ发送消息时的短响应错误
2025-09-21 13:42:07 +00:00
akizon77 877852fc29 fix: 处理QQ发送消息时的短响应错误 2025-09-21 21:38:00 +08:00
github-actions[bot] dbb9075a52 Merge pull request #281 from komari-monitor/dev
fix: 实现按任务ID分组的记录比例下采样
2025-09-20 16:43:49 +00:00
akizon77 eafcf7fe76 fix: 实现按任务ID分组的记录比例下采样 2025-09-21 00:43:00 +08:00
github-actions[bot] 094ce6c1ba Merge pull request #280 from komari-monitor/dev
feat: 更新通知机制,使用事件消息替代文本消息
2025-09-20 11:41:25 +00:00
akizon77 2eed2cf7ba fix: 测试消息使用事件 2025-09-20 17:37:22 +08:00
akizon77 88b9d3bdac Merge branch 'main' into dev 2025-09-20 17:22:29 +08:00
Akizon 9c87227613 Merge pull request #269 from kdwycz/dev
新增了GPU监控数据接收存储
2025-09-17 16:53:46 +08:00
Akizon77 bc737b1ef9 feat: 更新通知模板,添加表情和消息内容 2025-09-17 13:50:19 +08:00
Akizon77 911d990690 feat: 更新通知机制,使用事件消息替代文本消息 2025-09-17 13:41:17 +08:00
kdwycz 0f7cd5f438 新增了GPU监控数据接收存储
接收agent发送的gpu监控数据.
参考Record实现, 新增GPURecord表存储监控数据和压缩监控数据.
2025-09-15 20:23:16 +08:00
github-actions[bot] 354903b97b Merge pull request #264 from komari-monitor/dev
fix: 修正 WebSocket 升级逻辑
2025-09-14 05:06:52 +00:00
Akizon77 b432396111 fix: 修正 WebSocket 升级逻辑 2025-09-14 13:06:02 +08:00
github-actions[bot] 491c0ad531 Merge pull request #263 from komari-monitor/dev
feat: 延迟显示更多信息
2025-09-14 02:41:08 +00:00
Akizon77 1540b78487 feat: 延迟显示更多信息 2025-09-14 01:17:51 +00:00
Akizon77 11f43b116d fix: 修正Uptime 2025-09-13 20:05:46 +08:00
github-actions[bot] a0760c8e93 Merge pull request #261 from komari-monitor/dev
feat: rpc2最近信息补全延迟、uptime
2025-09-13 09:30:40 +00:00
github-actions[bot] 85ecfc3500 Merge pull request #260 from komari-monitor/dev
feat: 添加 Nezha 兼容 gRPC 服务器的启动和停止功能
2025-09-13 09:29:10 +00:00
Akizon77 4c2308622a feat: rpc2最近信息补全延迟、uptime 2025-09-13 17:28:04 +08:00
Akizon77 25b6a25f9a fix: 更新 OAuthCallback 重定向路径,从 /manage 改为 /admin 2025-09-13 16:19:51 +08:00
Akizon77 7138c22d02 refactor: 移除不必要的 nezhaBoot 变量,更新 StopNezhaCompat 以强制停止服务器 2025-09-13 15:52:57 +08:00
Akizon77 a06a5c9e72 feat: 替换 StartNezhaCompatServer 为 StartNezhaCompat,标记为已弃用 2025-09-13 15:45:20 +08:00
Akizon77 668eb63668 feat: 添加 Nezha 兼容 gRPC 服务器的启动和停止功能 2025-09-13 15:09:34 +08:00
Akizon77 7d75f1ba37 feat: 在客户端自动创建时记录审计日志事件 2025-09-13 15:01:20 +08:00
github-actions[bot] 2c7b59985e Merge pull request #259 from komari-monitor/dev
feat: 添加数据掩码功能以保护敏感信息
2025-09-13 06:36:17 +00:00
Akizon77 b616eeb2d4 feat: 添加数据掩码功能以保护敏感信息 2025-09-13 14:35:42 +08:00
Akizon77 0f0e359c63 wip: 通知 2025-09-12 03:01:42 +00:00
Akizon77 1b83644788 docs: 更新 Sharon 图片 2025-09-10 04:40:13 +00:00
github-actions[bot] 9a9200744e Merge pull request #250 from komari-monitor/dev
feat: 兼容哪吒Agent
2025-09-09 14:41:47 +00:00
Akizon77 e763bc12c4 docs: 添加 SharonNetworks 的赞助信息 2025-09-09 14:39:39 +00:00
Akizon77 8d7b47a7de feat: 兼容哪吒Agent 2025-09-08 13:38:47 +00:00
github-actions[bot] d7b3c3443d Merge pull request #240 from komari-monitor/dev
feat: 未绑定第三方账户时不能关闭密码登录
2025-09-06 04:03:58 +00:00
Akizon77 8559083d66 feat: 未绑定第三方账户时不能关闭密码登录 2025-09-06 04:02:22 +00:00
github-actions[bot] 96ef3c9fd5 Merge pull request #239 from komari-monitor/dev
feat: 添加JSON-RPC2接口
2025-09-06 03:21:32 +00:00
Akizon77 3cace1fce0 feat: 添加 maxCount 参数以限制记录数量并实现分组下采样 2025-09-06 03:16:18 +00:00
Akizon77 f2adfcc052 feat: 添加获取记录的 RPC 方法及相关逻辑 2025-09-06 03:02:11 +00:00
Akizon77 b31bfa8ba9 feat: 添加获取版本和节点最近状态的方法 2025-09-06 02:44:27 +00:00
Akizon77 b098509df4 feat: 添加 getMe 方法 2025-09-02 14:12:00 +00:00
Akizon77 2df23b6ddb feat: 更新 getNodesLatestStatus 支持通过多个 UUID 获取节点最新状态 2025-08-31 20:44:32 +08:00
Akizon77 6dbfdf7f89 feat: 添加获取节点最新状态的 RPC 方法及相关逻辑 2025-08-31 20:37:46 +08:00
Akizon77 5d8b0d9c91 feat: rpc 添加公开信息方法 2025-08-31 13:15:39 +08:00
Akizon77 fa00885d8b feat: 增强 JSON-RPC 支持,添加批量请求处理及权限管理 2025-08-31 12:47:36 +08:00
Akizon77 f3b52b3282 feat: 添加 JSON-RPC2 支持及相关方法注册 2025-08-30 18:43:36 +08:00
svnmoe 10ddde6592 feat:update /api/records/ping API (#232)
* feat:update /api/records/ping API
Request method:GET
parameters:
  uuid | string | client uuid
  task_id | int | ping_task_id
  hours   | int | ping_record_hours
*At least one of uuid or task_id must be provided
examples:
  GET /api/records/ping?uuid= &hours=
  GET /api/records/ping?task_id= &hours=
  GET /api/records/ping?uuid= &task_id= &hours=\

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

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

* feat:移除nsmao接口支持。

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

* ci: 添加自动合并

* ci: 申请auto-merge权限
2025-08-06 06:36:49 +00:00
Akizon77 0b34beea59 ci: 申请auto-merge权限 2025-08-06 06:35:50 +00:00
Akizon77 90ee9bc731 ci: 添加自动合并 2025-08-06 06:25:39 +00:00
Akizon77 046f38e7c4 fix: 安装时多一个嵌套的/data 2025-08-06 06:07:23 +00:00
Akizon eb733cea45 Merge pull request #168 from dbedu/main
feat: support theme online update.
2025-08-06 12:39:09 +08:00
Akizon eb68f0c629 Merge pull request #167 from komari-monitor/dev
feat: #162 添加Webhook消息发送功能
2025-08-05 15:36:41 +08:00
Akizon77 1cbcac4a00 fix: 修正拼写错误 2025-08-05 07:35:28 +00:00
Akizon77 2d6d58edab feat: #162 添加Webhook消息发送功能 2025-08-05 07:34:05 +00:00
Akizon77 c01fadbb48 feat: 添加option选项 2025-08-05 07:26:11 +00:00
Akizon77 41ef2e239d fix: 更新任务结果保存逻辑以使用模型中的时间类型 2025-08-05 06:44:24 +00:00
Akizon77 7176180343 fix: 修复任务创建时未包含离线客户端 2025-08-05 06:32:45 +00:00
Akizon77 0b9a01c8ac fix: 更新Exec函数以处理离线客户端并保存任务结果 2025-08-05 05:56:00 +00:00
Akizon 50ef62a64a Merge pull request #163 from imlonghao/fix/oidc
fix(oidc): 修正请求 token 阶段的请求包
2025-08-05 13:17:30 +08:00
Akizon 384fa4bbe1 Merge pull request #165 from JohnsonRan/bark
feat: add Bark notification provider
2025-08-04 22:36:42 +08:00
JohnsonRan 10f84a8052 feat: add Bark notification provider 2025-08-04 22:09:49 +08:00
imlonghao 53bd83af8b fix(oidc): 修正请求 token 阶段的请求包 2025-08-04 21:22:20 +08:00
dbedu 53577e21e7 feat: support update theme from github release 2025-08-04 21:01:27 +08:00
dbedu 46c0cb9bfb feat: support update theme from github release 2025-08-04 20:48:10 +08:00
dbedu ac8ee8f9fa feat: add theme online update 2025-08-04 19:56:43 +08:00
Akizon 787e8bdd80 Merge pull request #160 from imlonghao/fix/oidc-redirect-uri
fix(oidc): 增加 redirect_uri
2025-08-03 21:48:47 +08:00
Akizon 83a39e3143 Merge pull request #161 from imlonghao/fix/oidc-reload-config
fix(oidc): 修复配置无法更新的问题
2025-08-03 21:43:21 +08:00
imlonghao 5e2ed79929 fix(oidc): 修复配置无法更新的问题 2025-08-03 21:40:54 +08:00
imlonghao c8f0ba926d fix(oidc): 增加 redirect_uri 2025-08-03 21:22:37 +08:00
Akizon77 17e0e14aef fix: 初次启动时无法加载oauth 2025-08-03 09:54:07 +00:00
Akizon77 731602a80d Merge branch 'main' of https://github.com/komari-monitor/komari 2025-08-03 09:42:55 +00:00
Akizon77 37f0e449de fix: 优化OIDC提供者加载逻辑,添加空配置处理 2025-08-03 09:42:52 +00:00
Akizon 56dd512d3c Merge pull request #157 from imlonghao/feat/5years
feat: 增加五年期的账单周期
2025-08-02 23:40:31 +08:00
Akizon 16f11b052a Merge pull request #158 from JohnsonRan/kernel-version
feat: add kernel_version field to Client model
2025-08-02 23:39:56 +08:00
JohnsonRan 06bae7efcb feat: add kernel_version field to Client model 2025-08-02 22:52:17 +08:00
imlonghao 39b017620f feat: 增加五年期的账单周期 2025-08-02 22:21:10 +08:00
Akizon77 45e8a3a530 Merge branch 'main' of https://github.com/komari-monitor/komari 2025-08-01 14:28:27 +00:00
Akizon77 8dd64a9fa3 fix: 更新OAuth回调中文案消息 2025-08-01 14:04:19 +00:00
Akizon c1e8b0779f Merge pull request #155 from komari-monitor/dev
feat: #149 支持超级群组话题
2025-08-01 21:53:47 +08:00
Akizon77 d16daf1862 feat: #149 支持超级群组话题 2025-08-01 13:45:06 +00:00
Akizon77 491b092ac1 feat: 添加Help字段到Item结构 2025-08-01 13:17:26 +00:00
Akizon77 e2304c6b1b refactor: 重构messageSender接口 2025-08-01 09:05:43 +00:00
Akizon77 ea52f61a2c Merge branch 'dev' 2025-08-01 06:53:12 +00:00
Akizon77 3db6edcc3d fix: 保存第三方登陆后会自动重载 2025-08-01 06:52:39 +00:00
Akizon77 2045dd3f30 Merge branch 'main' of https://github.com/komari-monitor/komari 2025-08-01 06:33:01 +00:00
Akizon77 7a37c93a02 fix: 修复私有站点中对非/api路径的放行逻辑 2025-08-01 06:32:26 +00:00
Akizon 1d49300f8c Merge pull request #154 from komari-monitor/dev
fix: OAuth校验state失效、打开私有化后客户点连接401的问题
2025-08-01 14:24:10 +08:00
Akizon77 f5641df0d9 fix: OAuth校验state失效 2025-08-01 06:22:10 +00:00
Akizon77 b9c1e58703 fix: /api/clients 绕过 2025-08-01 06:18:18 +00:00
Akizon77 22b9fada55 fix: 打开私有化后客户点连接401的问题 2025-07-31 16:07:00 +00:00
Akizon d34b286c4f Merge pull request #152 from komari-monitor/dev
refactor: 重构OAuth
2025-07-31 16:42:02 +08:00
Akizon77 6dab3c8450 refactor: 重写oauth逻辑 2025-07-31 08:23:34 +00:00
Akizon77 32dd5c9414 feat: OIDC接口 2025-07-31 07:24:00 +00:00
Akizon77 2f09d28b94 feat: 添加OIDC接口 2025-07-31 06:08:09 +00:00
Akizon77 77ce900bbf wip: 添加通用OIDC提供者实现及相关注册逻辑 2025-07-31 04:51:27 +00:00
Akizon77 0675634b27 wip: OIDC接口 2025-07-30 09:45:14 +00:00
Akizon77 74db186ab3 feat: 更新备份恢复成功消息并添加服务重启逻辑 2025-07-30 07:10:56 +00:00
Akizon 1a5bc31b9b Merge pull request #151 from komari-monitor/dev
feat: #143 添加全站私有化
2025-07-30 13:37:54 +08:00
Akizon77 f6a94db702 Merge branch 'main' into dev 2025-07-29 08:11:39 +00:00
Akizon77 6213ce7556 feat: #143 添加全站私有化 2025-07-29 08:11:27 +00:00
Akizon77 ff91e548b8 docs: 添加英语、日文和繁体中文的 README 文件 2025-07-29 07:28:26 +00:00
Akizon77 6d59070184 Merge branch 'main' into dev 2025-07-28 08:21:22 +00:00
Akizon77 c8126e551f refactor: 将 UTCTime 类型替换为 LocalTime 2025-07-28 08:20:42 +00:00
Akizon77 589eae0d3f ci: 更新清理旧包的工作流配置 2025-07-28 04:20:26 +00:00
Akizon77 8e25bc9ce3 ci: 删除没有标签的版本 2025-07-28 04:15:24 +00:00
Akizon77 9aa4dcc206 ci: 更新 Docker 发布工作流 2025-07-28 04:12:23 +00:00
Akizon77 b2b5966699 fix: 修改 Ping 记录时间格式为 RFC3339 2025-07-26 07:20:34 +00:00
Akizon77 e9c7e0031c fix: #146 优化 Ping 记录的输出结构 2025-07-26 07:19:31 +00:00
Akizon77 1e3d29ffdb Merge branch 'main' of https://github.com/komari-monitor/komari 2025-07-26 07:10:15 +00:00
Akizon77 84670ac231 feat: 恢复备份添加锁、添加备份标记文件并在上传时验证 2025-07-26 06:56:02 +00:00
Akizon 6b17c5c498 Update README.md 2025-07-26 14:11:21 +08:00
Akizon eed9820ce6 Merge pull request #148 from dbedu/main
feat: 备份文件恢复
2025-07-26 10:06:35 +08:00
dbedu 858d914ae3 新功能: 新增备份文件恢复 2025-07-26 00:46:07 +08:00
Akizon be7c3812db docs: 更新文档链接 2025-07-25 01:00:17 +08:00
Akizon 44c65f1eb8 docs: 容器云标注价格 2025-07-25 00:36:50 +08:00
Akizon dd742c1038 docs: 添加雨云一键部署 2025-07-25 00:16:53 +08:00
Akizon d19b64ec1d Merge pull request #144 from komari-monitor/dev
feat: 优化静态资源服务
2025-07-24 16:25:33 +08:00
Akizon77 6ea22e3241 feat: 优化静态资源服务 2025-07-24 08:10:09 +00:00
Akizon77 ea99966e7a feat: 配置获取添加并发控制 2025-07-24 08:09:57 +00:00
Akizon77 fa13d4d1f9 feat: 自定义端口 2025-07-23 14:31:45 +00:00
Akizon77 15e4f583e0 chore: 删除待办清单文件 2025-07-23 14:25:40 +00:00
Akizon77 3fbbc7bdce docs: 添加前端构建说明文档 2025-07-23 14:23:54 +00:00
Akizon77 5fcd1d313c fix: #138 Docker容器添加时区数据 2025-07-23 04:00:24 +00:00
Akizon 967b7ab8a7 docs: 更新文档链接 2025-07-22 20:52:24 +08:00
Akizon77 d113dde3cc docs: 添加容器云一键部署 2025-07-21 07:52:12 +00:00
Akizon77 60c24a5ee1 docs: 添加一键脚本的指南 2025-07-21 06:48:51 +00:00
Akizon77 d5b8982763 docs: 添加Telegram群组链接 2025-07-21 06:40:45 +00:00
Akizon77 88a441da23 feat: 添加manifest.json路由 2025-07-20 08:28:15 +00:00
akizon77 03f27e7eb1 docs: 更新鸣谢 2025-07-19 01:01:56 +08:00
Akizon 6bba4526f0 Merge pull request #129 from komari-monitor/dev
feat: 定时自动续费
2025-07-16 11:26:14 +08:00
Akizon77 ebb84862a2 feat: 定时自动续费 2025-07-16 11:16:05 +08:00
Akizon77 28480a93d4 feat: 自动续费 2025-07-16 11:07:27 +08:00
Akizon 5c7c5a4e99 Merge pull request #128 from komari-monitor/main
Sync to dev branch
2025-07-16 10:21:57 +08:00
Akizon 90ee1da9c7 Merge pull request #127 from ours1505/dev
feat: 自动续费
2025-07-16 10:18:56 +08:00
Nya 596dbd0c2d feat: 自动续费 2025-07-16 10:02:26 +08:00
Akizon de5db97505 Merge pull request #124 from komari-monitor/dev
docs: 更新前端开发指南
2025-07-15 16:33:32 +08:00
Akizon77 0db40294ba docs: 更新前端开发指南 2025-07-15 16:32:17 +08:00
Akizon 74cd074c98 Merge pull request #123 from komari-monitor/dev
feat: 终端页面不使用主题
2025-07-15 16:05:01 +08:00
Akizon77 bb43b25e37 fix: 主题short不可以为default 2025-07-15 14:44:14 +08:00
Akizon77 b28431e4bd feat: 终端页面不使用主题 2025-07-15 11:37:42 +08:00
Akizon77 ee999810af feat: 在删除客户端时同步删除ws报告 2025-07-15 11:32:36 +08:00
Akizon 860f498e21 Merge pull request #122 from komari-monitor/dev
docs: 更新readme
2025-07-14 23:55:07 +08:00
Akizon77 798e900a9a docs: 更新readme 2025-07-14 23:54:38 +08:00
Akizon 9617cf575f Merge pull request #121 from komari-monitor/dev
feat: 添加主题管理
2025-07-14 23:48:29 +08:00
Akizon77 0ebba0f591 feat: 添加主题管理功能,包括上传、列出、删除和设置主题 2025-07-14 23:23:47 +08:00
Akizon77 01fd429ec3 refactor: 提取RunServer 2025-07-14 19:18:32 +08:00
Akizon 701aacf45c Merge pull request #119 from komari-monitor/dev
feat: #107 支持自定义Tags
2025-07-14 19:13:13 +08:00
Akizon77 2120168d1f feat: #107 支持自定义Tags 2025-07-14 18:53:19 +08:00
Akizon 171e4ceece Merge pull request #117 from komari-monitor/dev
refactor: 替换logOperation为auditlog、将notification模块重构为notifier
2025-07-13 15:47:09 +08:00
Akizon77 1d4c394ad8 refactor: 将notification模块重构为notifier 2025-07-13 15:39:21 +08:00
Akizon77 837313e146 refactor: 替换logOperation为auditlog 2025-07-13 15:36:56 +08:00
Akizon 3e415da8d4 Merge pull request #114 from komari-monitor/dev
feat: admin页面不使用自定义样式
2025-07-12 18:42:05 +08:00
Akizon77 a125466b75 feat: admin页面不使用自定义样式 2025-07-12 18:39:04 +08:00
Akizon a9306cd651 Merge pull request #110 from komari-monitor/dev
feat: 添加客户端分组字段
2025-07-09 14:47:21 +08:00
Akizon77 6af72f41c0 feat: 添加客户端分组字段 2025-07-09 14:19:56 +08:00
Akizon77 e9e04593c7 feat: 负载通知 2025-07-08 23:31:20 +08:00
Akizon 43b39c9f68 Merge pull request #109 from komari-monitor/dev
refactor: 重建数据库关系
2025-07-08 16:58:28 +08:00
Akizon77 8d5ce55301 refactor: 离线通知不回传ClientInfo 2025-07-08 16:57:23 +08:00
Akizon77 e72d461869 refactor: 重建数据库关系 2025-07-08 16:52:21 +08:00
Akizon 5cc4b518ba Merge pull request #106 from komari-monitor/dev
feat: 管理脚本
2025-07-06 00:49:32 +08:00
Akizon77 8aed055cc4 feat: 精简安装脚本 2025-07-06 00:48:44 +08:00
Akizon77 a306b6911a feat: 显示默认登录信息 2025-07-05 23:29:59 +08:00
Akizon77 776dfee23b feat: 移除日志配置前缀颜色 2025-07-05 23:26:44 +08:00
Akizon77 02e01a1d7c feat: 修正二进制文件命名格式 2025-07-05 23:22:26 +08:00
Akizon77 6b0b5b4855 feat: 添加安装脚本 2025-07-05 23:17:46 +08:00
Akizon 9dd09731bd Merge pull request #100 from komari-monitor/dev
feat: 补全相关日志记录
2025-07-04 00:47:00 +08:00
Akizon77 963c829164 feat: 添加重试机制以增强消息发送的可靠性 2025-07-04 00:45:43 +08:00
Akizon77 d01fafbf0f feat: 更新终端相关日志记录 2025-07-04 00:42:50 +08:00
Akizon77 729db05346 feat: 增强cloudflared运行时日志记录,添加错误处理和成功退出日志 2025-07-04 00:35:25 +08:00
Akizon 70dc06a761 Merge pull request #90 from komari-monitor/dev
docs: 更新 Agent 开发指南
2025-07-01 22:40:38 +08:00
Akizon77 be059b7aa3 feat: 更新 Agent 开发指南 2025-07-01 22:39:36 +08:00
Akizon b80c917f25 Merge pull request #89 from komari-monitor/dev
fix: 数据库可能两次打包在压缩包内
2025-07-01 17:09:42 +08:00
Akizon77 6b74bf90ce fix: 数据库可能两次打包在压缩包内 2025-07-01 17:09:11 +08:00
Akizon a2e4665ff5 Merge pull request #88 from komari-monitor/dev
feat: 优化报告平均值计算
2025-07-01 16:57:04 +08:00
Akizon77 2c9bd9c847 feat: 修改AverageReport函数以支持topPercentage参数,优化报告平均值计算 2025-07-01 16:56:31 +08:00
Akizon77 54eb957618 feat: 为GeoIP服务提供者添加名称方法 2025-07-01 16:44:56 +08:00
Akizon 370f0ff815 Merge pull request #87 from komari-monitor/dev
feat: #68 集成cloudflared
2025-07-01 15:45:15 +08:00
Akizon77 9bd9a280e5 feat: #68 集成cloudflared 2025-07-01 15:34:00 +08:00
Akizon daf6ae291f Merge pull request #86 from komari-monitor/dev
feat: 添加IP查询缓存机制
2025-07-01 15:03:06 +08:00
Akizon77 3d774eec81 feat: 添加IP查询缓存机制 2025-07-01 14:58:56 +08:00
Akizon77 129e0be151 refactor: 简化GeoIP相关调用,可以插中间件了 2025-07-01 14:51:17 +08:00
Akizon 862657ea86 Merge pull request #83 from komari-monitor/dev
feat: 在获取的任务中添加间隔时间字段
2025-06-30 18:38:06 +08:00
Akizon77 70b519a345 feat: 在获取的任务中添加间隔时间字段 2025-06-30 18:09:31 +08:00
Akizon 6600e4014d Merge pull request #82 from komari-monitor/dev
feat: #53 通知方式兼容SMTP邮件、支持下载备份
2025-06-29 23:50:00 +08:00
Akizon77 b8a6f18a77 feat: 支持下载备份 2025-06-29 23:29:19 +08:00
Akizon77 03e08cdfbb fix: 只有在通知方式变动时重载MessageSenderProvider 2025-06-29 22:30:28 +08:00
Akizon77 b07f7345ee feat: 通知方式兼容SMTP邮件 2025-06-29 22:28:04 +08:00
Akizon77 e85996178a refactor: 抽象消息发送接口 2025-06-29 22:17:35 +08:00
Akizon77 5547fcd067 ci: 发布main分支的docker镜像,修正release 2025-06-29 21:54:41 +08:00
Akizon aef4344997 Merge pull request #81 from komari-monitor/dev
fix: #79 GeoIP无法更新
feat: IP查询兼容 ipinfo.io
2025-06-29 21:44:43 +08:00
Akizon77 061197cd77 ci: 发布到测试环境不显示部分日志 2025-06-29 21:40:14 +08:00
Akizon77 c00d1d0cb2 ci: 发布docker镜像时预编译二进制 2025-06-29 21:36:30 +08:00
Akizon77 b6a7511e33 fix: #79 GeoIP无法更新 2025-06-29 01:31:54 +08:00
Akizon77 13450a50eb feat: IP查询兼容 ipinfo.io 2025-06-28 21:04:37 +08:00
Akizon eb883ccc6f Merge pull request #78 from komari-monitor/dev
feat: 到期提醒显示具体还剩多少天
2025-06-28 20:20:43 +08:00
Akizon77 2ae9b3516c fix: MaxMind 数据库 initialize 方法需要在解锁后调用,以避免死锁 2025-06-28 20:15:31 +08:00
Akizon77 7dc2a0294e feat: 到期提醒显示具体还剩多少天 2025-06-28 20:02:32 +08:00
Akizon f97b91c19c Merge pull request #77 from komari-monitor/dev
feat: 添加过期通知功能,支持配置过期前通知天数
2025-06-27 23:10:54 +08:00
Akizon77 bfac98dc67 feat: 添加过期通知功能,支持配置过期前通知天数 2025-06-27 22:28:42 +08:00
Akizon77 0f0f9f5985 feat: 添加记录删除功能,支持在记录禁用时清除所有记录 2025-06-27 22:13:24 +08:00
Akizon efebe66518 Merge pull request #76 from komari-monitor/dev
feat: 添加对ip-api和geojs服务的支持
2025-06-27 21:38:10 +08:00
Akizon77 37a05e8f3d fix: 更新时间条件,调整为四小时之前的记录查询 2025-06-27 21:36:04 +08:00
Akizon77 7473c438c2 feat: 订阅配置变化更新geoip配置 2025-06-27 21:22:50 +08:00
Akizon77 6e4e6d0653 feat: 添加对ip-api和geojs服务的支持,更新GeoIP配置 2025-06-27 21:20:31 +08:00
Akizon77 c09bec5c20 refactor: 更新GeoIP服务实现 2025-06-27 21:05:59 +08:00
Akizon77 14ce2b8f53 fix: 调整高位百分比和删除旧数据的时间条件 2025-06-27 20:26:49 +08:00
Akizon 8ecb6aa7aa Merge pull request #75 from komari-monitor/dev
ci: docker发布
2025-06-27 17:36:26 +08:00
Akizon77 cedc144173 ci: docker发布 2025-06-27 17:34:26 +08:00
Akizon 434e09ae95 Merge pull request #74 from komari-monitor/dev
refactor: 将time.Time 迁移到UTCTime
2025-06-27 13:55:34 +08:00
Akizon77 06ffc44530 refactor: 将time.Time 迁移到UTCTime 2025-06-27 13:53:03 +08:00
Akizon a7c24e2323 Merge pull request #72 from komari-monitor/dev
ci: 限定Matrix
2025-06-25 23:15:26 +08:00
Akizon77 726cfa513e ci: 限定Matrix 2025-06-25 23:14:33 +08:00
Akizon 62a1317d88 Merge pull request #71 from komari-monitor/dev
ci: 使用Zig作为C编译器
2025-06-25 23:12:20 +08:00
Akizon77 1221feae45 ci: 使用Zig作为C编译器 2025-06-25 23:11:53 +08:00
Akizon 884c5686bd Merge pull request #70 from komari-monitor/dev
fix: #69  (概率)离线通知未按预期工作
2025-06-25 19:09:46 +08:00
Akizon77 9b82a19528 fix: #69 (概率)离线通知未按预期工作 2025-06-25 19:09:04 +08:00
294 changed files with 41931 additions and 4767 deletions

No files matched your search

+55
View File
@@ -0,0 +1,55 @@
name: Build frontend theme
description: Clone komari-web, build the default theme, and optionally upload it as an artifact.
inputs:
node-version:
description: Node.js version used to build komari-web.
default: "23"
repository:
description: komari-web repository URL.
default: https://github.com/komari-monitor/komari-web
artifact-name:
description: Optional artifact name for the packed frontend theme.
default: ""
runs:
using: composite
steps:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node-version }}
- name: Clone and build frontend
shell: bash
run: |
set -euxo pipefail
rm -rf komari-web
git clone --depth=1 "${{ inputs.repository }}" komari-web
cd komari-web
npm install
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/
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
if [ -f web/public/defaultTheme/preview.png ] && [ ! -f web/public/defaultTheme/perview.png ]; then cp -f web/public/defaultTheme/preview.png web/public/defaultTheme/perview.png; fi
if [ -f web/public/defaultTheme/perview.png ] && [ ! -f web/public/defaultTheme/preview.png ]; then cp -f web/public/defaultTheme/perview.png web/public/defaultTheme/preview.png; fi
- name: Pack frontend artifact
if: ${{ inputs.artifact-name != '' }}
shell: bash
run: |
set -euxo pipefail
tar -czf "$RUNNER_TEMP/${{ inputs.artifact-name }}.tar.gz" -C web/public/defaultTheme .
- name: Upload frontend artifact
if: ${{ inputs.artifact-name != '' }}
uses: actions/upload-artifact@v4
with:
name: ${{ inputs.artifact-name }}
path: ${{ runner.temp }}/${{ inputs.artifact-name }}.tar.gz
@@ -0,0 +1,24 @@
name: Restore frontend theme
description: Download and unpack a previously built frontend theme artifact.
inputs:
artifact-name:
description: Frontend theme artifact name.
default: komari-frontend
runs:
using: composite
steps:
- name: Download frontend artifact
uses: actions/download-artifact@v4
with:
name: ${{ inputs.artifact-name }}
path: ${{ runner.temp }}/frontend-artifact
- name: Extract frontend artifact
shell: bash
run: |
set -euxo pipefail
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
+31
View File
@@ -0,0 +1,31 @@
name: Setup Zig
description: Restore or install the Zig toolchain used for CGO cross-compilation.
inputs:
zig-version:
description: Zig version to install.
default: 0.14.1
runs:
using: composite
steps:
- name: Restore Zig cache
id: zig-cache
uses: actions/cache@v4
with:
path: ~/.cache/komari-zig/zig-x86_64-linux-${{ inputs.zig-version }}
key: zig-${{ runner.os }}-x86_64-${{ inputs.zig-version }}
- name: Download Zig
if: ${{ steps.zig-cache.outputs.cache-hit != 'true' }}
shell: bash
run: |
set -euxo pipefail
mkdir -p "$HOME/.cache/komari-zig"
curl -fsSL "https://ziglang.org/download/${{ inputs.zig-version }}/zig-x86_64-linux-${{ inputs.zig-version }}.tar.xz" -o "$RUNNER_TEMP/zig.tar.xz"
tar -C "$HOME/.cache/komari-zig" -Jxf "$RUNNER_TEMP/zig.tar.xz"
- name: Add Zig to PATH
shell: bash
run: |
echo "$HOME/.cache/komari-zig/zig-x86_64-linux-${{ inputs.zig-version }}" >> "$GITHUB_PATH"
@@ -0,0 +1,109 @@
name: Auto Merge Dev to Main
on:
pull_request:
types: [opened, synchronize] # No longer need 'closed' trigger
branches:
- main
paths-ignore:
- '**.md'
- 'docs/**'
jobs:
# Job to enable Auto-Merge for the PR
auto-merge:
runs-on: ubuntu-latest
# Only run for open PRs from dev to main
if: github.head_ref == 'dev' && github.base_ref == 'main'
permissions:
contents: write
pull-requests: write
checks: read
statuses: read
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
token: ${{ secrets.GITHUB_TOKEN }}
fetch-depth: 0 # Required for git history
- name: Configure Git
run: |
git config --local user.email "action@github.com"
git config --local user.name "GitHub Action"
- name: Get PR number
id: get-pr
run: |
echo "pr_number=${{ github.event.number }}" >> $GITHUB_OUTPUT
- name: Check for conflicts
id: check-conflicts
run: |
# Ensure local branches are up-to-date
git fetch origin main
git fetch origin dev
# Use merge-base to find the common ancestor and check for conflicts
if git merge-tree $(git merge-base origin/main origin/dev) origin/main origin/dev | grep -q '<<<<<<<'; then
echo "has_conflicts=true" >> $GITHUB_OUTPUT
echo "❌ Merge conflicts detected"
else
echo "has_conflicts=false" >> $GITHUB_OUTPUT
echo "✅ No merge conflicts"
fi
- name: Add conflict comment
if: steps.check-conflicts.outputs.has_conflicts == 'true'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh pr comment ${{ steps.get-pr.outputs.pr_number }} \
--body "❌ **Merge Conflict Detection**
This PR has merge conflicts and cannot be automatically merged. Please resolve conflicts manually and push to the dev branch.
Steps to resolve conflicts:
1. \`git checkout dev\`
2. \`git merge main\`
3. Resolve conflicts
4. \`git commit\`
5. \`git push origin dev\`"
exit 1 # Exit to prevent further steps
- name: Enable auto-merge
if: steps.check-conflicts.outputs.has_conflicts == 'false'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# Enable auto-merge using a merge commit
gh pr merge ${{ steps.get-pr.outputs.pr_number }} \
--auto \
--merge \
--delete-branch=false
# Add success comment
gh pr comment ${{ steps.get-pr.outputs.pr_number }} \
--body "✅ **Auto-merge Enabled**
This PR has no merge conflicts and auto-merge has been enabled. It will be automatically merged to the main branch once all checks pass.
Merge method: Merge commit
Branch retention: dev branch will be preserved"
- name: Summary
run: |
echo "### 🤖 Auto-merge Summary" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "- 📝 Processing PR #${{ steps.get-pr.outputs.pr_number }} (dev → main)" >> $GITHUB_STEP_SUMMARY
if [ "${{ steps.check-conflicts.outputs.has_conflicts }}" == "true" ]; then
echo "- ❌ Merge conflicts detected, manual resolution required" >> $GITHUB_STEP_SUMMARY
else
echo "- ✅ No merge conflicts, auto-merge enabled" >> $GITHUB_STEP_SUMMARY
fi
echo "" >> $GITHUB_STEP_SUMMARY
echo "**PR Link**: https://github.com/${{ github.repository }}/pull/${{ steps.get-pr.outputs.pr_number }}" >> $GITHUB_STEP_SUMMARY
+65 -60
View File
@@ -1,91 +1,96 @@
name: Build Binaries on Main Push
name: Build Binaries on PR
on:
push:
pull_request:
branches:
- main
workflow_dispatch:
jobs:
build-binaries:
build-frontend:
runs-on: ubuntu-latest
strategy:
matrix:
goos: [windows, linux]
goarch: [amd64, arm64]
include:
- goos: windows
goarch: amd64
gcc: x86_64-w64-mingw32-gcc
CGO_CFLAGS: ''
CGO_LDFLAGS: ''
- goos: linux
goarch: amd64
gcc: gcc
CGO_CFLAGS: ''
CGO_LDFLAGS: ''
- goos: linux
goarch: arm64
gcc: aarch64-linux-gnu-gcc
CGO_CFLAGS: '-pthread'
CGO_LDFLAGS: '-pthread'
exclude:
- goos: windows
goarch: arm64
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
- name: Build frontend artifact
uses: ./.github/actions/build-frontend
with:
node-version: "23"
artifact-name: komari-frontend
- name: Clone and build frontend
run: |
git clone https://github.com/komari-monitor/komari-web web
cd web
npm install
npm run build
cd ..
mkdir -p public/dist
cp -r web/dist/* public/dist/
build-binaries:
needs: build-frontend
runs-on: ubuntu-latest
strategy:
matrix:
goos: [windows, linux]
goarch: [amd64, arm64, 386, riscv64]
include:
# Windows
- goos: windows
goarch: amd64
zig_target_triple: x86_64-windows-gnu
binary_extension: .exe
- goos: windows
goarch: arm64
zig_target_triple: aarch64-windows-gnu
binary_extension: .exe
- goos: windows
goarch: 386
zig_target_triple: x86-windows-gnu
binary_extension: .exe
# Linux
- goos: linux
goarch: amd64
zig_target_triple: x86_64-linux-musl
binary_extension: ""
- goos: linux
goarch: arm64
zig_target_triple: aarch64-linux-musl
binary_extension: ""
- goos: linux
goarch: 386
zig_target_triple: x86-linux-musl
binary_extension: ""
- goos: linux
goarch: riscv64
zig_target_triple: riscv64-linux-musl
binary_extension: ""
exclude:
- goos: windows
goarch: riscv64
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Restore frontend artifact
uses: ./.github/actions/restore-frontend
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: "1.23"
- name: Install cross-compiler
run: |
sudo apt-get update
if [ "${{ matrix.goarch }}" = "arm64" ] && [ "${{ matrix.goos }}" = "linux" ]; then
sudo apt-get install gcc-aarch64-linux-gnu
fi
if [ "${{ matrix.goarch }}" = "amd64" ] && [ "${{ matrix.goos }}" = "windows" ]; then
sudo apt-get install mingw-w64
fi
if [ "${{ matrix.goarch }}" = "arm64" ] && [ "${{ matrix.goos }}" = "windows" ]; then
sudo apt-get install wget xz-utils
mkdir -p /tmp/mingw64
wget https://github.com/mstorsjo/llvm-mingw/releases/download/20250613/llvm-mingw-20250613-ucrt-ubuntu-22.04-aarch64.tar.xz -O /tmp/mingw64.tar.xz
tar -xf /tmp/mingw64.tar.xz -C /tmp/mingw64 --strip-components=1
fi
- name: Install Zig cross-compiler toolchain
uses: ./.github/actions/setup-zig
- name: Build binary
env:
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
CGO_ENABLED: 1
CC: ${{ matrix.gcc }}
CGO_CFLAGS: ${{ matrix.CGO_CFLAGS }}
CGO_LDFLAGS: ${{ matrix.CGO_LDFLAGS }}
CC: zig cc -target ${{ matrix.zig_target_triple }}
GIN_MODE: release
run: |
BINARY_NAME=komari-${{ matrix.goos }}-${{ matrix.goarch }}
if [ "${{ matrix.goos }}" = "windows" ]; then
BINARY_NAME=${BINARY_NAME}.exe
# Append the appropriate executable extension based on the OS
if [ "${{ matrix.binary_extension }}" != "" ]; then
BINARY_NAME=${BINARY_NAME}${{ matrix.binary_extension }}
fi
VERSION="${{ github.sha }}"
VERSION_HASH="${{ github.sha }}"
@@ -95,4 +100,4 @@ jobs:
uses: actions/upload-artifact@v4
with:
name: komari-${{ matrix.goos }}-${{ matrix.goarch }}
path: komari-${{ matrix.goos }}-${{ matrix.goarch }}*
path: komari-${{ matrix.goos }}-${{ matrix.goarch }}*
+33 -14
View File
@@ -1,8 +1,9 @@
name: Cleanup Old Packages
on:
schedule:
- cron: '0 0 * * 0' # Run every Sunday at midnight
#schedule:
# - cron: '0 0 * * 0' # Run every Sunday at midnight
# 会清理掉正常的镜像
workflow_dispatch: # Allow manual trigger
jobs:
@@ -20,23 +21,41 @@ jobs:
uses: actions/github-script@v7
with:
script: |
const { data: packages } = await github.rest.packages.getAllPackageVersionsForPackageOwnedByAuthenticatedUser({
package_type: 'container',
package_name: process.env.GITHUB_REPOSITORY.split('/')[1]
});
// 分页获取所有包版本
const allVersions = [];
let page = 1;
let fetched;
const packageName = process.env.GITHUB_REPOSITORY.split('/')[1];
do {
const { data: packages } = await github.rest.packages.getAllPackageVersionsForPackageOwnedByAuthenticatedUser({
package_type: 'container',
package_name: packageName,
page,
per_page: 100
});
fetched = packages.length;
allVersions.push(...packages);
page++;
} while (fetched === 100);
// Identify versions with only a single SHA tag (format: sha-<7 hexadecimal characters>)
const shaOnlyVersions = packages.filter(pkg => {
// 筛选仅有 SHA/sha256 标签或无标签的版本
const shaOrNoTagVersions = allVersions.filter(pkg => {
const tags = pkg.metadata.container.tags;
return tags.length === 1 && tags[0].match(/^sha-[0-9a-f]{7}$/);
return (
(tags.length === 1 && (
tags[0].match(/^sha-[0-9a-f]{7}$/) ||
tags[0].match(/^sha256:[0-9a-f]{64}$/)
)) ||
tags.length === 0
);
});
// Delete the SHA-only versions
for (const version of shaOnlyVersions) {
// 删除这些版本
for (const version of shaOrNoTagVersions) {
await github.rest.packages.deletePackageVersionForAuthenticatedUser({
package_type: 'container',
package_name: process.env.GITHUB_REPOSITORY.split('/')[1],
package_name: packageName,
package_version_id: version.id
});
console.log(`Deleted package version ${version.id} with SHA tag ${version.metadata.container.tags[0]}`);
}
console.log(`Deleted package version ${version.id} with tags: ${version.metadata.container.tags.join(', ') || 'none'}`);
}
+7 -14
View File
@@ -12,22 +12,15 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v4
#- name: Create dummy index.html
# run: |
# mkdir -p public/dist
# echo "<html><body><h1>This is komari development server.</h1></body></html>" > public/dist/index.html
# 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
run: |
git clone https://github.com/komari-monitor/komari-web web
cd web
npm install
npm run build
cd ..
mkdir -p public/dist
cp -r web/dist/* public/dist/
uses: ./.github/actions/build-frontend
- name: Set up Go
uses: actions/setup-go@v5
@@ -69,7 +62,7 @@ jobs:
- name: Add host to known hosts
run: |
mkdir -p ~/.ssh
ssh-keyscan -H ${{ env.PRODUCTION_SERVER }} >> ~/.ssh/known_hosts
ssh-keyscan -H ${{ env.PRODUCTION_SERVER }} >> ~/.ssh/known_hosts 2>/dev/null
- name: Deploy to production
run: |
+72 -8
View File
@@ -1,9 +1,9 @@
name: Docker Build and Publish
name: Publish Docker Image on Main
on:
push:
branches:
- main
#push:
# branches:
# - main
workflow_dispatch:
env:
@@ -11,7 +11,61 @@ env:
IMAGE_NAME: ${{ github.repository }}
jobs:
build-and-push:
build-frontend:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Build frontend artifact
uses: ./.github/actions/build-frontend
with:
artifact-name: komari-frontend
build-docker-binaries:
needs: build-frontend
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Restore frontend artifact
uses: ./.github/actions/restore-frontend
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: "1.23"
- name: Install Zig
uses: ./.github/actions/setup-zig
- name: Build static binaries for multi-arch
env:
GOOS: linux
CGO_ENABLED: 1
GIN_MODE: release
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}"
echo "Building for linux/amd64..."
GOARCH=amd64 CC="zig cc -target x86_64-linux-musl" go build -trimpath -ldflags="$LDFLAGS" -o komari-linux-amd64
echo "Building for linux/arm64..."
GOARCH=arm64 CC="zig cc -target aarch64-linux-musl" go build -trimpath -ldflags="$LDFLAGS" -o komari-linux-arm64
- name: Upload Docker binaries
uses: actions/upload-artifact@v4
with:
name: komari-docker-binaries
path: komari-linux-*
build-and-push-multi-arch:
needs: build-docker-binaries
runs-on: ubuntu-latest
permissions:
contents: read
@@ -21,6 +75,18 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v4
- name: Download Docker binaries
uses: actions/download-artifact@v4
with:
name: komari-docker-binaries
path: .
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to the Container registry
uses: docker/login-action@v3
with:
@@ -42,8 +108,6 @@ jobs:
with:
context: .
push: true
platforms: linux/amd64,linux/arm64
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
VERSION=${{ github.ref_name }}
VERSION_HASH=${{ github.sha }}
@@ -0,0 +1,613 @@
name: Generate Release Notes
on:
release:
types: [published]
repository_dispatch:
types:
- komari-web-release-published
schedule:
- cron: "41 * * * *"
workflow_dispatch:
inputs:
tag:
description: "Release tag to regenerate notes for. Leave empty to use the latest release."
required: false
type: string
base_ref:
description: "Base ref or SHA for the release comparison. Snapshot builds pass the previous main SHA."
required: false
type: string
permissions:
contents: write
jobs:
update-release-notes:
runs-on: ubuntu-latest
if: ${{ github.event_name != 'release' || !startsWith(github.event.release.tag_name, 'Snapshot-') }}
steps:
- name: Generate and apply release notes
uses: actions/github-script@v9
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }}
OPENAI_MODEL: ${{ vars.OPENAI_MODEL }}
with:
script: |
const { owner, repo } = context.repo;
const webRepo = { owner: "komari-monitor", repo: "komari-web" };
const AI_CONTEXT_CHAR_LIMIT = 120000;
const AI_MAX_COMMITS = 160;
const AI_MAX_CHANGED_FILES = 120;
const AI_MAX_PULL_REQUESTS = 80;
const AI_MAX_PATCH_CHARS_PER_FILE = 2200;
const OPENAI_TIMEOUT_MS = 120000;
const clean = (value) => (typeof value === "string" ? value.trim() : "");
const isSnapshotTag = (tag) => /^Snapshot-/i.test(clean(tag));
const releaseTimestamp = (release) => Date.parse(release.published_at || release.created_at || "");
const isStableRelease = (release) => !release.draft && !release.prerelease && !isSnapshotTag(release.tag_name);
function limitText(value, maxLength) {
const text = clean(value);
if (text.length <= maxLength) {
return text;
}
return `${text.slice(0, maxLength)}\n...[truncated ${text.length - maxLength} chars]`;
}
function appendWithinLimit(parts, value, maxLength) {
const text = clean(value);
if (!text) {
return false;
}
const currentLength = parts.reduce((total, part) => total + part.length + 2, 0);
if (currentLength + text.length + 2 <= maxLength) {
parts.push(text);
return true;
}
const remaining = maxLength - currentLength - 64;
if (remaining > 500) {
parts.push(`${text.slice(0, remaining)}\n...[context truncated]`);
}
return false;
}
async function resolveRelease() {
const requestedTag = clean(context.payload.release?.tag_name)
|| clean(context.payload.client_payload?.tag)
|| clean(context.payload.inputs?.tag);
if (requestedTag) {
const release = context.payload.release?.tag_name === requestedTag
? context.payload.release
: (await github.rest.repos.getReleaseByTag({ owner, repo, tag: requestedTag })).data;
return { tag: requestedTag, release };
}
const release = (await github.rest.repos.getLatestRelease({ owner, repo })).data;
return { tag: release.tag_name, release };
}
async function getPreviousReleaseTag(tag, release) {
const releases = await github.paginate(github.rest.repos.listReleases, {
owner,
repo,
per_page: 100,
});
const currentTime = releaseTimestamp(release);
const currentIsSnapshot = isSnapshotTag(tag);
const currentIsStable = isStableRelease(release);
const candidates = releases
.filter((item) => !item.draft && item.tag_name !== tag)
.filter((item) => {
if (!Number.isFinite(currentTime)) {
return true;
}
const itemTime = releaseTimestamp(item);
return Number.isFinite(itemTime) && itemTime < currentTime;
})
.filter((item) => {
if (currentIsSnapshot || currentIsStable) {
return isStableRelease(item);
}
return item.prerelease && !isSnapshotTag(item.tag_name);
})
.sort((left, right) => releaseTimestamp(right) - releaseTimestamp(left));
return candidates[0]?.tag_name || "";
}
async function resolveComparisonBase(tag, release) {
const requestedBaseRef = clean(context.payload.client_payload?.base_ref)
|| clean(context.payload.inputs?.base_ref);
if (requestedBaseRef) {
return {
ref: requestedBaseRef,
previousTag: "",
source: "requested base ref",
};
}
const previousTag = await getPreviousReleaseTag(tag, release);
if (previousTag) {
return {
ref: previousTag,
previousTag,
source: isSnapshotTag(tag) ? "previous stable release" : "previous release in the same channel",
};
}
if (isSnapshotTag(tag)) {
try {
const commit = (await github.rest.repos.getCommit({
owner,
repo,
ref: release.target_commitish || tag,
})).data;
const parentSha = commit.parents?.[0]?.sha || "";
if (parentSha) {
return {
ref: parentSha,
previousTag: "",
source: "snapshot target parent commit",
};
}
} catch (error) {
core.warning(`Failed to resolve parent commit for ${tag}: ${error.message}`);
}
}
return {
ref: "",
previousTag: "",
source: "none",
};
}
async function getGeneratedNotes(tag, release, previousTag) {
const params = {
owner,
repo,
tag_name: tag,
target_commitish: release.target_commitish || undefined,
};
if (previousTag) {
params.previous_tag_name = previousTag;
}
const generated = await github.request(
"POST /repos/{owner}/{repo}/releases/generate-notes",
params,
);
return clean(generated.data.body);
}
async function getComparison(baseRef, tag) {
if (!baseRef) {
return { commits: [], files: [], htmlUrl: "" };
}
const commits = [];
let files = [];
let htmlUrl = "";
for (let page = 1; page <= 20; page += 1) {
const response = await github.rest.repos.compareCommitsWithBasehead({
owner,
repo,
basehead: `${baseRef}...${tag}`,
per_page: 100,
page,
});
if (page === 1) {
files = response.data.files || [];
htmlUrl = response.data.html_url || "";
}
commits.push(...response.data.commits);
if (!response.headers.link?.includes('rel="next"')) {
break;
}
}
return { commits, files, htmlUrl };
}
function renderCommitLine(commit) {
const subject = clean(commit.commit.message.split("\n")[0]) || "(no subject)";
const shortSha = commit.sha.slice(0, 7);
const author = commit.author?.login
? ` @${commit.author.login}`
: (commit.commit.author?.name ? ` ${commit.commit.author.name}` : "");
return `- ${subject} ([${shortSha}](${commit.html_url}))${author}`;
}
function buildWhatsChangedSection(baseRef, tag, comparison) {
if (!baseRef) {
return "## What's Changed\n\nNo comparison base was found for this release.";
}
if (comparison.commits.length === 0) {
return `## What's Changed\n\nNo commits found between \`${baseRef}\` and \`${tag}\`.`;
}
const lines = ["## What's Changed", "", ...comparison.commits.map(renderCommitLine)];
if (comparison.htmlUrl) {
lines.push("", `**Full Changelog**: ${comparison.htmlUrl}`);
}
return lines.join("\n");
}
function appendCommitsToGeneratedNotes(generatedNotes, baseRef, tag, comparison) {
const notes = clean(generatedNotes);
if (!notes || comparison.commits.length === 0) {
return notes || buildWhatsChangedSection(baseRef, tag, comparison);
}
const lines = notes.split("\n");
const fullChangelogIndex = lines.findIndex((line) => /^\*\*Full Changelog\*\*:/i.test(line.trim()));
const nextTopLevelSectionIndex = lines.findIndex((line, index) => (
index > 0
&& /^##\s+/.test(line)
&& !/^##\s+What's Changed\b/i.test(line)
));
const insertionIndex = nextTopLevelSectionIndex >= 0
? nextTopLevelSectionIndex
: (fullChangelogIndex >= 0 ? fullChangelogIndex : lines.length);
const commitSection = [
"### Commits",
"",
...comparison.commits.map(renderCommitLine),
"",
];
lines.splice(insertionIndex, 0, ...commitSection);
return clean(lines.join("\n"));
}
async function getPullRequestContext(commits) {
const pullRequests = new Map();
for (const commit of commits.slice(0, AI_MAX_COMMITS)) {
try {
const response = await github.rest.repos.listPullRequestsAssociatedWithCommit({
owner,
repo,
commit_sha: commit.sha,
});
for (const pullRequest of response.data || []) {
pullRequests.set(pullRequest.html_url, pullRequest);
}
} catch (error) {
core.warning(`Failed to load pull requests for ${commit.sha.slice(0, 7)}: ${error.message}`);
}
if (pullRequests.size >= AI_MAX_PULL_REQUESTS) {
break;
}
}
return [...pullRequests.values()].slice(0, AI_MAX_PULL_REQUESTS);
}
function buildChangedFilesContext(files) {
if (!files.length) {
return "No changed file metadata was returned by GitHub.";
}
return files.slice(0, AI_MAX_CHANGED_FILES).map((file) => {
const lines = [
`File: ${file.filename}`,
`Status: ${file.status}; additions: ${file.additions}; deletions: ${file.deletions}; changes: ${file.changes}`,
];
if (file.patch) {
lines.push("Patch excerpt:");
lines.push(limitText(file.patch, AI_MAX_PATCH_CHARS_PER_FILE));
}
return lines.join("\n");
}).join("\n\n");
}
function buildCommitContext(commits) {
if (!commits.length) {
return "No commits were returned by GitHub.";
}
return commits.slice(0, AI_MAX_COMMITS).map((commit) => {
const shortSha = commit.sha.slice(0, 7);
const author = commit.author?.login || commit.commit.author?.name || "unknown";
const message = limitText(commit.commit.message, 1800);
return [
`Commit: ${shortSha}`,
`Author: ${author}`,
`URL: ${commit.html_url}`,
"Message:",
message,
].join("\n");
}).join("\n\n");
}
function buildPullRequestContext(pullRequests) {
if (!pullRequests.length) {
return "No associated pull requests were found for the compared commits.";
}
return pullRequests.map((pullRequest) => {
const labels = (pullRequest.labels || []).map((label) => label.name).filter(Boolean).join(", ") || "none";
return [
`PR: #${pullRequest.number} ${pullRequest.title}`,
`URL: ${pullRequest.html_url}`,
`Author: ${pullRequest.user?.login || "unknown"}`,
`Labels: ${labels}`,
"Body:",
limitText(pullRequest.body, 1800) || "(empty)",
].join("\n");
}).join("\n\n");
}
function buildAiContext({ tag, release, comparisonBase, whatsChangedSection, comparison, pullRequests, webReleaseSection }) {
const sections = [];
appendWithinLimit(sections, [
`Repository: ${owner}/${repo}`,
`Release tag: ${tag}`,
`Release title: ${release.name || tag}`,
`Prerelease: ${release.prerelease ? "yes" : "no"}`,
`Snapshot tag: ${isSnapshotTag(tag) ? "yes" : "no"}`,
`Target commitish: ${release.target_commitish || "(unknown)"}`,
`Comparison base: ${comparisonBase.ref || "(none)"}`,
`Comparison base source: ${comparisonBase.source}`,
`Compare URL: ${comparison.htmlUrl || "(unavailable)"}`,
].join("\n"), AI_CONTEXT_CHAR_LIMIT);
appendWithinLimit(sections, `What's Changed section:\n${whatsChangedSection}`, AI_CONTEXT_CHAR_LIMIT);
appendWithinLimit(sections, `Changed files and patch excerpts:\n${buildChangedFilesContext(comparison.files)}`, AI_CONTEXT_CHAR_LIMIT);
appendWithinLimit(sections, `Associated pull requests:\n${buildPullRequestContext(pullRequests)}`, AI_CONTEXT_CHAR_LIMIT);
appendWithinLimit(sections, `Commit details:\n${buildCommitContext(comparison.commits)}`, AI_CONTEXT_CHAR_LIMIT);
if (webReleaseSection) {
appendWithinLimit(sections, `Komari Web release notes for the same tag:\n${webReleaseSection}`, AI_CONTEXT_CHAR_LIMIT);
}
return sections.join("\n\n---\n\n");
}
function buildChatCompletionsUrl(baseUrl) {
const trimmed = clean(baseUrl).replace(/\/+$/, "");
if (!trimmed) {
return "";
}
if (trimmed.endsWith("/chat/completions")) {
return trimmed;
}
if (trimmed.endsWith("/v1")) {
return `${trimmed}/chat/completions`;
}
return `${trimmed}/v1/chat/completions`;
}
function normalizeReleaseSummary(markdown) {
const summary = clean(markdown);
if (!summary) {
return "";
}
const withoutAiHeading = clean(summary
.replace(/^#{1,6}\s+AI Summary\b\s*/im, "")
.replace(/^\*\*AI Summary\*\*\s*/im, ""));
const whatsChangedIndex = withoutAiHeading.search(/^##\s+What's Changed\b/im);
if (whatsChangedIndex >= 0) {
return clean(withoutAiHeading.slice(0, whatsChangedIndex));
}
return withoutAiHeading;
}
async function callOpenAiChatCompletion(url, apiKey, model, messages) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), OPENAI_TIMEOUT_MS);
try {
const response = await fetch(url, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ model, messages, temperature: 0 }),
signal: controller.signal,
});
const responseText = await response.text();
let responseJson = {};
try {
responseJson = responseText ? JSON.parse(responseText) : {};
} catch {
responseJson = {};
}
if (!response.ok) {
const message = clean(responseJson.error?.message) || limitText(responseText, 500);
throw new Error(`OpenAI request failed with ${response.status}: ${message}`);
}
return clean(responseJson.choices?.[0]?.message?.content);
} finally {
clearTimeout(timeout);
}
}
async function buildReleaseSummarySection({ tag, release, comparisonBase, whatsChangedSection, comparison, webReleaseSection }) {
const apiKey = clean(process.env.OPENAI_API_KEY);
const baseUrl = clean(process.env.OPENAI_BASE_URL);
const model = clean(process.env.OPENAI_MODEL);
if (!apiKey || !baseUrl || !model) {
core.warning("OPENAI_API_KEY, OPENAI_BASE_URL, or OPENAI_MODEL is missing. Skipping generated release summary.");
return "";
}
const pullRequests = await getPullRequestContext(comparison.commits);
const aiContext = buildAiContext({
tag,
release,
comparisonBase,
whatsChangedSection,
comparison,
pullRequests,
webReleaseSection,
});
const messages = [
{
role: "system",
content: [
"You write accurate release notes for the Komari server project.",
"Use only the supplied repository context.",
"Include both Komari server changes and Komari Web frontend changes when frontend release notes are supplied.",
"Prioritize user-visible behavior, compatibility or migration notes, operations/deployment changes, security fixes, and important bug fixes.",
"Do not invent features or implementation details that are not supported by the context.",
"Write concise Markdown in Simplified Chinese.",
].join(" "),
},
{
role: "user",
content: [
`Create the release summary section for release ${tag}.`,
comparisonBase.ref
? `Summarize only changes in the comparison ${comparisonBase.ref}...${tag}.`
: "No comparison base was found, so summarize only the supplied release context.",
"Return only Markdown for the summary section that appears before '## What's Changed'.",
"Do not use the heading 'AI Summary'. Do not add a 'What's Changed' section.",
"Use these top-level headings, in this order: '## 主要更新内容', '## Bug修复', and '## 其他'.",
"Omit '## Bug修复' when there are no concrete bug fixes. Keep bullets short and specific.",
"",
aiContext,
].join("\n"),
},
];
try {
const url = buildChatCompletionsUrl(baseUrl);
const summary = await callOpenAiChatCompletion(url, apiKey, model, messages);
return normalizeReleaseSummary(summary);
} catch (error) {
core.warning(`Generated release summary failed: ${error.message}`);
return "";
}
}
async function buildWebReleaseSection(tag) {
const demoteMarkdownHeadings = (markdown) => markdown.replace(/^(#{1,5})(\s+)/gm, "#$1$2");
try {
const webRelease = (await github.rest.repos.getReleaseByTag({
owner: webRepo.owner,
repo: webRepo.repo,
tag,
})).data;
const webBody = clean(webRelease.body);
if (!webBody) {
return `## Komari Web Release Notes\n\nKomari Web release [${tag}](${webRelease.html_url}) exists, but its release notes are empty.`;
}
return [
"## Komari Web Release Notes",
"",
`Source: [komari-web ${tag}](${webRelease.html_url})`,
"",
demoteMarkdownHeadings(webBody),
].join("\n");
} catch (error) {
if (error.status === 404) {
core.info(`Komari Web release ${tag} does not exist yet. Skipping web release notes for now.`);
return "";
}
throw error;
}
}
const { tag, release } = await resolveRelease();
if (!tag) {
core.setFailed("No release tag was provided or found.");
return;
}
const comparisonBase = await resolveComparisonBase(tag, release);
core.info(comparisonBase.ref
? `Comparison base for ${tag}: ${comparisonBase.ref} (${comparisonBase.source}).`
: `No comparison base found for ${tag}.`);
const comparison = await getComparison(comparisonBase.ref, tag);
const whatsChangedSection = comparisonBase.previousTag
? appendCommitsToGeneratedNotes(
await getGeneratedNotes(tag, release, comparisonBase.previousTag),
comparisonBase.ref,
tag,
comparison,
)
: (!comparisonBase.ref && !isSnapshotTag(tag)
? await getGeneratedNotes(tag, release, "")
: buildWhatsChangedSection(comparisonBase.ref, tag, comparison));
if (!whatsChangedSection) {
core.setFailed(`Failed to build What's Changed section for ${tag}.`);
return;
}
const webReleaseSection = await buildWebReleaseSection(tag);
const releaseSummarySection = await buildReleaseSummarySection({
tag,
release,
comparisonBase,
whatsChangedSection,
comparison,
webReleaseSection,
});
const body = [
releaseSummarySection,
whatsChangedSection,
].filter(Boolean).join("\n\n");
if (clean(release.body) === clean(body)) {
core.info(`Release notes for ${tag} are already up to date.`);
} else {
await github.rest.repos.updateRelease({
owner,
repo,
release_id: release.id,
body,
});
}
await core.summary
.addHeading("Release Notes")
.addRaw(`Updated release notes for ${tag}.`)
.write();
+121
View File
@@ -0,0 +1,121 @@
name: Publish Docker Image Manually
on:
workflow_dispatch:
inputs:
version:
description: Docker image tag and embedded version.
required: true
type: string
permissions:
contents: read
packages: write
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build-frontend:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Build frontend artifact
uses: ./.github/actions/build-frontend
with:
artifact-name: komari-frontend
build-docker-binaries:
needs: build-frontend
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Restore frontend artifact
uses: ./.github/actions/restore-frontend
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: "1.23"
- name: Install Zig
uses: ./.github/actions/setup-zig
- name: Build static binaries for multi-arch
env:
GOOS: linux
CGO_ENABLED: 1
GIN_MODE: release
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}"
echo "Building for linux/amd64..."
GOARCH=amd64 CC="zig cc -target x86_64-linux-musl" go build -trimpath -ldflags="$LDFLAGS" -o komari-linux-amd64
echo "Building for linux/arm64..."
GOARCH=arm64 CC="zig cc -target aarch64-linux-musl" go build -trimpath -ldflags="$LDFLAGS" -o komari-linux-arm64
- name: Upload Docker binaries
uses: actions/upload-artifact@v4
with:
name: komari-docker-binaries
path: komari-linux-*
build-and-push-multi-arch:
needs: build-docker-binaries
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Download Docker binaries
uses: actions/download-artifact@v4
with:
name: komari-docker-binaries
path: .
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to the Container registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=raw,value=${{ github.event.inputs.version }}
type=raw,value=latest
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
push: true
platforms: linux/amd64,linux/arm64
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
no-cache: true
provenance: false
+91 -63
View File
@@ -1,4 +1,4 @@
name: Build and Attach Binaries to Release
name: Attach Release Binaries
on:
release:
@@ -13,109 +13,137 @@ env:
IMAGE_NAME: ${{ github.repository }}
jobs:
build-and-attach-binaries:
build-frontend:
runs-on: ubuntu-latest
strategy:
matrix:
goos: [windows, linux]
goarch: [amd64, arm64]
include:
- goos: windows
goarch: amd64
gcc: x86_64-w64-mingw32-gcc
CGO_CFLAGS: ''
CGO_LDFLAGS: ''
- goos: linux
goarch: amd64
gcc: gcc
CGO_CFLAGS: ''
CGO_LDFLAGS: ''
- goos: linux
goarch: arm64
gcc: aarch64-linux-gnu-gcc
CGO_CFLAGS: '-pthread'
CGO_LDFLAGS: '-pthread'
exclude:
- goos: windows
goarch: arm64
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
- name: Build frontend artifact
uses: ./.github/actions/build-frontend
with:
node-version: "23"
artifact-name: komari-frontend
- name: Clone and build frontend
run: |
git clone https://github.com/komari-monitor/komari-web web
cd web
npm install
npm run build
cd ..
mkdir -p public/dist
cp -r web/dist/* public/dist/
build-and-attach-binaries:
needs: build-frontend
runs-on: ubuntu-latest
strategy:
matrix:
goos: [windows, linux]
goarch: [amd64, arm64, 386, riscv64]
include:
# Windows
- goos: windows
goarch: amd64
zig_target_triple: x86_64-windows-gnu
binary_extension: .exe
- goos: windows
goarch: arm64
zig_target_triple: aarch64-windows-gnu
binary_extension: .exe
- goos: windows
goarch: 386
zig_target_triple: x86-windows-gnu
binary_extension: .exe
# Linux
- goos: linux
goarch: amd64
zig_target_triple: x86_64-linux-musl
binary_extension: ""
- goos: linux
goarch: arm64
zig_target_triple: aarch64-linux-musl
binary_extension: ""
- goos: linux
goarch: 386
zig_target_triple: x86-linux-musl
binary_extension: ""
- goos: linux
goarch: riscv64
zig_target_triple: riscv64-linux-musl
binary_extension: ""
exclude:
- goos: windows
goarch: riscv64
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Restore frontend artifact
uses: ./.github/actions/restore-frontend
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: "1.23"
- name: Install cross-compiler
run: |
sudo apt-get update
if [ "${{ matrix.goarch }}" = "arm64" ] && [ "${{ matrix.goos }}" = "linux" ]; then
sudo apt-get install gcc-aarch64-linux-gnu
fi
if [ "${{ matrix.goarch }}" = "amd64" ] && [ "${{ matrix.goos }}" = "windows" ]; then
sudo apt-get install mingw-w64
fi
if [ "${{ matrix.goarch }}" = "arm64" ] && [ "${{ matrix.goos }}" = "windows" ]; then
sudo apt-get install wget xz-utils
mkdir -p /tmp/mingw64
wget https://github.com/mstorsjo/llvm-mingw/releases/download/20250613/llvm-mingw-20250613-ucrt-ubuntu-22.04-aarch64.tar.xz -O /tmp/mingw64.tar.xz
tar -xf /tmp/mingw64.tar.xz -C /tmp/mingw64 --strip-components=1
fi
- name: Install Zig cross-compiler toolchain
uses: ./.github/actions/setup-zig
- name: Build binary
env:
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
CGO_ENABLED: 1
CC: ${{ matrix.gcc }}
CGO_CFLAGS: ${{ matrix.CGO_CFLAGS }}
CGO_LDFLAGS: ${{ matrix.CGO_LDFLAGS }}
CC: zig cc -target ${{ matrix.zig_target_triple }}
GIN_MODE: release
run: |
BINARY_NAME=komari-${{ matrix.goos }}-${{ matrix.goarch }}
if [ "${{ matrix.goos }}" = "windows" ]; then
BINARY_NAME=${BINARY_NAME}.exe
if [ "${{ matrix.binary_extension }}" != "" ]; then
BINARY_NAME=${BINARY_NAME}${{ matrix.binary_extension }}
fi
VERSION="${{ github.event.release.tag_name }}"
VERSION_HASH="${{ github.sha }}"
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
- name: Upload binary as artifact
uses: actions/upload-artifact@v4
with:
name: komari-${{ matrix.goos }}-${{ matrix.goarch }}
path: komari-${{ matrix.goos }}-${{ matrix.goarch }}*
- name: Upload binary to release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# Define the binary name for upload
BINARY_NAME=komari-${{ matrix.goos }}-${{ matrix.goarch }}
if [ "${{ matrix.goos }}" = "windows" ]; then
BINARY_NAME=${BINARY_NAME}.exe
fi
# Upload the binary as a release asset using GitHub CLI
gh release upload ${{ github.event.release.tag_name }} $BINARY_NAME --repo ${{ github.repository }}
shell: bash
build-and-push-docker:
needs: build-and-attach-binaries
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Download Docker binaries
uses: actions/download-artifact@v4
with:
pattern: komari-linux-*
path: .
merge-multiple: true
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to the Container registry
uses: docker/login-action@v3
with:
@@ -123,7 +151,7 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
@@ -137,8 +165,8 @@ jobs:
with:
context: .
push: true
platforms: linux/amd64,linux/arm64
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
VERSION=${{ github.event.release.tag_name }}
VERSION_HASH=${{ github.sha }}
no-cache: true
provenance: false
+289
View File
@@ -0,0 +1,289 @@
name: Publish Snapshot
on:
workflow_dispatch:
push:
branches:
- main
permissions:
contents: write
packages: write
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
concurrency:
group: snapshot-${{ github.ref }}
cancel-in-progress: true
jobs:
validate-ref:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Ensure snapshot target is main
run: |
if [ "${GITHUB_REF}" != "refs/heads/main" ]; then
echo "Snapshot builds must run from refs/heads/main. Current ref: ${GITHUB_REF}"
exit 1
fi
git fetch --no-tags --depth=1 origin main
MAIN_SHA="$(git rev-parse FETCH_HEAD)"
if [ "${GITHUB_SHA}" != "${MAIN_SHA}" ]; then
echo "Snapshot builds must use the current main commit."
echo "Workflow SHA: ${GITHUB_SHA}"
echo "Current main: ${MAIN_SHA}"
exit 1
fi
prepare:
needs: validate-ref
runs-on: ubuntu-latest
outputs:
version: ${{ steps.version.outputs.version }}
steps:
- name: Generate snapshot version
id: version
run: |
# Snapshot 版本号格式: Snapshot-yymmddhhMM (UTC+8)
VERSION="Snapshot-$(date -u -d '+8 hours' +%y%m%d%H%M)"
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "Snapshot version: ${VERSION}"
build-frontend:
needs: validate-ref
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Build frontend artifact
uses: ./.github/actions/build-frontend
with:
artifact-name: komari-frontend
build-and-attach-binaries:
needs: [prepare, build-frontend]
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
goos: [windows, linux]
goarch: [amd64, arm64, 386, riscv64]
include:
# Windows
- goos: windows
goarch: amd64
zig_target_triple: x86_64-windows-gnu
binary_extension: .exe
- goos: windows
goarch: arm64
zig_target_triple: aarch64-windows-gnu
binary_extension: .exe
- goos: windows
goarch: 386
zig_target_triple: x86-windows-gnu
binary_extension: .exe
# Linux
- goos: linux
goarch: amd64
zig_target_triple: x86_64-linux-musl
binary_extension: ""
- goos: linux
goarch: arm64
zig_target_triple: aarch64-linux-musl
binary_extension: ""
- goos: linux
goarch: 386
zig_target_triple: x86-linux-musl
binary_extension: ""
- goos: linux
goarch: riscv64
zig_target_triple: riscv64-linux-musl
binary_extension: ""
exclude:
- goos: windows
goarch: riscv64
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Restore frontend artifact
uses: ./.github/actions/restore-frontend
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: "1.23"
- name: Install Zig cross-compiler toolchain
uses: ./.github/actions/setup-zig
- name: Build binary
env:
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
CGO_ENABLED: 1
CC: zig cc -target ${{ matrix.zig_target_triple }}
GIN_MODE: release
run: |
BINARY_NAME=komari-${{ matrix.goos }}-${{ matrix.goarch }}
if [ "${{ matrix.binary_extension }}" != "" ]; then
BINARY_NAME=${BINARY_NAME}${{ matrix.binary_extension }}
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
- name: Upload binary as artifact
uses: actions/upload-artifact@v4
with:
name: komari-${{ matrix.goos }}-${{ matrix.goarch }}
path: komari-${{ matrix.goos }}-${{ matrix.goarch }}*
publish-snapshot-release:
needs: [prepare, build-and-attach-binaries]
runs-on: ubuntu-latest
permissions:
actions: write
contents: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Ensure commit is still current main
run: |
git fetch --no-tags --depth=1 origin main
MAIN_SHA="$(git rev-parse FETCH_HEAD)"
if [ "${GITHUB_SHA}" != "${MAIN_SHA}" ]; then
echo "Snapshot publish skipped because a newer main commit exists."
echo "Workflow SHA: ${GITHUB_SHA}"
echo "Current main: ${MAIN_SHA}"
exit 1
fi
- name: Download all binaries
uses: actions/download-artifact@v4
with:
path: artifacts
merge-multiple: true
- name: Create snapshot prerelease and upload binaries
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ needs.prepare.outputs.version }}
run: |
set -euxo pipefail
echo "Snapshot version: ${VERSION}"
ls -alR artifacts
# 如果同名 release/tag 已存在(例如重跑),先删除避免创建冲突
if gh release view "${VERSION}" --repo "${{ github.repository }}" >/dev/null 2>&1; then
echo "Release ${VERSION} 已存在,正在删除以重新创建..."
gh release delete "${VERSION}" --repo "${{ github.repository }}" --cleanup-tag --yes
fi
# 创建预发布并上传所有二进制文件
gh release create "${VERSION}" \
--repo "${{ github.repository }}" \
--target "${{ github.sha }}" \
--title "${VERSION}" \
--notes "Automated snapshot build (${VERSION}) from commit ${{ github.sha }}." \
--prerelease \
artifacts/komari-*
gh workflow run generate-release-notes.yml \
--repo "${{ github.repository }}" \
--ref main \
-f tag="${VERSION}"
gh release list \
--repo "${{ github.repository }}" \
--limit 1000 \
--json tagName,isPrerelease \
--jq '.[] | select(.isPrerelease and (.tagName | startswith("Snapshot-"))) | .tagName' |
while IFS= read -r tag; do
if [ -z "${tag}" ] || [ "${tag}" = "${VERSION}" ]; then
continue
fi
echo "Deleting old snapshot release ${tag}..."
gh release delete "${tag}" --repo "${{ github.repository }}" --cleanup-tag --yes
done
shell: bash
build-and-push-docker:
needs: [prepare, build-and-attach-binaries, publish-snapshot-release]
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Ensure commit is still current main
run: |
git fetch --no-tags --depth=1 origin main
MAIN_SHA="$(git rev-parse FETCH_HEAD)"
if [ "${GITHUB_SHA}" != "${MAIN_SHA}" ]; then
echo "Snapshot Docker publish skipped because a newer main commit exists."
echo "Workflow SHA: ${GITHUB_SHA}"
echo "Current main: ${MAIN_SHA}"
exit 1
fi
- name: Download Docker binaries
uses: actions/download-artifact@v4
with:
pattern: komari-linux-*
path: .
merge-multiple: true
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to the Container registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=raw,value=snapshot
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
push: true
platforms: linux/amd64,linux/arm64
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
no-cache: true
provenance: false
+6
View File
@@ -7,12 +7,14 @@
*.dll
*.so
*.dylib
__debug*
# local development data
komari.db
/data
/utils/geoip/data
.vscode/
/backup
# Test binary, built with `go test -c`
*.test
@@ -29,3 +31,7 @@ go.work.sum
# env file
.env
# build
komari
.codex
+12 -28
View File
@@ -1,34 +1,19 @@
FROM node:23-alpine AS frontend
WORKDIR /web
RUN apk add --no-cache git
RUN git clone https://github.com/komari-monitor/komari-web .
RUN npm install
RUN npm run build
FROM golang:1.24 AS builder
WORKDIR /app
# RUN apt-get update && apt-get install -y musl-tools
COPY go.mod go.sum ./
RUN go mod download
COPY . .
COPY --from=frontend /web/dist ./public/dist
ENV CGO_ENABLED=1
ARG VERSION=unknown
ARG VERSION_HASH=unknown
# RUN CC=musl-gcc go build -trimpath -ldflags="-s -w -linkmode external -extldflags -static" -o komari .
RUN go build -trimpath -ldflags="-s -w -linkmode external -extldflags -static -X github.com/komari-monitor/komari/utils.CurrentVersion=${VERSION} -X github.com/komari-monitor/komari/utils.VersionHash=${VERSION_HASH}" -o komari .
# FROM scratch
FROM alpine:3.21
WORKDIR /app
COPY --from=builder /app/komari .
# Docker buildx 会在构建时自动填充这些变量
ARG TARGETOS
ARG TARGETARCH
RUN apk add --no-cache ca-certificates curl tzdata
COPY komari-${TARGETOS}-${TARGETARCH} /app/komari
RUN chmod +x /app/komari
ENV GIN_MODE=release
# 数据库配置环境变量(可以在运行时覆盖)
ENV KOMARI_DB_TYPE=sqlite
ENV KOMARI_DB_FILE=/app/data/komari.db
ENV KOMARI_DB_HOST=localhost
@@ -40,5 +25,4 @@ ENV KOMARI_LISTEN=0.0.0.0:25774
EXPOSE 25774
# 使用环境变量启动服务
CMD ["/app/komari","server"]
CMD ["/app/komari", "server"]
+27
View File
@@ -0,0 +1,27 @@
CoreOS Project
Copyright 2014 CoreOS, Inc
This product includes software developed at CoreOS, Inc.
(http://www.coreos.com/).
otp
Copyright (c) 2014, Paul Querna
This product includes software developed by
Paul Querna (http://paul.querna.org/).
Copyright 2014 gRPC authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+85 -70
View File
@@ -1,27 +1,56 @@
# Komari
Komari 是一款轻量级的自托管服务器监控工具,旨在提供简单、高效的服务器性能监控解决方案。它支持通过 Web 界面查看服务器状态,并通过轻量级 Agent 收集数据。
![Badge](https://hitscounter.dev/api/hit?url=https%3A%2F%2Fgithub.com%2Fkomari-monitor%2Fkomari&label=&icon=github&color=%23a370f7&message=&style=flat&tz=UTC)
[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/komari-monitor/komari)
[文档](https://komari-monitor.github.io/komari-document/)
![komari](https://socialify.git.ci/komari-monitor/komari/image?description=1&font=Inter&forks=1&issues=1&language=1&logo=https%3A%2F%2Fraw.githubusercontent.com%2Fkomari-monitor%2Fkomari-web%2Fd54ce1288df41ead08aa19f8700186e68028a889%2Fpublic%2Ffavicon.png&name=1&owner=1&pattern=Plus&pulls=1&stargazers=1&theme=Auto)
## 特性
- **轻量高效**:低资源占用,适合各种规模的服务器。
- **自托管**:完全掌控数据隐私,部署简单。
- **Web 界面**:直观的监控仪表盘,易于使用。
[简体中文](./docs/README_zh.md) | [繁體中文](./docs/README_zh-TW.md) | [日本語](./docs/README_ja.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.
### 依赖
- Docker(快速部署)
- 或者 Go 1.18+ 和 Node.js 20+(手工构建)
> [!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.
### Docker 部署
1. 创建数据目录:
[Documentation](https://komari-document.pages.dev/) | [文档(镜像站 By Geekertao)](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.
## Quick Start
### 0. One-click Deployment with Cloud Hosting
- Rainyun - CNY 4.5/month
[![](https://rainyun-apps.cn-nb1.rains3.com/materials/deploy-on-rainyun-cn.svg)](https://app.rainyun.com/apps/rca/store/6780/NzYxNzAz_)
- 1Panel App Store
Available on 1Panel App Store. Install via **App Store > Utilities > Komari**.
### 1. Use the One-click Install Script
Suitable for distributions using systemd (Ubuntu, Debian...).
```bash
curl -fsSL https://raw.githubusercontent.com/komari-monitor/komari/main/install-komari.sh -o install-komari.sh
chmod +x install-komari.sh
sudo ./install-komari.sh
```
### 2. Docker Deployment
1. Create a data directory:
```bash
mkdir -p ./data
```
2. 运行 Docker 容器:
2. Run the Docker container:
```bash
docker run -d \
-p 25774:25774 \
@@ -29,104 +58,90 @@ Komari 是一款轻量级的自托管服务器监控工具,旨在提供简单
--name komari \
ghcr.io/komari-monitor/komari:latest
```
3. 查看默认账号和密码:
3. View the default username and password:
```bash
docker logs komari
```
4. 在浏览器中访问 `http://<your_server_ip>:25774`。
4. Access `http://<your_server_ip>:25774` in your browser.
> [!NOTE]
> 你也可以通过环境变量 `ADMIN_USERNAME` 和 `ADMIN_PASSWORD` 自定义初始用户名和密码。
> You can also customize the initial username and password through the environment variables `ADMIN_USERNAME` and `ADMIN_PASSWORD`.
### 二进制文件部署
1. 访问 Komari 的 [GitHub Release 页面](https://github.com/komari-monitor/komari/releases) 下载适用于你操作系统的最新二进制文件。
2. 运行 Komari:
### 3. Binary File Deployment
1. Visit Komari's [GitHub Release page](https://github.com/komari-monitor/komari/releases) to download the latest binary for your operating system.
2. Run Komari:
```bash
./komari server -l 0.0.0.0:25774
```
3. 在浏览器中访问 `http://<your_server_ip>:25774`,默认监听 `25774` 端口。
4. 默认账号和密码可在启动日志中查看,或通过环境变量 `ADMIN_USERNAME` 和 `ADMIN_PASSWORD` 设置。
3. Access `http://<your_server_ip>:25774` in your browser. The default port is `25774`.
4. The default username and password can be found in the startup logs or set via the environment variables `ADMIN_USERNAME` and `ADMIN_PASSWORD`.
> [!NOTE]
> 确保二进制文件具有可执行权限(`chmod +x komari`)。数据将保存在运行目录下的 `data` 文件夹中。
> Ensure the binary has execute permissions (`chmod +x komari`). Data will be saved in the `data` folder in the running directory.
### Manual Build
### 手工构建
1. 构建前端静态文件:
#### Dependencies
- Go 1.18+ and Node.js 20+ (for manual build)
1. Build the frontend static files:
```bash
git clone https://github.com/komari-monitor/komari-web
cd komari-web
npm install
npm run build
```
2. 构建后端:
2. Build the backend:
```bash
git clone https://github.com/komari-monitor/komari
cd komari
```
将步骤1中生成的静态文件复制到 `komari` 项目根目录下的 `/public/dist` 文件夹。
```bash
Copy the static files generated in step 1 to the `/web/public/defaultTheme/dist` folder in the root of the `komari` project, and copy `komari-theme.json` + `preview.png`/`perview.png` to `/web/public/defaultTheme`.
```bash
go build -o komari
```
4. 运行:
3. Run:
```bash
./komari server -l 0.0.0.0:25774
```
默认监听 `25774` 端口,访问 `http://localhost:25774`。
The default listening port is `25774`. Access `http://localhost:25774`.
## 前端开发指南
这个坑晚点再填吧 ヽ( ̄ω ̄( ̄ω ̄〃)ゝ
## Frontend Development Guide
## 客户端 Agent 开发指南
这个坑晚点再填吧 (o゜▽゜)o☆
[Komari Theme Development Guide | Komari](https://komari-document.pages.dev/dev/theme.html)
## 贡献
欢迎提交 Issue 或 Pull Request!
## Client Agent Development Guide
## 鸣谢
- 感谢我自己能这么闲
- 广告位招租喵
[Komari Agent Information Reporting and Event Handling Documentation](https://komari-document.pages.dev/dev/agent.html)
## 引用
- [gorm.io](https://gorm.io/)
- [spf13/cobra](https://github.com/spf13/cobra)
- [oschwald/maxminddb-golang](https://github.com/oschwald/maxminddb-golang)
- [gorilla/websocket](https://github.com/gorilla/websocket)
- [google/uuid](https://github.com/google/uuid)
- [gin-gonic/gin](https://github.com/gin-gonic/gin)
- [UserExistsError/conpty](https://github.com/UserExistsError/conpty)
- [creack/pty](https://github.com/creack/pty)
- [rhysd/go-github-selfupdate](https://github.com/rhysd/go-github-selfupdate)
- [shirou/gopsutil](https://github.com/shirou/gopsutil)
## Contributing
## 许可证
[MIT License](LICENSE)
Issues and Pull Requests are welcome!
## 截图
## Acknowledgements
![PixPin_2025-06-07_15-28-30](https://github.com/user-attachments/assets/edce5694-c6c8-4647-bc11-8b27105fd55c)
![PixPin_2025-06-07_15-28-49](https://github.com/user-attachments/assets/23b58032-211a-4b59-b444-c8267606a0bb)
![PixPin_2025-06-07_15-29-05](https://github.com/user-attachments/assets/f4325d4d-fa69-41c6-9251-19f530476e65)
![PixPin_2025-06-07_15-30-05](https://github.com/user-attachments/assets/69be38f6-6681-4afb-9a04-965d39b7fcc2)
![PixPin_2025-06-07_15-30-46](https://github.com/user-attachments/assets/01d84dd1-3c81-4424-a041-7dbc2ae5822e)
### 破碎工坊云
[破碎工坊云 - 专业云计算服务平台,提供高效、稳定、安全的高防服务器与CDN解决方案](https://www.crash.work/)
## 碎碎念
### DreamCloud
### 我为什么做Komari?
[DreamCloud - 极高性价比解锁直连亚太高防](https://as211392.com/)
起因: [求推荐系统监控软件](https://www.nodeseek.com/post-133745-1)
### 🚀 Sponsored by SharonNetworks
> @古xx斯
> 哪吒?
> > 数据至少保存15天
[![Sharon Networks](https://raw.githubusercontent.com/komari-monitor/public/refs/heads/main/images/sharon-networks.webp)](https://sharon.io)
> @sxxu
> 怕不是做梦的预算
SharonNetworks 为您的业务起飞保驾护航!
> @Vxxn
> prometheus加grafana
> > 部署太麻烦
亚太数据中心提供顶级的中国优化网络接入 · 低延时&高带宽&提供Tbps级本地清洗高防服务, 为您的业务保驾护航, 为您的客户提供极致体验. 加入社区 [Telegram群组](https://t.me/SharonNetwork) 可参与公益募捐或群内抽奖免费使用
没有合适的,最近刚好时间稍微多了一些,就搓了 komari.
### The open source software community
All the developers who submitted PRs and created themes
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=komari-monitor/komari&type=Date)](https://www.star-history.com/#komari-monitor/komari&Date)
-35
View File
@@ -1,35 +0,0 @@
package api
import (
"net/http"
"github.com/komari-monitor/komari/database/accounts"
"github.com/gin-gonic/gin"
)
func AdminAuthMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
session, err := c.Cookie("session_token")
if err != nil {
RespondError(c, http.StatusUnauthorized, "Unauthorized.")
c.Abort()
return
}
// Komari is a single user system
uuid, err := accounts.GetSession(session)
if err != nil {
RespondError(c, http.StatusUnauthorized, "Unauthorized.")
c.Abort()
return
}
accounts.UpdateLatest(session, c.Request.UserAgent(), c.ClientIP())
// 将 session 和 用户 UUID 传递到后续处理器
c.Set("session", session)
c.Set("uuid", uuid)
c.Next()
}
}
-118
View File
@@ -1,118 +0,0 @@
package api
import (
"log"
"net/http"
"sync"
"time"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"github.com/komari-monitor/komari/common"
"github.com/patrickmn/go-cache"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/utils"
)
var (
Records = cache.New(1*time.Minute, 1*time.Minute)
)
type TerminalSession struct {
UUID string
UserUUID string
Browser *websocket.Conn
Agent *websocket.Conn
}
var TerminalSessionsMutex = &sync.Mutex{}
var TerminalSessions = make(map[string]*TerminalSession)
func SaveClientReportToDB() error {
lastMinute := time.Now().Add(-time.Minute).Unix()
var records []models.Record
// 遍历所有客户端记录
for uuid, x := range Records.Items() {
if uuid == "" {
continue
}
reports, ok := x.Object.([]common.Report)
if !ok {
log.Printf("Invalid report type for UUID %s", uuid)
continue
}
// 过滤一分钟前的记录
var filtered []common.Report
for _, r := range reports {
if r.UpdatedAt.Unix() >= lastMinute {
filtered = append(filtered, r)
}
}
// 更新缓存
Records.Set(uuid, filtered, cache.DefaultExpiration)
// 计算平均报告并添加到记录列表
if len(filtered) > 0 {
r := utils.AverageReport(uuid, time.Now(), filtered)
records = append(records, r)
}
}
// 批量插入数据库前去重(client与time共同构成唯一键)
if len(records) > 0 {
unique := make(map[string]models.Record)
for _, rec := range records {
key := rec.Client + "_" + rec.Time.Format("20060102150405")
unique[key] = rec
}
var deduped []models.Record
for _, rec := range unique {
deduped = append(deduped, rec)
}
db := dbcore.GetDBInstance()
if err := db.Model(&models.Record{}).Create(&deduped).Error; err != nil {
log.Printf("Failed to save records to database: %v", err)
return err
}
}
return nil
}
type Response struct {
Status string `json:"status"`
Message string `json:"message"`
Data interface{} `json:"data,omitempty"`
}
// Respond sends a standardized JSON response.
func Respond(c *gin.Context, httpStatus int, status string, message string, data interface{}) {
c.JSON(httpStatus, Response{Status: status, Message: message, Data: data})
}
// RespondSuccess sends a success response with data.
func RespondSuccess(c *gin.Context, data interface{}) {
Respond(c, http.StatusOK, "success", "", data)
}
// RespondSuccessMessage sends a success response with message and data.
func RespondSuccessMessage(c *gin.Context, message string, data interface{}) {
Respond(c, http.StatusOK, "success", message, data)
}
// RespondError sends an error response with message.
func RespondError(c *gin.Context, httpStatus int, message string) {
Respond(c, httpStatus, "error", message, nil)
}
func GetVersion(c *gin.Context) {
RespondSuccess(c, gin.H{
"version": utils.CurrentVersion,
"hash": utils.VersionHash,
})
}
-81
View File
@@ -1,81 +0,0 @@
package api
import (
"bytes"
"database/sql"
"encoding/json"
"io"
"net/http"
"github.com/komari-monitor/komari/database/clients"
"gorm.io/gorm"
"github.com/gin-gonic/gin"
)
// TokenAuthMiddleware creates a Gin middleware that validates a token from query parameters or request body.
func TokenAuthMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
var token string
// Step 1: Check query parameter for token
token = c.Query("token")
// Step 2: If no token in query, check request body for non-GET requests
if token == "" && c.Request.Method != http.MethodGet {
// Read the body
bodyBytes, err := io.ReadAll(c.Request.Body)
if err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"status": "error", "error": "failed to read request body"})
return
}
// Restore the body for downstream handlers
c.Request.Body = io.NopCloser(bytes.NewReader(bodyBytes))
// Try to extract token from JSON body
var bodyMap map[string]interface{}
if len(bodyBytes) > 0 {
if err := json.Unmarshal(bodyBytes, &bodyMap); err == nil {
if tokenVal, exists := bodyMap["token"]; exists {
if str, ok := tokenVal.(string); ok && str != "" {
token = str
}
}
}
}
}
if token == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"status": "error", "error": "token is required"})
return
}
exists, err := checkTokenExists(token)
if err != nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"status": "error", "error": "failed to validate token"})
return
}
if !exists {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"status": "error", "error": "invalid token"})
return
}
c.Next()
}
}
func checkTokenExists(token string) (bool, error) {
_, err := clients.GetClientUUIDByToken(token)
if err == sql.ErrNoRows {
return false, nil
}
if err == gorm.ErrRecordNotFound {
return false, nil
}
if err != nil {
return false, err
}
return true, nil
}
-134
View File
@@ -1,134 +0,0 @@
package admin
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/database/clients"
"github.com/komari-monitor/komari/database/logOperation"
"github.com/komari-monitor/komari/database/records"
)
func AddClient(c *gin.Context) {
var req struct {
Name string `json:"name"`
}
if err := c.ShouldBindJSON(&req); err != nil || req.Name == "" {
uuid, token, err := clients.CreateClient()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"status": "error", "message": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": "success", "uuid": uuid, "token": token})
return
}
uuid, token, err := clients.CreateClientWithName(req.Name)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"status": "error", "message": err.Error()})
return
}
user_uuid, _ := c.Get("uuid")
logOperation.Log(c.ClientIP(), user_uuid.(string), "create client:"+uuid, "info")
c.JSON(http.StatusOK, gin.H{"status": "success", "uuid": uuid, "token": token, "message": ""})
}
func EditClient(c *gin.Context) {
var req = make(map[string]interface{})
uuid := c.Param("uuid")
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"status": "error", "message": err.Error()})
return
}
if uuid == "" {
c.JSON(http.StatusBadRequest, gin.H{"status": "error", "message": "Invalid or missing UUID"})
return
}
req["uuid"] = uuid
err := clients.SaveClient(req)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"status": "error", "message": err.Error()})
return
}
user_uuid, _ := c.Get("uuid")
logOperation.Log(c.ClientIP(), user_uuid.(string), "edit client:"+uuid, "info")
c.JSON(http.StatusOK, gin.H{"status": "success"})
}
func RemoveClient(c *gin.Context) {
uuid := c.Param("uuid")
err := clients.DeleteClient(uuid)
if err != nil {
c.JSON(500, gin.H{
"status": "error",
"error": "Failed to delete client" + err.Error(),
})
return
}
user_uuid, _ := c.Get("uuid")
logOperation.Log(c.ClientIP(), user_uuid.(string), "delete client:"+uuid, "warn")
c.JSON(200, gin.H{"status": "success"})
}
func ClearRecord(c *gin.Context) {
if err := records.DeleteAll(); err != nil {
c.JSON(500, gin.H{
"status": "error",
"message": "Failed to delete Record" + err.Error(),
})
return
}
user_uuid, _ := c.Get("uuid")
logOperation.Log(c.ClientIP(), user_uuid.(string), "clear records", "warn")
c.JSON(200, gin.H{"status": "success"})
}
func GetClient(c *gin.Context) {
uuid := c.Param("uuid")
if uuid == "" {
c.JSON(400, gin.H{
"status": "error",
"message": "Invalid or missing UUID",
})
return
}
result, err := clients.GetClientByUUID(uuid)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"status": "error",
"message": err.Error(),
})
return
}
c.JSON(http.StatusOK, result)
}
func ListClients(c *gin.Context) {
cls, err := clients.GetAllClientBasicInfo()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"status": "error", "message": err.Error()})
return
}
c.JSON(http.StatusOK, cls)
}
func GetClientToken(c *gin.Context) {
uuid := c.Param("uuid")
if uuid == "" {
c.JSON(400, gin.H{
"status": "error",
"message": "Invalid or missing UUID",
})
return
}
token, err := clients.GetClientTokenByUUID(uuid)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"status": "error", "message": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": "success", "token": token, "message:": ""})
}
-28
View File
@@ -1,28 +0,0 @@
package admin
import (
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/logOperation"
"github.com/komari-monitor/komari/database/models"
)
func OrderWeight(c *gin.Context) {
var req = make(map[string]int)
if err := c.ShouldBindJSON(&req); err != nil {
api.RespondError(c, 400, "Invalid or missing request body: "+err.Error())
return
}
db := dbcore.GetDBInstance()
for uuid, weight := range req {
err := db.Model(&models.Client{}).Where("uuid = ?", uuid).Update("weight", weight).Error
if err != nil {
api.RespondError(c, 500, "Failed to update client weight: "+err.Error())
return
}
}
uuid, _ := c.Get("uuid")
logOperation.Log(c.ClientIP(), uuid.(string), "order clients", "info")
api.RespondSuccess(c, nil)
}
-115
View File
@@ -1,115 +0,0 @@
package clipboard
import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/api"
clipboardDB "github.com/komari-monitor/komari/database/clipboard"
"github.com/komari-monitor/komari/database/logOperation"
"github.com/komari-monitor/komari/database/models"
)
// GetClipboard retrieves a clipboard entry by ID
func GetClipboard(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.Atoi(idStr)
if err != nil {
api.RespondError(c, http.StatusBadRequest, "Invalid ID")
return
}
cb, err := clipboardDB.GetClipboardByID(id)
if err != nil {
api.RespondError(c, http.StatusInternalServerError, "Failed to get clipboard: "+err.Error())
return
}
api.RespondSuccess(c, cb)
}
// ListClipboard lists all clipboard entries
func ListClipboard(c *gin.Context) {
list, err := clipboardDB.ListClipboard()
if err != nil {
api.RespondError(c, http.StatusInternalServerError, "Failed to list clipboard: "+err.Error())
return
}
api.RespondSuccess(c, list)
}
// CreateClipboard creates a new clipboard entry
func CreateClipboard(c *gin.Context) {
var req models.Clipboard
if err := c.ShouldBindJSON(&req); err != nil {
api.RespondError(c, http.StatusBadRequest, "Invalid request: "+err.Error())
return
}
if err := clipboardDB.CreateClipboard(&req); err != nil {
api.RespondError(c, http.StatusInternalServerError, "Failed to create clipboard: "+err.Error())
return
}
userUUID, _ := c.Get("uuid")
logOperation.Log(c.ClientIP(), userUUID.(string), "create clipboard:"+strconv.Itoa(req.Id), "info")
api.RespondSuccess(c, req)
}
// UpdateClipboard updates an existing clipboard entry
func UpdateClipboard(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.Atoi(idStr)
if err != nil {
api.RespondError(c, http.StatusBadRequest, "Invalid ID")
return
}
var fields map[string]interface{}
if err := c.ShouldBindJSON(&fields); err != nil {
api.RespondError(c, http.StatusBadRequest, "Invalid request: "+err.Error())
return
}
if err := clipboardDB.UpdateClipboardFields(id, fields); err != nil {
api.RespondError(c, http.StatusInternalServerError, "Failed to update clipboard: "+err.Error())
return
}
userUUID, _ := c.Get("uuid")
logOperation.Log(c.ClientIP(), userUUID.(string), "update clipboard:"+strconv.Itoa(id), "info")
api.RespondSuccess(c, nil)
}
// DeleteClipboard deletes a clipboard entry
func DeleteClipboard(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.Atoi(idStr)
if err != nil {
api.RespondError(c, http.StatusBadRequest, "Invalid ID")
return
}
if err := clipboardDB.DeleteClipboard(id); err != nil {
api.RespondError(c, http.StatusInternalServerError, "Failed to delete clipboard: "+err.Error())
return
}
userUUID, _ := c.Get("uuid")
logOperation.Log(c.ClientIP(), userUUID.(string), "delete clipboard:"+strconv.Itoa(id), "warn")
api.RespondSuccess(c, nil)
}
// BatchDeleteClipboard deletes multiple clipboard entries
func BatchDeleteClipboard(c *gin.Context) {
var req struct {
IDs []int `json:"ids" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
api.RespondError(c, http.StatusBadRequest, "Invalid request: "+err.Error())
return
}
if len(req.IDs) == 0 {
api.RespondError(c, http.StatusBadRequest, "IDs cannot be empty")
return
}
if err := clipboardDB.DeleteClipboardBatch(req.IDs); err != nil {
api.RespondError(c, http.StatusInternalServerError, "Failed to batch delete clipboard: "+err.Error())
return
}
userUUID, _ := c.Get("uuid")
logOperation.Log(c.ClientIP(), userUUID.(string), "batch delete clipboard: "+strconv.Itoa(len(req.IDs))+" items", "warn")
api.RespondSuccess(c, nil)
}
-78
View File
@@ -1,78 +0,0 @@
package admin
import (
"encoding/json"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/database/logOperation"
"github.com/komari-monitor/komari/database/tasks"
"github.com/komari-monitor/komari/utils"
"github.com/komari-monitor/komari/ws"
)
// 接受数据类型:
// - command: string
// - clients: []string (客户端 UUID 列表)
func Exec(c *gin.Context) {
var req struct {
Command string `json:"command" binding:"required"`
Clients []string `json:"clients" binding:"required"`
}
var onlineClients []string
if err := c.ShouldBindJSON(&req); err != nil {
api.RespondError(c, 400, "Invalid or missing request body: "+err.Error())
return
}
for uuid := range ws.GetConnectedClients() {
if contain(req.Clients, uuid) {
onlineClients = append(onlineClients, uuid)
} else {
api.RespondError(c, 400, "Client not connected: "+uuid)
return
}
}
if len(onlineClients) == 0 {
api.RespondError(c, 400, "No clients connected")
return
}
taskId := utils.GenerateRandomString(16)
if err := tasks.CreateTask(taskId, onlineClients, req.Command); err != nil {
api.RespondError(c, 500, "Failed to create task: "+err.Error())
return
}
for _, uuid := range onlineClients {
var send struct {
Message string `json:"message"`
Command string `json:"command"`
TaskId string `json:"task_id"`
}
send.Message = "exec"
send.Command = req.Command
send.TaskId = taskId
payload, _ := json.Marshal(send)
client := ws.GetConnectedClients()[uuid]
if client != nil {
client.WriteMessage(websocket.TextMessage, payload)
} else {
api.RespondError(c, 400, "Client connection is null: "+uuid)
return
}
}
uuid, _ := c.Get("uuid")
logOperation.Log(c.ClientIP(), uuid.(string), "REC, task id: "+taskId, "warn")
api.RespondSuccess(c, gin.H{
"task_id": taskId,
"clients": onlineClients,
})
}
func contain(clients []string, uuid string) bool {
for _, client := range clients {
if client == uuid {
return true
}
}
return false
}
-49
View File
@@ -1,49 +0,0 @@
package log
import (
"strconv"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
)
func GetLogs(c *gin.Context) {
limit := c.Query("limit")
if limit == "" {
limit = "100" // Default to 100 logs if not specified
}
page := c.Query("page")
if page == "" {
page = "1" // Default to page 1 if not specified
}
// Convert limit and page to integers
// If conversion fails, return an error
limitInt, err := strconv.Atoi(limit)
if err != nil || limitInt <= 0 {
api.RespondError(c, 400, "Invalid limit: "+limit)
return
}
pageInt, err := strconv.Atoi(page)
if err != nil || pageInt <= 0 {
api.RespondError(c, 400, "Invalid page: "+page)
return
}
db := dbcore.GetDBInstance()
var logs []models.Log
// 添加分页:计算偏移量并限制数量
offset := (pageInt - 1) * limitInt
var total int64
if err := db.Model(&models.Log{}).Count(&total).Error; err != nil {
api.RespondError(c, 500, "Failed to count logs: "+err.Error())
return
}
if err := db.Order("time desc").Limit(limitInt).Offset(offset).Find(&logs).Error; err != nil {
api.RespondError(c, 500, "Failed to retrieve logs: "+err.Error())
return
}
api.RespondSuccess(c, gin.H{"logs": logs, "total": total})
}
-109
View File
@@ -1,109 +0,0 @@
package notification
import (
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
"gorm.io/gorm/clause"
)
// POST body : []uuid
func EnableOfflineNotification(c *gin.Context) {
var uuids []string
if err := c.ShouldBindJSON(&uuids); err != nil {
api.RespondError(c, 400, "Invalid request body: "+err.Error())
return
}
var notifications []models.OfflineNotification
for _, uuid := range uuids {
notifications = append(notifications, models.OfflineNotification{
Client: uuid,
Enable: true,
})
}
err := dbcore.GetDBInstance().Model(&models.OfflineNotification{}).
Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "client"}},
DoUpdates: clause.AssignmentColumns([]string{"enable"}),
}).
Select("client", "enable").
Create(notifications).Error
if err != nil {
api.RespondError(c, 500, "Failed to enable offline notifications: "+err.Error())
return
}
api.RespondSuccess(c, nil)
}
// POST body : []uuid
func DisableOfflineNotification(c *gin.Context) {
var uuids []string
if err := c.ShouldBindJSON(&uuids); err != nil {
api.RespondError(c, 400, "Invalid request body: "+err.Error())
return
}
var notifications []models.OfflineNotification
for _, uuid := range uuids {
notifications = append(notifications, models.OfflineNotification{
Client: uuid,
Enable: false,
})
}
err := dbcore.GetDBInstance().Model(&models.OfflineNotification{}).
Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "client"}},
DoUpdates: clause.AssignmentColumns([]string{"enable"}),
}).
Select("client", "enable").
Create(notifications).Error
if err != nil {
api.RespondError(c, 500, "Failed to disable offline notifications: "+err.Error())
return
}
api.RespondSuccess(c, nil)
}
func EditOfflineNotification(c *gin.Context) {
var notifications []models.OfflineNotification
if err := c.ShouldBindJSON(&notifications); err != nil {
api.RespondError(c, 400, "Invalid request body: "+err.Error())
return
}
if len(notifications) == 0 {
api.RespondError(c, 400, "At least one notification is required")
return
}
for _, noti := range notifications {
if noti.Client == "" {
api.RespondError(c, 400, "Client UUID cannot be empty")
return
}
if noti.GracePeriod <= 0 {
api.RespondError(c, 400, "GracePeriod must be a positive integer")
return
}
}
err := dbcore.GetDBInstance().Model(&models.OfflineNotification{}).
Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "client"}},
DoUpdates: clause.AssignmentColumns([]string{"enable", "grace_period"}),
}).
Select("*").
Create(notifications).Error
if err != nil {
api.RespondError(c, 500, "Failed to edit offline notifications: "+err.Error())
return
}
api.RespondSuccess(c, nil)
}
func ListOfflineNotifications(c *gin.Context) {
var notifications []models.OfflineNotification
err := dbcore.GetDBInstance().Model(&models.OfflineNotification{}).Find(&notifications).Error
if err != nil {
api.RespondError(c, 500, "Failed to list offline notifications: "+err.Error())
return
}
api.RespondSuccess(c, notifications)
}
-80
View File
@@ -1,80 +0,0 @@
package admin
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/database/tasks"
)
// POST body: clients []string, target, task_type string, interval int
func AddPingTask(c *gin.Context) {
var req struct {
Clients []string `json:"clients" binding:"required"`
Name string `json:"name" binding:"required"`
Target string `json:"target" binding:"required"`
TaskType string `json:"type" binding:"required"` // icmp, tcp, http
Interval int `json:"interval" binding:"required"` // 间隔时间,单位秒
}
if err := c.ShouldBindJSON(&req); err != nil {
api.RespondError(c, http.StatusBadRequest, err.Error())
return
}
if taskID, err := tasks.AddPingTask(req.Clients, req.Name, req.Target, req.TaskType, req.Interval); err != nil {
api.RespondError(c, http.StatusInternalServerError, err.Error())
} else {
api.RespondSuccess(c, gin.H{"task_id": taskID})
}
}
// POST body: id []uint
func DeletePingTask(c *gin.Context) {
var req struct {
ID []uint `json:"id" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
api.RespondError(c, http.StatusBadRequest, err.Error())
return
}
if err := tasks.DeletePingTask(req.ID); err != nil {
api.RespondError(c, http.StatusInternalServerError, err.Error())
} else {
api.RespondSuccess(c, nil)
}
}
// POST body: id []uint, updates map[string]interface{}
func EditPingTask(c *gin.Context) {
var req struct {
Tasks []*models.PingTask `json:"tasks" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
api.RespondError(c, http.StatusBadRequest, "Invalid request data")
return
}
if err := tasks.EditPingTask(req.Tasks); err != nil {
api.RespondError(c, http.StatusInternalServerError, err.Error())
} else {
// for _, task := range req.Tasks {
// tasks.DeletePingRecords([]uint{task.Id})
// }
api.RespondSuccess(c, nil)
}
}
func GetAllPingTasks(c *gin.Context) {
tasks, err := tasks.GetAllPingTasks()
if err != nil {
api.RespondError(c, http.StatusInternalServerError, err.Error())
return
}
api.RespondSuccess(c, tasks)
}
-50
View File
@@ -1,50 +0,0 @@
package admin
import (
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/database/accounts"
"github.com/komari-monitor/komari/database/logOperation"
"github.com/gin-gonic/gin"
)
func GetSessions(c *gin.Context) {
ss, err := accounts.GetAllSessions()
if err != nil {
api.RespondError(c, 500, "Failed to retrieve sessions: "+err.Error())
return
}
current, _ := c.Cookie("session_token")
c.JSON(200, gin.H{"status": "success", "current": current, "data": ss})
}
func DeleteSession(c *gin.Context) {
var req struct {
Session string `json:"session" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
api.RespondError(c, 400, "Invalid request: "+err.Error())
return
}
err := accounts.DeleteSession(req.Session)
if err != nil {
api.RespondError(c, 500, "Failed to delete session: "+err.Error())
return
}
uuid, _ := c.Get("uuid")
logOperation.Log(c.ClientIP(), uuid.(string), "delete session", "info")
api.RespondSuccess(c, nil)
}
func DeleteAllSession(c *gin.Context) {
err := accounts.DeleteAllSessions()
if err != nil {
api.RespondError(c, 500, "Failed to delete all sessions: "+err.Error())
return
}
uuid, _ := c.Get("uuid")
logOperation.Log(c.ClientIP(), uuid.(string), "delete all sessions", "warn")
api.RespondSuccess(c, nil)
}
-71
View File
@@ -1,71 +0,0 @@
package admin
import (
"database/sql"
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/database/config"
"github.com/komari-monitor/komari/database/logOperation"
"github.com/komari-monitor/komari/database/models"
"github.com/gin-gonic/gin"
)
// GetSettings 获取自定义配置
func GetSettings(c *gin.Context) {
cst, err := config.Get()
if err != nil {
if err == sql.ErrNoRows {
//override
cst = models.Config{Sitename: "Komari"}
cst.ID = 1
config.Save(cst)
api.RespondSuccess(c, cst)
return
}
c.JSON(500, gin.H{
"status": "error",
"message": "Internal Server Error: " + err.Error(),
})
}
api.RespondSuccess(c, cst)
}
// EditSettings 更新自定义配置
func EditSettings(c *gin.Context) {
cfg := make(map[string]interface{})
if err := c.ShouldBindJSON(&cfg); err != nil {
api.RespondError(c, 400, "Invalid or missing request body: "+err.Error())
return
}
cfg["id"] = 1 // Only one record
if err := config.Update(cfg); err != nil {
api.RespondError(c, 500, "Failed to update settings: "+err.Error())
return
}
uuid, _ := c.Get("uuid")
message := "update settings: "
for key := range cfg {
ignoredKeys := []string{"id", "updated_at"}
if contains(ignoredKeys, key) {
continue
}
message += key + ", "
}
if len(message) > 2 {
message = message[:len(message)-2]
}
logOperation.Log(c.ClientIP(), uuid.(string), message, "info")
api.RespondSuccess(c, nil)
}
func contains(slice []string, item string) bool {
for _, v := range slice {
if v == item {
return true
}
}
return false
}
-154
View File
@@ -1,154 +0,0 @@
package admin
import (
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/database/tasks"
)
func GetTasks(c *gin.Context) {
dbTasks, err := tasks.GetAllTasks()
if err != nil {
api.RespondError(c, 500, "Failed to retrieve tasks: "+err.Error())
return
}
var responseTasks []gin.H
for _, t := range dbTasks {
results, err := tasks.GetTaskResultsByTaskId(t.TaskId)
if err != nil {
api.RespondError(c, 500, "Failed to retrieve task results: "+err.Error())
return
}
var filteredResults []gin.H
for _, r := range results {
filteredResults = append(filteredResults, gin.H{
"client": r.Client,
"result": r.Result,
"exit_code": r.ExitCode,
"finished_at": r.FinishedAt,
"created_at": r.CreatedAt,
})
}
responseTasks = append(responseTasks, gin.H{
"task_id": t.TaskId,
"clients": t.Clients,
"command": t.Command,
"results": filteredResults,
})
}
api.RespondSuccess(c, responseTasks)
}
func GetTaskById(c *gin.Context) {
taskId := c.Param("task_id")
if taskId == "" {
api.RespondError(c, 400, "Task ID is required")
return
}
task, err := tasks.GetTaskByTaskId(taskId)
if err != nil {
api.RespondError(c, 500, "Failed to retrieve task: "+err.Error())
return
}
if task == nil {
api.RespondError(c, 404, "Task not found")
return
}
results, err := tasks.GetTaskResultsByTaskId(taskId)
if err != nil {
api.RespondError(c, 500, "Failed to retrieve task results: "+err.Error())
return
}
var filteredResults []gin.H
for _, r := range results {
filteredResults = append(filteredResults, gin.H{
"client": r.Client,
"result": r.Result,
"exit_code": r.ExitCode,
"finished_at": r.FinishedAt,
"created_at": r.CreatedAt,
})
}
api.RespondSuccess(c, gin.H{
"task_id": task.TaskId,
"clients": task.Clients,
"command": task.Command,
"results": filteredResults,
})
}
func GetTasksByClientId(c *gin.Context) {
clientId := c.Param("uuid")
if clientId == "" {
api.RespondError(c, 400, "Client ID is required")
return
}
tasks, err := tasks.GetTasksByClientId(clientId)
if err != nil {
api.RespondError(c, 500, "Failed to retrieve tasks: "+err.Error())
return
}
if len(tasks) == 0 {
api.RespondError(c, 404, "No tasks found for this client")
return
}
api.RespondSuccess(c, tasks)
}
func GetSpecificTaskResult(c *gin.Context) {
taskId := c.Param("task_id")
clientId := c.Param("uuid")
if taskId == "" || clientId == "" {
api.RespondError(c, 400, "Task ID and Client ID are required")
return
}
result, err := tasks.GetSpecificTaskResult(taskId, clientId)
if err != nil {
api.RespondError(c, 500, "Failed to retrieve task result: "+err.Error())
return
}
if result == nil {
api.RespondError(c, 404, "No result found for this task and client")
return
}
api.RespondSuccess(c, result)
}
// Param: task_id
func GetTaskResultsByTaskId(c *gin.Context) {
taskId := c.Param("task_id")
if taskId == "" {
api.RespondError(c, 400, "Task ID is required")
return
}
results, err := tasks.GetTaskResultsByTaskId(taskId)
if err != nil {
api.RespondError(c, 500, "Failed to retrieve task results: "+err.Error())
return
}
if len(results) == 0 {
api.RespondError(c, 404, "No results found for this task")
return
}
api.RespondSuccess(c, results)
}
func GetAllTaskResultByUUID(c *gin.Context) {
clientId := c.Param("uuid")
if clientId == "" {
api.RespondError(c, 400, "Client ID is required")
return
}
results, err := tasks.GetAllTasksResultByUUID(clientId)
if err != nil {
api.RespondError(c, 500, "Failed to retrieve tasks: "+err.Error())
return
}
if len(results) == 0 {
api.RespondError(c, 404, "No tasks found for this client")
return
}
api.RespondSuccess(c, results)
}
-47
View File
@@ -1,47 +0,0 @@
package test
import (
"net"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/database/config"
"github.com/komari-monitor/komari/utils/geoip"
"github.com/komari-monitor/komari/utils/messageSender"
)
func TestSendMessage(c *gin.Context) {
message := "Komari test message."
err := messageSender.SendTextMessage(message)
if err != nil {
api.RespondError(c, 500, "Failed to send message: "+err.Error())
return
}
api.RespondSuccess(c, nil)
}
func TestGeoIp(c *gin.Context) {
ip := c.Query("ip")
if ip == "" {
if cfIP := c.GetHeader("CF-Connecting-IP"); cfIP != "" {
ip = cfIP
} else {
ip = c.ClientIP()
}
}
conf, err := config.Get()
if err != nil {
api.RespondError(c, 500, "Failed to get configuration: "+err.Error())
return
}
if !conf.GeoIpEnabled {
api.RespondError(c, 400, "GeoIP is not enabled in the configuration.")
return
}
GeoIpRecord, err := geoip.GetGeoIpInfo(net.ParseIP(ip))
if err != nil {
api.RespondError(c, 500, "Failed to get GeoIP record: "+err.Error())
return
}
api.RespondSuccess(c, GeoIpRecord)
}
-46
View File
@@ -1,46 +0,0 @@
package update
import (
"io"
"net/http"
"os"
"strings"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/database/logOperation"
)
func UploadFavicon(c *gin.Context) {
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 5<<20) // 5MB
data, err := io.ReadAll(c.Request.Body)
if err != nil {
if strings.Contains(err.Error(), "request body too large") {
api.RespondError(c, http.StatusRequestEntityTooLarge, "File too large. Maximum size is 5MB")
} else {
api.RespondError(c, http.StatusBadRequest, err.Error())
}
return
}
if err := os.WriteFile("./data/favicon.ico", data, 0644); err != nil {
api.RespondError(c, http.StatusInternalServerError, "Failed to save favicon: "+err.Error())
return
}
uuid, _ := c.Get("uuid")
logOperation.Log(c.ClientIP(), uuid.(string), "Favicon uploaded", "info")
api.RespondSuccess(c, nil)
}
func DeleteFavicon(c *gin.Context) {
if err := os.Remove("./data/favicon.ico"); err != nil {
if os.IsNotExist(err) {
api.RespondError(c, http.StatusNotFound, "Favicon not found")
} else {
api.RespondError(c, http.StatusInternalServerError, "Failed to delete favicon: "+err.Error())
}
return
}
uuid, _ := c.Get("uuid")
logOperation.Log(c.ClientIP(), uuid.(string), "Favicon deleted", "info")
api.RespondSuccess(c, nil)
}
-18
View File
@@ -1,18 +0,0 @@
package update
import (
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/database/logOperation"
"github.com/komari-monitor/komari/utils/geoip"
)
func UpdateMmdbGeoIP(c *gin.Context) {
if err := geoip.UpdateGeoIpDatabase(); err != nil {
api.RespondError(c, 500, "Failed to update GeoIP database "+err.Error())
return
}
uuid, _ := c.Get("uuid")
logOperation.Log(c.ClientIP(), uuid.(string), "GeoIP database updated", "info")
api.RespondSuccess(c, nil)
}
-40
View File
@@ -1,40 +0,0 @@
package update
import (
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/database/accounts"
"github.com/komari-monitor/komari/database/logOperation"
)
func UpdateUser(c *gin.Context) {
var req struct {
Uuid string `json:"uuid" binding:"required"`
Name *string `json:"username"`
Password *string `json:"password"`
SsoType *string `json:"sso_type"`
}
if err := c.ShouldBindJSON(&req); err != nil {
api.RespondError(c, 400, "Invalid or missing request body: "+err.Error())
return
}
if req.Password == nil && req.Name == nil {
api.RespondError(c, 400, "At least one field (username or password) must be provided")
return
}
if req.Name != nil && len(*req.Name) < 3 {
api.RespondError(c, 400, "Username must be at least 3 characters long")
return
}
if req.Password != nil && len(*req.Password) < 6 {
api.RespondError(c, 400, "Password must be at least 6 characters long")
return
}
if err := accounts.UpdateUser(req.Uuid, req.Name, req.Password, req.SsoType); err != nil {
api.RespondError(c, 500, "Failed to update user: "+err.Error())
return
}
uuid, _ := c.Get("uuid")
logOperation.Log(c.ClientIP(), uuid.(string), "User updated", "warn")
api.RespondSuccess(c, gin.H{"uuid": req.Uuid})
}
-193
View File
@@ -1,193 +0,0 @@
package client
import (
"bytes"
"encoding/json"
"fmt"
"io"
"time"
"log"
"net/http"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/common"
"github.com/komari-monitor/komari/database/clients"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/database/tasks"
"github.com/komari-monitor/komari/utils/notification"
"github.com/komari-monitor/komari/ws"
"github.com/patrickmn/go-cache"
)
func UploadReport(c *gin.Context) {
bodyBytes, err := io.ReadAll(c.Request.Body)
if err != nil {
log.Println("Failed to read request body:", err)
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
return
}
var data map[string]interface{}
err = json.Unmarshal(bodyBytes, &data)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
return
}
// Save report to database
var report common.Report
err = json.Unmarshal(bodyBytes, &report)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
return
}
report.UpdatedAt = time.Now()
err = SaveClientReport(report.UUID, report)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("%v", err)})
return
}
// Update report with method and token
ws.GetLatestReport()[report.UUID] = &report
c.Request.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) // Restore the body for further use
c.JSON(200, gin.H{"status": "success"})
}
func WebSocketReport(c *gin.Context) {
// 升级ws
if !websocket.IsWebSocketUpgrade(c.Request) {
c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "Require WebSocket upgrade"})
return
}
upgrader := websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
return true
},
}
// Upgrade the HTTP connection to a WebSocket connection
unsafeConn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "Failed to upgrade to WebSocket"})
return
}
conn := ws.NewSafeConn(unsafeConn)
defer conn.Close()
_, message, err := conn.ReadMessage()
if err != nil {
log.Println("Error reading message:", err)
return
}
// 第一次数据拿token
data := map[string]interface{}{}
err = json.Unmarshal(message, &data)
if err != nil {
conn.WriteJSON(gin.H{"status": "error", "error": "Invalid JSON"})
return
}
// it should ok,token was verfied in the middleware
token := ""
var errMsg string
// 优先检查查询参数中的 token
token = c.Query("token")
// 如果 token 为空,返回错误
if token == "" {
conn.WriteJSON(gin.H{"status": "error", "error": errMsg})
return
}
uuid, err := clients.GetClientUUIDByToken(token)
if err != nil {
conn.WriteJSON(gin.H{"status": "error", "error": errMsg})
return
}
// 只允许一个客户端的连接
if _, exists := ws.GetConnectedClients()[uuid]; exists {
conn.WriteJSON(gin.H{"status": "error", "error": "Token already in use"})
return
}
ws.SetConnectedClients(uuid, conn)
go notification.OnlineNotification(uuid)
defer func() {
ws.DeleteConnectedClients(uuid)
notification.OfflineNotification(uuid)
}()
for {
_, message, err := conn.ReadMessage()
if err != nil {
break
}
type MessageType struct {
Type string `json:"type"`
}
var msgType MessageType
err = json.Unmarshal(message, &msgType)
if err != nil {
conn.WriteJSON(gin.H{"status": "error", "error": "Invalid JSON"})
continue
}
switch msgType.Type {
case "", "report":
report := common.Report{}
err = json.Unmarshal(message, &report)
if err != nil {
conn.WriteJSON(gin.H{"status": "error", "error": "Invalid report format"})
continue
}
report.UpdatedAt = time.Now()
err = SaveClientReport(uuid, report)
if err != nil {
conn.WriteJSON(gin.H{"status": "error", "error": fmt.Sprintf("%v", err)})
continue
}
ws.SetLatestReport(uuid, &report)
case "ping_result":
var reqBody struct {
PingTaskID uint `json:"task_id"`
PingResult int `json:"value"`
PingType string `json:"ping_type"`
FinishedAt time.Time `json:"finished_at"`
}
err = json.Unmarshal(message, &reqBody)
if err != nil {
conn.WriteJSON(gin.H{"status": "error", "error": "Invalid ping result format"})
continue
}
pingResult := models.PingRecord{
Client: uuid,
TaskId: reqBody.PingTaskID,
Value: reqBody.PingResult,
Time: reqBody.FinishedAt,
}
tasks.SavePingRecord(pingResult)
default:
log.Printf("Unknown message type: %s", msgType.Type)
conn.WriteJSON(gin.H{"status": "error", "error": "Unknown message type"})
}
}
}
func SaveClientReport(uuid string, report common.Report) error {
reports, _ := api.Records.Get(uuid)
if reports == nil {
reports = []common.Report{}
}
if report.CPU.Usage < 0.01 {
report.CPU.Usage = 0.01
}
reports = append(reports.([]common.Report), report)
api.Records.Set(uuid, reports, cache.DefaultExpiration)
return nil
}
-35
View File
@@ -1,35 +0,0 @@
package client
import (
"time"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/database/clients"
"github.com/komari-monitor/komari/database/tasks"
)
func TaskResult(c *gin.Context) {
token := c.Query("token")
clientId, _ := clients.GetClientUUIDByToken(token)
if clientId == "" {
c.JSON(400, gin.H{"status": "error", "message": "Invalid or missing token"})
return
}
var req struct {
TaskId string `json:"task_id" binding:"required"`
Result string `json:"result" binding:"required"`
ExitCode int `json:"exit_code"`
FinishedAt time.Time `json:"finished_at" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, gin.H{"status": "error", "message": "Invalid request"})
return
}
if err := tasks.SaveTaskResult(req.TaskId, clientId, req.Result, req.ExitCode, req.FinishedAt); err != nil {
c.JSON(500, gin.H{"status": "error", "message": "Failed to update task result: " + err.Error()})
return
}
c.JSON(200, gin.H{"status": "success", "message": "Task result updated successfully"})
}
-51
View File
@@ -1,51 +0,0 @@
package client
import (
"net"
"github.com/komari-monitor/komari/database/clients"
"github.com/komari-monitor/komari/database/config"
"github.com/komari-monitor/komari/utils/geoip"
"github.com/gin-gonic/gin"
)
func UploadBasicInfo(c *gin.Context) {
var cbi = map[string]interface{}{}
if err := c.ShouldBindJSON(&cbi); err != nil {
c.JSON(400, gin.H{"status": "error", "error": "Invalid or missing data"})
return
}
token := c.Query("token")
uuid, err := clients.GetClientUUIDByToken(token)
if uuid == "" || err != nil {
c.JSON(400, gin.H{"status": "error", "error": "Invalid token"})
return
}
cbi["uuid"] = uuid
if cfg, err := config.Get(); err == nil && cfg.GeoIpEnabled {
if ipv4, ok := cbi["ipv4"].(string); ok && ipv4 != "" {
ip4 := net.ParseIP(ipv4)
ip4_record, _ := geoip.GetGeoIpInfo(ip4)
if ip4_record != nil {
cbi["region"] = geoip.GetRegionUnicodeEmoji(ip4_record.Country.ISOCode)
}
} else if ipv6, ok := cbi["ipv6"].(string); ok && ipv6 != "" {
ip6 := net.ParseIP(ipv6)
ip6_record, _ := geoip.GetGeoIpInfo(ip6)
if ip6_record != nil {
cbi["region"] = geoip.GetRegionUnicodeEmoji(ip6_record.Country.ISOCode)
}
}
}
if err := clients.SaveClientInfo(cbi); err != nil {
c.JSON(500, gin.H{"status": "error", "error": err})
return
}
c.JSON(200, gin.H{"status": "success"})
}
-15
View File
@@ -1,15 +0,0 @@
package api
import (
"github.com/gin-gonic/gin"
)
func GetClientRecentRecords(c *gin.Context) {
uuid := c.Param("uuid")
if uuid == "" {
RespondError(c, 400, "UUID is required")
return
}
records, _ := Records.Get(uuid)
RespondSuccess(c, records)
}
-75
View File
@@ -1,75 +0,0 @@
package api
import (
"encoding/json"
"io"
"net/http"
"github.com/komari-monitor/komari/database/accounts"
"github.com/komari-monitor/komari/database/config"
"github.com/komari-monitor/komari/database/logOperation"
"github.com/gin-gonic/gin"
)
type LoginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
TwoFa string `json:"2fa_code"`
}
func Login(c *gin.Context) {
conf, _ := config.Get()
if conf.DisablePasswordLogin {
RespondError(c, http.StatusForbidden, "Password login is disabled")
return
}
bodyBytes, err := io.ReadAll(c.Request.Body)
if err != nil {
RespondError(c, http.StatusBadRequest, "Invalid request body: "+err.Error())
return
}
var data LoginRequest
err = json.Unmarshal(bodyBytes, &data)
if err != nil {
RespondError(c, http.StatusBadRequest, "Invalid request body: "+err.Error())
return
}
if data.Username == "" || data.Password == "" {
RespondError(c, http.StatusBadRequest, "Invalid request body: Username and password are required")
return
}
uuid, success := accounts.CheckPassword(data.Username, data.Password)
if !success {
RespondError(c, http.StatusUnauthorized, "Invalid credentials")
return
}
// 2FA
user, _ := accounts.GetUserByUUID(uuid)
if user.TwoFactor != "" && data.TwoFa == "" {
RespondError(c, http.StatusUnauthorized, "2FA code is required")
return
}
if ok, err := accounts.Verify2Fa(uuid, data.TwoFa); err != nil && ok {
RespondError(c, http.StatusUnauthorized, "Invalid 2FA code")
return
}
// Create session
session, err := accounts.CreateSession(uuid, 2592000, c.Request.UserAgent(), c.ClientIP(), "password")
if err != nil {
RespondError(c, http.StatusInternalServerError, "Failed to create session: "+err.Error())
return
}
c.SetCookie("session_token", session, 2592000, "/", "", false, true)
logOperation.Log(c.ClientIP(), uuid, "logged in (password)", "login")
RespondSuccess(c, gin.H{"set-cookie": gin.H{"session_token": session}})
}
func Logout(c *gin.Context) {
session, _ := c.Cookie("session_token")
accounts.DeleteSession(session)
c.SetCookie("session_token", "", -1, "/", "", false, true)
logOperation.Log(c.ClientIP(), "", "logged out", "logout")
c.Redirect(302, "/")
}
-27
View File
@@ -1,27 +0,0 @@
package api
import (
"github.com/komari-monitor/komari/database/accounts"
"github.com/gin-gonic/gin"
)
func GetMe(c *gin.Context) {
session, err := c.Cookie("session_token")
if err != nil {
c.JSON(200, gin.H{"username": "Guest", "logged_in": false})
return
}
uuid, err := accounts.GetSession(session)
if err != nil {
c.JSON(200, gin.H{"username": "Guest", "logged_in": false})
return
}
user, err := accounts.GetUserByUUID(uuid)
if err != nil {
c.JSON(200, gin.H{"username": "Guest", "logged_in": false})
return
}
c.JSON(200, gin.H{"username": user.Username, "logged_in": true, "uuid": user.UUID, "sso_type": user.SSOType, "sso_id": user.SSOID, "2fa_enabled": user.TwoFactor != ""})
}
-95
View File
@@ -1,95 +0,0 @@
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/database/accounts"
"github.com/stretchr/testify/assert"
)
func TestGetMe(t *testing.T) {
// 设置测试模式
gin.SetMode(gin.TestMode)
// 创建测试用户
user, err := accounts.CreateAccount("testuser", "password")
if err != nil {
t.Fatalf("Failed to create test user: %v", err)
}
uuid := user.UUID
sessionToken, _ := accounts.CreateSession(uuid, 2592000, "test_user_agent", "127.0.0.1", "oauth")
tests := []struct {
name string
sessionToken string
expectedStatus int
expectedBody map[string]interface{}
}{
{
name: "有效的会话",
sessionToken: sessionToken,
expectedStatus: http.StatusOK,
expectedBody: map[string]interface{}{
"username": "testuser",
},
},
{
name: "无效的会话",
sessionToken: "invalid_token",
expectedStatus: http.StatusOK,
expectedBody: map[string]interface{}{
"username": "Guest",
},
},
{
name: "无会话",
sessionToken: "",
expectedStatus: http.StatusOK,
expectedBody: map[string]interface{}{
"username": "Guest",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// 创建测试路由
router := gin.New()
router.GET("/me", GetMe)
// 创建测试请求
req, _ := http.NewRequest("GET", "/me", nil)
if tt.sessionToken != "" {
req.AddCookie(&http.Cookie{
Name: "session_token",
Value: tt.sessionToken,
})
}
// 创建响应记录器
w := httptest.NewRecorder()
// 执行请求
router.ServeHTTP(w, req)
// 断言状态码
assert.Equal(t, tt.expectedStatus, w.Code)
// 解析响应体
var response map[string]interface{}
err := json.Unmarshal(w.Body.Bytes(), &response)
assert.NoError(t, err)
// 断言响应体
assert.Equal(t, tt.expectedBody, response)
})
}
// 清除测试数据
accounts.DeleteAccountByUsername("testuser")
accounts.DeleteAllSessions()
}
-26
View File
@@ -1,26 +0,0 @@
package api
import (
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/database/clients"
)
func GetNodesInformation(c *gin.Context) {
clientList, err := clients.GetAllClientBasicInfo()
if err != nil {
RespondError(c, 500, "Failed to retrieve client information: "+err.Error())
return
}
count := len(clientList)
// 公开信息不展示IP地址,私有备注不展示
for i := 0; i < count; i++ {
clientList[i].IPv4 = ""
clientList[i].IPv6 = ""
clientList[i].Remark = ""
clientList[i].Version = ""
clientList[i].Token = ""
}
RespondSuccess(c, clientList)
}
-97
View File
@@ -1,97 +0,0 @@
package api
import (
"fmt"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/database/accounts"
"github.com/komari-monitor/komari/database/config"
"github.com/komari-monitor/komari/database/logOperation"
"github.com/komari-monitor/komari/utils/oauth"
)
// /api/oauth
func OAuth(c *gin.Context) {
cfg, _ := config.Get()
if !cfg.OAuthEnabled {
c.JSON(403, gin.H{"status": "error", "error": "OAuth is not enabled"})
return
}
authURL, state := oauth.GetOAuthUrl()
c.SetCookie("oauth_state", state, 3600, "/", "", false, true)
c.Redirect(302, authURL)
}
// /api/oauth_callback
func OAuthCallback(c *gin.Context) {
// 验证state防止CSRF攻击
state, _ := c.Cookie("oauth_state")
c.SetCookie("oauth_state", "", -1, "/", "", false, true)
if state == "" || state != c.Query("state") {
c.JSON(400, gin.H{"status": "error", "error": "Invalid state"})
return
}
// 获取code
code := c.Query("code")
if code == "" {
c.JSON(400, gin.H{"status": "error", "error": "No code provided"})
return
}
githubUser, err := oauth.OAuthCallback(code, state)
if err != nil {
c.JSON(500, gin.H{"status": "error", "error": "Failed to get user info: " + err.Error()})
return
}
// 使用GitHub ID作为SSO ID
sso_id := fmt.Sprintf("github_%d", githubUser.ID)
// 如果cookie中有binding_external_account,说明是绑定外部账号
// 否则是登录
uuid, _ := c.Cookie("binding_external_account")
c.SetCookie("binding_external_account", "", -1, "/", "", false, true)
if uuid != "" {
// 绑定外部账号
session, _ := c.Cookie("session_token")
user, err := accounts.GetUserBySession(session)
if err != nil || user.UUID != uuid {
c.JSON(500, gin.H{"status": "error", "message": "Binding failed"})
return
}
err = accounts.BindingExternalAccount(user.UUID, sso_id)
if err != nil {
c.JSON(500, gin.H{"status": "error", "message": "Binding failed"})
return
}
logOperation.Log(c.ClientIP(), user.UUID, "bound external account (OAuth)"+fmt.Sprintf(",sso_id: %s", sso_id), "login")
c.Redirect(302, "/manage")
return
}
// 尝试获取用户
user, err := accounts.GetUserBySSO(sso_id)
if err != nil {
c.JSON(401, gin.H{
"status": "error",
"message": "please log in and bind your GitHub account first.",
})
return
}
// 创建会话
session, err := accounts.CreateSession(user.UUID, 2592000, c.Request.UserAgent(), c.ClientIP(), "oauth")
if err != nil {
c.JSON(500, gin.H{"status": "error", "message": err.Error()})
return
}
// 设置cookie并返回
c.SetCookie("session_token", session, 2592000, "/", "", false, true)
logOperation.Log(c.ClientIP(), user.UUID, "logged in (OAuth)", "login")
c.Redirect(302, "/manage")
}
-28
View File
@@ -1,28 +0,0 @@
package api
import (
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/database/config"
)
func GetPublicSettings(c *gin.Context) {
cst, err := config.Get()
if err != nil {
RespondError(c, 500, err.Error())
return
}
// Return public settings including CORS
RespondSuccess(c, gin.H{
"sitename": cst.Sitename,
"description": cst.Description,
"custom_head": cst.CustomHead,
"custom_body": cst.CustomBody,
"oauth_enable": cst.OAuthEnabled,
"disable_password_login": cst.DisablePasswordLogin,
"allow_cors": cst.AllowCors,
"record_enabled": cst.RecordEnabled,
"record_preserve_time": cst.RecordPreserveTime,
"ping_record_preserve_time": cst.PingRecordPreserveTime,
})
}
-109
View File
@@ -1,109 +0,0 @@
package record
import (
"strconv"
"time"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/api"
records "github.com/komari-monitor/komari/database/records"
"github.com/komari-monitor/komari/database/tasks"
)
func GetRecordsByUUID(c *gin.Context) {
uuid := c.Query("uuid")
hours := c.Query("hours")
if uuid == "" {
api.RespondError(c, 400, "UUID is required")
return
}
if hours == "" {
hours = "4"
}
hoursInt, err := strconv.Atoi(hours)
if err != nil {
api.RespondError(c, 400, "Invalid hours parameter")
return
}
records, err := records.GetRecordsByClientAndTime(uuid, time.Now().Add(-time.Duration(hoursInt)*time.Hour), time.Now())
if err != nil {
api.RespondError(c, 500, "Failed to fetch records: "+err.Error())
return
}
api.RespondSuccess(c, gin.H{
"records": records,
"count": len(records),
})
}
// GET query: uuid string, hours int
func GetPingRecords(c *gin.Context) {
uuid := c.Query("uuid")
hours := c.Query("hours")
if uuid == "" {
api.RespondError(c, 400, "UUID is required")
return
}
if hours == "" {
hours = "4"
}
hoursInt, err := strconv.Atoi(hours)
if err != nil {
api.RespondError(c, 400, "Invalid hours parameter")
return
}
records, err := tasks.GetPingRecordsByClientAndTime(uuid, time.Now().Add(-time.Duration(hoursInt)*time.Hour), time.Now())
if err != nil {
api.RespondError(c, 500, "Failed to fetch records: "+err.Error())
return
}
response := gin.H{
"records": records,
"count": len(records),
}
if len(records) > 0 {
// 获取当前属于该 uuid 的 pingTasks
pingTasks, err := tasks.GetAllPingTasks()
if err != nil {
api.RespondError(c, 500, "Failed to fetch ping tasks: "+err.Error())
return
}
taskIdSet := make(map[uint]struct{})
for _, r := range records {
taskIdSet[r.TaskId] = struct{}{}
}
tasksList := make([]gin.H, 0, len(pingTasks))
for _, t := range pingTasks {
// 只保留分配给该 uuid 的任务
found := false
for _, client := range t.Clients {
if client == uuid {
found = true
break
}
}
if !found {
continue
}
// 只返回有记录的任务
if _, ok := taskIdSet[t.Id]; ok {
tasksList = append(tasksList, gin.H{
"id": t.Id,
"name": t.Name,
})
}
}
response["tasks"] = tasksList
}
api.RespondSuccess(c, response)
}
-168
View File
@@ -1,168 +0,0 @@
package api
import (
"log"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"github.com/komari-monitor/komari/database/clients"
"github.com/komari-monitor/komari/database/logOperation"
"github.com/komari-monitor/komari/utils"
"github.com/komari-monitor/komari/ws"
)
func RequestTerminal(c *gin.Context) {
uuid := c.Param("uuid")
user_uuid, _ := c.Get("uuid")
_, err := clients.GetClientByUUID(uuid)
if err != nil {
c.JSON(400, gin.H{
"status": "error",
"message": "Client not found",
})
return
}
// 建立ws
if !websocket.IsWebSocketUpgrade(c.Request) {
c.JSON(http.StatusBadRequest, gin.H{"status": "error", "message": "Require WebSocket upgrade"})
return
}
upgrader := websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
return true
},
}
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
return
}
// 新建一个终端连接
id := utils.GenerateRandomString(32)
session := &TerminalSession{
UserUUID: user_uuid.(string),
UUID: uuid,
Browser: conn,
Agent: nil,
}
TerminalSessionsMutex.Lock()
TerminalSessions[id] = session
TerminalSessionsMutex.Unlock()
conn.SetCloseHandler(func(code int, text string) error {
log.Println("Terminal connection closed:", code, text)
TerminalSessionsMutex.Lock()
delete(TerminalSessions, id)
TerminalSessionsMutex.Unlock()
// 通知 Agent 关闭终端连接
if session.Agent != nil {
session.Agent.Close()
}
return nil
})
if ws.GetConnectedClients()[uuid] == nil {
conn.WriteMessage(1, []byte("Client offline!\n被控端离线!"))
conn.Close()
TerminalSessionsMutex.Lock()
delete(TerminalSessions, id)
TerminalSessionsMutex.Unlock()
return
}
err = ws.GetConnectedClients()[uuid].WriteJSON(gin.H{
"message": "terminal",
"request_id": id,
})
if err != nil {
conn.Close()
TerminalSessionsMutex.Lock()
delete(TerminalSessions, id)
TerminalSessionsMutex.Unlock()
return
}
conn.WriteMessage(1, []byte("等待被控端连接 waiting for agent..."))
// 如果没有连接上,则关闭连接
time.AfterFunc(30*time.Second, func() {
TerminalSessionsMutex.Lock()
if session.Agent == nil {
if session.Browser != nil {
session.Browser.WriteMessage(1, []byte("被控端连接超时 timeout"))
session.Browser.Close()
}
conn.Close()
delete(TerminalSessions, id)
}
TerminalSessionsMutex.Unlock()
})
logOperation.Log(c.ClientIP(), user_uuid.(string), "request, terminal id:"+id+",client:"+session.UUID, "terminal")
}
func ForwardTerminal(id string) {
session, exists := TerminalSessions[id]
if !exists || session == nil || session.Agent == nil || session.Browser == nil {
return
}
logOperation.Log("", session.UserUUID, "established, terminal id:"+id, "terminal")
established_time := time.Now()
errChan := make(chan error, 1)
go func() {
for {
messageType, data, err := session.Browser.ReadMessage()
if err != nil {
errChan <- err
return
}
if messageType == websocket.TextMessage {
if session.Agent != nil && string(data[0:1]) == "{" {
err = session.Agent.WriteMessage(websocket.TextMessage, data)
} else if session.Agent != nil {
err = session.Agent.WriteMessage(websocket.BinaryMessage, data)
}
} else if session.Agent != nil {
// 二进制消息,原样传递
err = session.Agent.WriteMessage(websocket.BinaryMessage, data)
}
if err != nil {
errChan <- err
return
}
}
}()
go func() {
for {
_, data, err := session.Agent.ReadMessage()
if err != nil {
errChan <- err
return
}
if session.Browser != nil {
err = session.Browser.WriteMessage(websocket.BinaryMessage, data)
if err != nil {
errChan <- err
return
}
}
}
}()
// 等待错误或主动关闭
<-errChan
// 关闭连接
if session.Agent != nil {
session.Agent.Close()
}
if session.Browser != nil {
session.Browser.Close()
}
disconnect_time := time.Now()
logOperation.Log("", session.UserUUID, "disconnected, terminal id:"+id+", duration:"+disconnect_time.Sub(established_time).String(), "terminal")
TerminalSessionsMutex.Lock()
delete(TerminalSessions, id)
TerminalSessionsMutex.Unlock()
}
+445
View File
@@ -0,0 +1,445 @@
package cmd
import (
"context"
"errors"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/cmd/flags"
"github.com/komari-monitor/komari/database"
"github.com/komari-monitor/komari/database/accounts"
"github.com/komari-monitor/komari/database/auditlog"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/metricstore"
"github.com/komari-monitor/komari/database/models"
d_notification "github.com/komari-monitor/komari/database/notification"
"github.com/komari-monitor/komari/database/tasks"
"github.com/komari-monitor/komari/pkg/config"
"github.com/komari-monitor/komari/pkg/corn"
"github.com/komari-monitor/komari/pkg/migrations"
"github.com/komari-monitor/komari/utils"
"github.com/komari-monitor/komari/utils/geoip"
logutil "github.com/komari-monitor/komari/utils/log"
"github.com/komari-monitor/komari/utils/messageSender"
"github.com/komari-monitor/komari/utils/notifier"
"github.com/komari-monitor/komari/web/api"
"github.com/komari-monitor/komari/web/nezha"
"github.com/komari-monitor/komari/web/oauth"
report_cache "github.com/komari-monitor/komari/web/report"
"github.com/komari-monitor/komari/web/router"
"github.com/komari-monitor/komari/web/security"
)
// cleanupFunc 是一个关闭阶段执行的清理函数。
type cleanupFunc struct {
name string
fn func(ctx context.Context) error
}
// App 显式建模服务端的启动生命周期。
//
// 过去 RunServer 把目录创建、数据库、metric store、GeoIP、定时任务、通知、OAuth、
// Nezha、Gin 中间件、路由、HTTP 启动与 shutdown 全部混在一个函数里,
// 启动顺序只能靠通读整段代码推断,异步初始化过早且吞掉错误,关闭也不完整。
//
// App 把这些拆成有序阶段:
//
// Bootstrap 基础设施:目录、数据库、默认管理员、配置快照
// InitStores 存储:metric store
// InitProviders 外部 provider:OAuth(同步,路由依赖)、GeoIP、消息发送、Nezha 兼容
// StartBackground 后台:定时任务
// BuildRouter 构建 Gin 引擎与路由
// Run 启动 HTTP 服务并阻塞直到收到信号
// Shutdown 反序执行已登记的清理函数
//
// 每个阶段返回错误即可让上层决定是否中止启动;各资源在创建时把对应清理登记到
// cleanup 栈,关闭时按后进先出(LIFO)顺序释放。
type App struct {
settings *config.Settings
engine *gin.Engine
server *http.Server
reload *ReloadManager
dbReady bool
cleanups []cleanupFunc
}
// NewApp 构造一个空的 App。真正的初始化在各阶段方法中完成。
func NewApp() *App {
return &App{
reload: NewReloadManager(),
}
}
// addCleanup 登记一个关闭阶段的清理函数(后进先出执行)。
func (a *App) addCleanup(name string, fn func(ctx context.Context) error) {
a.cleanups = append(a.cleanups, cleanupFunc{name: name, fn: fn})
}
// Bootstrap 初始化基础设施:数据目录、数据库、默认管理员账号、配置快照。
func (a *App) Bootstrap() error {
if err := os.MkdirAll("./data/theme", os.ModePerm); err != nil {
return fmt.Errorf("failed to create theme directory: %w", err)
}
// 注入版本标识,供 dbcore 在检测到版本升级时自动备份 ./data。
// 必须在 Initialize() 之前设置。
dbcore.SetVersionID(utils.CurrentVersion + "-" + utils.VersionHash)
// 显式初始化数据库(返回错误而非在 getter 里 log.Fatal)。
if err := dbcore.Initialize(); err != nil {
return fmt.Errorf("failed to initialize database: %w", err)
}
a.dbReady = true
a.addCleanup("database", func(context.Context) error {
return dbcore.Close()
})
if utils.VersionHash != "unknown" {
gin.SetMode(gin.ReleaseMode)
}
// 首次启动创建默认管理员账号。
if err := ensureDefaultAdmin(); err != nil {
return fmt.Errorf("failed to ensure default admin account: %w", err)
}
conf, err := config.GetManyAs[config.Settings]()
if err != nil {
return fmt.Errorf("failed to load settings: %w", err)
}
a.settings = conf
return nil
}
// InitStores 初始化独立存储组件(metric store)并执行 metrics 迁移。
//
// metric store 现在始终启用(旧的 metric_store_enabled 开关已废弃):
// 未显式配置时使用 SQLite(./data/metrics.db),否则使用配置的 MySQL/PostgreSQL。
// 初始化失败即启动失败,不再静默 fallback 到旧 records 表。
//
// 初始化成功后先执行需要 metric store 的一次性迁移,再执行启动迁移:当 metrics
// 存储后端发生变化(例如从默认 SQLite 切换到 MySQL/PostgreSQL)时,把上一个
// metrics 目标库的数据搬运到当前目标。迁移失败同样让启动失败,并打印明确错误。
func (a *App) InitStores() error {
if err := metricstore.InitializeStore(); err != nil {
auditlog.EventLog("error", fmt.Sprintf("Failed to initialize metric store: %v", err))
return fmt.Errorf("failed to initialize metric store: %w", err)
}
a.addCleanup("metric-store", func(ctx context.Context) error {
return metricstore.CloseStoreContext(ctx)
})
if err := migrations.RunMetricStoreMigrations(migrations.MetricContext{
DB: dbcore.GetDBInstance(),
Store: metricstore.GetStore(),
}); err != nil {
auditlog.EventLog("error", fmt.Sprintf("Metric store one-shot migrations failed: %v", err))
return fmt.Errorf("metric store one-shot migrations failed: %w", err)
}
// 存储后端切换时把上一个 metrics 目标库的数据搬运到当前目标(失败即启动失败)。
if err := metricstore.RunStartupMigration(); err != nil {
auditlog.EventLog("error", fmt.Sprintf("Metrics startup migration failed: %v", err))
return fmt.Errorf("metrics startup migration failed: %w", err)
}
return nil
}
// InitProviders 初始化外部 provider。
//
// OAuth 必须在 HTTP 服务开始接收请求之前同步完成,否则 oauth.CurrentProvider()
// 存在空指针风险;GeoIP 与消息发送允许后台初始化;Nezha 兼容按配置决定是否启动。
func (a *App) InitProviders() error {
// OAuth:同步初始化并处理错误,保证路由可用前 provider 已就绪。
if err := oauth.Initialize(); err != nil {
// 记录但不致命:Initialize 内部在失败时已回退到 github provider。
log.Printf("Failed to initialize OAuth provider: %v", err)
auditlog.EventLog("error", fmt.Sprintf("Failed to initialize OAuth provider: %v", err))
}
a.addCleanup("oauth", func(context.Context) error {
return oauth.Shutdown()
})
// GeoIP:可能涉及下载/加载 mmdb,放后台执行避免拖慢启动。
go geoip.InitGeoIp()
a.addCleanup("geoip", func(context.Context) error {
return geoip.Shutdown()
})
// 消息发送 provider。
messageSender.Initialize()
a.addCleanup("message-sender", func(context.Context) error {
return messageSender.Shutdown()
})
// Nezha 兼容 gRPC 服务:按配置启动,且无论初始是否启动都登记清理,
// 以覆盖运行期通过热重载开启的情况。
if a.settings.NezhaCompatEnabled {
listen := a.settings.NezhaCompatListen
go func() {
if err := nezha.StartNezhaCompat(listen); err != nil {
log.Printf("Nezha compat server error: %v", err)
auditlog.EventLog("error", fmt.Sprintf("Nezha compat server error: %v", err))
}
}()
}
a.addCleanup("nezha-compat", func(context.Context) error {
// 未运行时 StopNezhaCompat 返回错误,属正常情况,忽略即可。
_ = nezha.StopNezhaCompat()
return nil
})
return nil
}
// StartBackground 启动后台工作:定时任务。
func (a *App) StartBackground() error {
registerScheduledWork()
a.addCleanup("scheduler", func(context.Context) error {
corn.StopAll()
return nil
})
return nil
}
// registerReloadHandlers 把此前散落各处的 config.Subscribe 统一登记到 reload 管理器。
func (a *App) registerReloadHandlers(cors *security.CorsController) {
// OAuth provider 切换。
a.reload.Register("oauth-provider", func(event config.ConfigEvent) {
if ok, t := config.IsChangedT[string](event, config.OAuthProviderKey); ok {
if t == "" || t == "none" {
t = "github"
}
oidcProvider, err := database.GetOidcConfigByName(t)
if err != nil {
log.Printf("Failed to get OIDC provider config: %v", err)
return
}
log.Printf("Using %s as OIDC provider", oidcProvider.Name)
if err := oauth.LoadProvider(oidcProvider.Name, oidcProvider.Addition); err != nil {
auditlog.EventLog("error", fmt.Sprintf("Failed to load OIDC provider: %v", err))
}
}
})
// Nezha 兼容服务开关。
a.reload.Register("nezha-compat", func(event config.ConfigEvent) {
if ok, t := config.IsChangedT[bool](event, config.NezhaCompatEnabledKey); ok {
if t {
l, _ := config.GetAs[string](config.NezhaCompatListenKey)
if err := nezha.StartNezhaCompat(l); err != nil {
log.Printf("start Nezha compat server error: %v", err)
auditlog.EventLog("error", fmt.Sprintf("start Nezha compat server error: %v", err))
}
} else {
if err := nezha.StopNezhaCompat(); err != nil {
log.Printf("stop Nezha compat server error: %v", err)
auditlog.EventLog("error", fmt.Sprintf("stop Nezha compat server error: %v", err))
}
}
}
})
// GeoIP provider 切换。
a.reload.Register("geoip-provider", func(event config.ConfigEvent) {
if event.IsChanged(config.GeoIpProviderKey) {
go geoip.InitGeoIp()
}
})
// 消息发送方式切换。
a.reload.Register("message-sender", func(event config.ConfigEvent) {
if event.IsChanged(config.NotificationMethodKey) {
go messageSender.Initialize()
}
})
// CORS 配置热更新。
a.reload.Register("cors", func(event config.ConfigEvent) {
cors.Update(event)
})
}
// BuildRouter 构建 Gin 引擎、中间件与全部路由,并登记热重载处理器。
func (a *App) BuildRouter() error {
r := gin.New()
r.Use(logutil.GinLogger())
r.Use(logutil.GinRecovery())
cors := security.NewCorsController(a.settings.CorsOriginCheckEnabled, a.settings.CorsAllowedOrigins)
r.Use(cors.Middleware())
r.Use(api.IdentityMiddleware())
r.Use(api.PrivateSiteMiddleware())
r.Use(func(c *gin.Context) {
if len(c.Request.URL.Path) >= 4 && c.Request.URL.Path[:4] == "/api" {
c.Header("Cache-Control", "no-store")
}
c.Next()
})
router.Register(r)
// 集中登记并启动热重载订阅。
a.registerReloadHandlers(cors)
a.reload.Start()
a.engine = r
return nil
}
// Run 启动 HTTP 服务并阻塞直到收到中断信号或服务异常退出。
func (a *App) Run() error {
a.server = &http.Server{
Addr: flags.Listen,
Handler: a.engine,
}
serverErr := make(chan error, 1)
log.Printf("Starting server on %s ...", flags.Listen)
go func() {
if err := a.server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
serverErr <- err
}
}()
quit := make(chan os.Signal, 1)
signal.Notify(quit, os.Interrupt, syscall.SIGTERM)
select {
case err := <-serverErr:
a.onFatal(err)
return fmt.Errorf("listen: %w", err)
case <-quit:
return a.Shutdown()
}
}
// Shutdown 优雅关闭:先停止接收新请求,再反序执行已登记的清理函数。
func (a *App) Shutdown() error {
if a.dbReady {
auditlog.Log("", "", "server is shutting down", "info")
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
// 先关闭 HTTP 服务,停止接收新请求。
if a.server != nil {
if err := a.server.Shutdown(ctx); err != nil {
log.Printf("HTTP server forced to shutdown: %v", err)
}
}
// 反序释放资源(后进先出)。
for i := len(a.cleanups) - 1; i >= 0; i-- {
c := a.cleanups[i]
if err := c.fn(ctx); err != nil {
log.Printf("cleanup %q failed: %v", c.name, err)
}
}
return nil
}
// onFatal 处理 HTTP 服务致命错误:尽力释放已登记资源。
func (a *App) onFatal(err error) {
if a.dbReady {
auditlog.Log("", "", "server encountered a fatal error: "+err.Error(), "error")
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
for i := len(a.cleanups) - 1; i >= 0; i-- {
c := a.cleanups[i]
if cerr := c.fn(ctx); cerr != nil {
log.Printf("cleanup %q failed: %v", c.name, cerr)
}
}
}
// ensureDefaultAdmin 首次启动(无任何用户)时创建默认管理员账号。
func ensureDefaultAdmin() error {
var count int64
dbcore.GetDBInstance().Model(&models.User{}).Count(&count)
if count != 0 {
return nil
}
user, passwd, err := accounts.CreateDefaultAdminAccount()
if err != nil {
return err
}
log.Println("Default admin account created. Username:", user, ", Password:", passwd)
return nil
}
// registerScheduledWork 注册所有定时任务与首启动同步逻辑。
func registerScheduledWork() {
if err := tasks.ReloadPingSchedule(); err != nil {
log.Println("Failed to reload ping schedule:", err)
}
if err := d_notification.ReloadLoadNotificationSchedule(); err != nil {
log.Println("Failed to reload load notification schedule:", err)
}
if err := corn.AddFunc("records:cleanup", "@every 30m", cleanupScheduledData); err != nil {
log.Println("Failed to add cleanup scheduled task:", err)
}
if err := corn.AddContextFunc("metrics:compact", "@every 5m", true, compactMetricStore); err != nil {
log.Println("Failed to add metric compact scheduled task:", err)
}
if err := corn.AddFunc("records:minute", "@every 1m", minuteScheduledWork); err != nil {
log.Println("Failed to add minute scheduled task:", err)
}
if err := corn.AddFunc("notifier:expire", "0 0 9 * * *", notifier.CheckExpireScheduledWork); err != nil {
log.Println("Failed to add expire notification scheduled task:", err)
}
notifier.InitTrafficReportSchedule()
}
func cleanupScheduledData() {
cfg, _ := config.GetManyAs[metricstore.MetricStoreConfig]()
retentionDays := cfg.RetentionDays
if retentionDays <= 0 {
retentionDays = 30
}
before := time.Now().Add(-24 * time.Hour * time.Duration(retentionDays))
tasks.ClearTaskResultsByTimeBefore(before)
auditlog.RemoveOldLogs()
accounts.RemoveExpiredSessions()
}
func compactMetricStore(ctx context.Context) {
compactCtx, cancel := context.WithTimeout(ctx, 15*time.Minute)
defer cancel()
written, err := metricstore.Compact(compactCtx, time.Now())
if errors.Is(err, metricstore.ErrCompactInProgress) {
return
}
if err != nil {
log.Printf("Failed to compact metric store after writing %d rollup buckets: %v", written, err)
return
}
if written > 0 {
log.Printf("Metric store compacted %d rollup buckets", written)
}
}
func minuteScheduledWork() {
report_cache.SaveClientReportToDB()
// 每分钟检查一次流量提醒
notifier.CheckTraffic()
}
+33 -6
View File
@@ -1,14 +1,41 @@
package flags
import "strings"
const (
DatabaseTypeSQLite = "sqlite"
)
var (
// 数据库配置
DatabaseType string // 数据库类型:sqlite, mysql
DatabaseType string // 数据库类型:sqlite
DatabaseFile string // SQLite数据库文件路径
DatabaseHost string // MySQL/其他数据库主机地址
DatabasePort string // MySQL/其他数据库端口
DatabaseUser string // MySQL/其他数据库用户名
DatabasePass string // MySQL/其他数据库密码
DatabaseName string // MySQL/其他数据库名称
DatabaseHost string // 保留的兼容参数,当前未使用
DatabasePort string // 保留的兼容参数,当前未使用
DatabaseUser string // 保留的兼容参数,当前未使用
DatabasePass string // 保留的兼容参数,当前未使用
DatabaseName string // 保留的兼容参数,当前未使用
Listen string
)
func NormalizeDatabaseType(databaseType string) string {
databaseType = strings.ToLower(strings.TrimSpace(databaseType))
if databaseType == "" {
return DatabaseTypeSQLite
}
return databaseType
}
func ApplyDatabaseTypeNormalization() string {
DatabaseType = NormalizeDatabaseType(DatabaseType)
return DatabaseType
}
func IsSQLite() bool {
return NormalizeDatabaseType(DatabaseType) == DatabaseTypeSQLite
}
func SupportedDatabaseTypes() string {
return DatabaseTypeSQLite
}
+20
View File
@@ -0,0 +1,20 @@
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)
}
}
}
+3 -8
View File
@@ -4,9 +4,8 @@ import (
"os"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/pkg/config"
"github.com/spf13/cobra"
"gorm.io/gorm"
)
var PermitPasswordLoginCmd = &cobra.Command{
@@ -14,12 +13,8 @@ var PermitPasswordLoginCmd = &cobra.Command{
Short: "Force permit password login",
Long: `Force permit password login`,
Run: func(cmd *cobra.Command, args []string) {
db := dbcore.GetDBInstance()
err := db.Transaction(func(tx *gorm.DB) error {
return tx.Model(&models.Config{}).Where("id = ?", 1).
Update("disable_password_login", false).Error
})
if err != nil {
dbcore.GetDBInstance()
if err := config.Set(config.DisablePasswordLoginKey, false); err != nil {
cmd.Println("Error:", err)
os.Exit(1)
}
+67
View File
@@ -0,0 +1,67 @@
package cmd
import (
"log"
"github.com/komari-monitor/komari/pkg/config"
)
// reloadHandler 是单个配置热重载处理器。
//
// name 仅用于日志定位,handler 接收配置变更事件,自行判断关心的 key 是否变化。
// 每个 handler 相互独立:一个 panic 不应影响其它 handler。
type reloadHandler struct {
name string
handler func(config.ConfigEvent)
}
// ReloadManager 统一收敛所有配置热重载逻辑。
//
// 过去 config.Subscribe 分散在 cmd/server.go、cors.go 等多处,导致:
// - 谁监听哪些 key 不清晰;
// - 某个 handler panic 会连带影响同一订阅回调里的其它逻辑;
// - 关闭时无法统一停止。
//
// ReloadManager 把这些 handler 注册在一处,用单个 config.Subscribe 分发,
// 并对每个 handler 做 panic 隔离。
type ReloadManager struct {
handlers []reloadHandler
started bool
}
// NewReloadManager 创建一个空的热重载管理器。
func NewReloadManager() *ReloadManager {
return &ReloadManager{}
}
// Register 注册一个命名的热重载处理器。必须在 Start 之前调用。
func (m *ReloadManager) Register(name string, handler func(config.ConfigEvent)) {
if handler == nil {
return
}
m.handlers = append(m.handlers, reloadHandler{name: name, handler: handler})
}
// Start 向 config 订阅一次,之后每个事件会分发给全部已注册 handler。
// 重复调用无副作用。
func (m *ReloadManager) Start() {
if m.started {
return
}
m.started = true
config.Subscribe(func(event config.ConfigEvent) {
for _, h := range m.handlers {
m.dispatch(h, event)
}
})
}
// dispatch 执行单个 handler,并隔离 panic,避免一个 handler 影响其它。
func (m *ReloadManager) dispatch(h reloadHandler, event config.ConfigEvent) {
defer func() {
if r := recover(); r != nil {
log.Printf("config reload handler %q panicked: %v", h.name, r)
}
}()
h.handler(event)
}
+17 -14
View File
@@ -9,7 +9,7 @@ import (
"github.com/spf13/cobra"
)
func getEnv(key, defaultValue string) string {
func GetEnv(key, defaultValue string) string {
value := os.Getenv(key)
if value == "" {
return defaultValue
@@ -19,13 +19,13 @@ func getEnv(key, defaultValue string) string {
// 从环境变量获取默认值
var (
dbTypeEnv = getEnv("KOMARI_DB_TYPE", "sqlite")
dbFileEnv = getEnv("KOMARI_DB_FILE", "./data/komari.db")
dbHostEnv = getEnv("KOMARI_DB_HOST", "localhost")
dbPortEnv = getEnv("KOMARI_DB_PORT", "3306")
dbUserEnv = getEnv("KOMARI_DB_USER", "root")
dbPassEnv = getEnv("KOMARI_DB_PASS", "")
dbNameEnv = getEnv("KOMARI_DB_NAME", "komari")
dbTypeEnv = GetEnv("KOMARI_DB_TYPE", "sqlite")
dbFileEnv = GetEnv("KOMARI_DB_FILE", "./data/komari.db")
dbHostEnv = GetEnv("KOMARI_DB_HOST", "localhost")
dbPortEnv = GetEnv("KOMARI_DB_PORT", "3306")
dbUserEnv = GetEnv("KOMARI_DB_USER", "root")
dbPassEnv = GetEnv("KOMARI_DB_PASS", "")
dbNameEnv = GetEnv("KOMARI_DB_NAME", "komari")
)
var RootCmd = &cobra.Command{
@@ -48,11 +48,14 @@ func Execute() {
func init() {
// 设置命令行参数,提供环境变量作为默认值
RootCmd.PersistentFlags().StringVarP(&flags.DatabaseType, "db-type", "t", dbTypeEnv, "Database type (sqlite, mysql) [env: KOMARI_DB_TYPE]")
RootCmd.PersistentFlags().StringVarP(&flags.DatabaseType, "db-type", "t", dbTypeEnv, "Database type (sqlite) [env: KOMARI_DB_TYPE]")
RootCmd.PersistentFlags().StringVarP(&flags.DatabaseFile, "database", "d", dbFileEnv, "SQLite database file path [env: KOMARI_DB_FILE]")
RootCmd.PersistentFlags().StringVar(&flags.DatabaseHost, "db-host", dbHostEnv, "MySQL/Other database host address [env: KOMARI_DB_HOST]")
RootCmd.PersistentFlags().StringVar(&flags.DatabasePort, "db-port", dbPortEnv, "MySQL/Other database port [env: KOMARI_DB_PORT]")
RootCmd.PersistentFlags().StringVar(&flags.DatabaseUser, "db-user", dbUserEnv, "MySQL/Other database username [env: KOMARI_DB_USER]")
RootCmd.PersistentFlags().StringVar(&flags.DatabasePass, "db-pass", dbPassEnv, "MySQL/Other database password [env: KOMARI_DB_PASS]")
RootCmd.PersistentFlags().StringVar(&flags.DatabaseName, "db-name", dbNameEnv, "MySQL/Other database name [env: KOMARI_DB_NAME]")
RootCmd.PersistentFlags().StringVar(&flags.DatabaseHost, "db-host", dbHostEnv, "Reserved database host parameter [env: KOMARI_DB_HOST]")
RootCmd.PersistentFlags().StringVar(&flags.DatabasePort, "db-port", dbPortEnv, "Reserved database port parameter [env: KOMARI_DB_PORT]")
RootCmd.PersistentFlags().StringVar(&flags.DatabaseUser, "db-user", dbUserEnv, "Reserved database username parameter [env: KOMARI_DB_USER]")
RootCmd.PersistentFlags().StringVar(&flags.DatabasePass, "db-pass", dbPassEnv, "Reserved database password parameter [env: KOMARI_DB_PASS]")
RootCmd.PersistentFlags().StringVar(&flags.DatabaseName, "db-name", dbNameEnv, "Reserved database name parameter [env: KOMARI_DB_NAME]")
for _, name := range []string{"db-host", "db-port", "db-user", "db-pass", "db-name"} {
_ = RootCmd.PersistentFlags().MarkHidden(name)
}
}
+28 -280
View File
@@ -1,308 +1,56 @@
package cmd
import (
"context"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/gin-gonic/gin"
"github.com/komari-monitor/komari/api"
"github.com/komari-monitor/komari/api/admin"
"github.com/komari-monitor/komari/api/admin/clipboard"
log_api "github.com/komari-monitor/komari/api/admin/log"
"github.com/komari-monitor/komari/api/admin/notification"
"github.com/komari-monitor/komari/api/admin/test"
"github.com/komari-monitor/komari/api/admin/update"
"github.com/komari-monitor/komari/api/client"
"github.com/komari-monitor/komari/api/record"
"github.com/komari-monitor/komari/cmd/flags"
"github.com/komari-monitor/komari/database/accounts"
"github.com/komari-monitor/komari/database/config"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/logOperation"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/database/records"
"github.com/komari-monitor/komari/database/tasks"
"github.com/komari-monitor/komari/public"
"github.com/komari-monitor/komari/utils"
"github.com/komari-monitor/komari/utils/geoip"
"github.com/komari-monitor/komari/ws"
"github.com/spf13/cobra"
)
var (
DynamicCorsEnabled bool = false
)
var ServerCmd = &cobra.Command{
Use: "server",
Short: "Start the server",
Long: `Start the server`,
Run: func(cmd *cobra.Command, args []string) {
// #region 初始化
if err := os.MkdirAll("./data", os.ModePerm); err != nil {
log.Fatalf("Failed to create data directory: %v", err)
}
InitDatabase()
if utils.VersionHash != "unknown" {
gin.SetMode(gin.ReleaseMode)
}
go geoip.InitGeoIp()
go DoScheduledWork()
r := gin.Default()
// 动态 CORS 中间件
conf, err := config.Get()
if err != nil {
log.Fatal(err)
}
DynamicCorsEnabled = conf.AllowCors
config.Subscribe(func(event config.ConfigEvent) {
DynamicCorsEnabled = event.New.AllowCors
})
r.Use(func(c *gin.Context) {
if DynamicCorsEnabled {
c.Header("Access-Control-Allow-Origin", "*")
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS")
c.Header("Access-Control-Allow-Headers", "Origin, Content-Length, Content-Type, Authorization, Accept, X-CSRF-Token, X-Requested-With, Set-Cookie")
c.Header("Access-Control-Expose-Headers", "Content-Length, Authorization, Set-Cookie")
c.Header("Access-Control-Allow-Credentials", "true")
c.Header("Access-Control-Max-Age", "43200") // 12 hours
if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(204)
return
}
}
c.Next()
})
r.Use(func(c *gin.Context) {
if len(c.Request.URL.Path) >= 4 && c.Request.URL.Path[:4] == "/api" {
c.Header("Cache-Control", "no-store")
}
c.Next()
})
r.Any("/ping", func(c *gin.Context) {
c.String(200, "pong")
})
// #region 公开路由
r.POST("/api/login", api.Login)
r.GET("/api/me", api.GetMe)
r.GET("/api/clients", ws.GetClients)
r.GET("/api/nodes", api.GetNodesInformation)
r.GET("/api/public", api.GetPublicSettings)
r.GET("/api/oauth", api.OAuth)
r.GET("/api/oauth_callback", api.OAuthCallback)
r.GET("/api/logout", api.Logout)
r.GET("/api/version", api.GetVersion)
r.GET("/api/recent/:uuid", api.GetClientRecentRecords)
r.GET("/api/records/load", record.GetRecordsByUUID)
r.GET("/api/records/ping", record.GetPingRecords)
// #region Agent
tokenAuthrized := r.Group("/api/clients", api.TokenAuthMiddleware())
{
tokenAuthrized.GET("/report", client.WebSocketReport) // websocket
tokenAuthrized.POST("/uploadBasicInfo", client.UploadBasicInfo)
tokenAuthrized.POST("/report", client.UploadReport)
tokenAuthrized.GET("/terminal", client.EstablishConnection)
tokenAuthrized.POST("/task/result", client.TaskResult)
}
// #region 管理员
adminAuthrized := r.Group("/api/admin", api.AdminAuthMiddleware())
{
// test
testGroup := adminAuthrized.Group("/test")
{
testGroup.GET("/geoip", test.TestGeoIp)
testGroup.POST("/sendMessage", test.TestSendMessage)
}
// update
updateGroup := adminAuthrized.Group("/update")
{
updateGroup.POST("/mmdb", update.UpdateMmdbGeoIP)
updateGroup.POST("/user", update.UpdateUser)
updateGroup.PUT("/favicon", update.UploadFavicon)
updateGroup.POST("/favicon", update.DeleteFavicon)
}
// tasks
taskGroup := adminAuthrized.Group("/task")
{
taskGroup.GET("/all", admin.GetTasks)
taskGroup.POST("/exec", admin.Exec)
taskGroup.GET("/:task_id", admin.GetTaskById)
taskGroup.GET("/:task_id/result", admin.GetTaskResultsByTaskId)
taskGroup.GET("/:task_id/result/:uuid", admin.GetSpecificTaskResult)
taskGroup.GET("/client/:uuid", admin.GetTasksByClientId)
}
// settings
adminAuthrized.GET("/settings", admin.GetSettings)
adminAuthrized.POST("/settings", admin.EditSettings)
// clients
clientGroup := adminAuthrized.Group("/client")
{
clientGroup.POST("/add", admin.AddClient)
clientGroup.GET("/list", admin.ListClients)
clientGroup.GET("/:uuid", admin.GetClient)
clientGroup.POST("/:uuid/edit", admin.EditClient)
clientGroup.POST("/:uuid/remove", admin.RemoveClient)
clientGroup.GET("/:uuid/token", admin.GetClientToken)
clientGroup.POST("/order", admin.OrderWeight)
// client terminal
clientGroup.GET("/:uuid/terminal", api.RequestTerminal)
}
// records
recordGroup := adminAuthrized.Group("/record")
{
recordGroup.POST("/clear", admin.ClearRecord)
}
// oauth2
oauth2Group := adminAuthrized.Group("/oauth2")
{
oauth2Group.GET("/bind", admin.BindingExternalAccount)
oauth2Group.POST("/unbind", admin.UnbindExternalAccount)
}
sessionGroup := adminAuthrized.Group("/session")
{
sessionGroup.GET("/get", admin.GetSessions)
sessionGroup.POST("/remove", admin.DeleteSession)
sessionGroup.POST("/remove/all", admin.DeleteAllSession)
}
two_factorGroup := adminAuthrized.Group("/2fa")
{
two_factorGroup.GET("/generate", admin.Generate2FA)
two_factorGroup.POST("/enable", admin.Enable2FA)
two_factorGroup.POST("/disable", admin.Disable2FA)
}
adminAuthrized.GET("/logs", log_api.GetLogs)
// clipboard
clipboardGroup := adminAuthrized.Group("/clipboard")
{
clipboardGroup.GET("/:id", clipboard.GetClipboard)
clipboardGroup.GET("", clipboard.ListClipboard)
clipboardGroup.POST("", clipboard.CreateClipboard)
clipboardGroup.POST("/:id", clipboard.UpdateClipboard)
clipboardGroup.POST("/remove", clipboard.BatchDeleteClipboard)
clipboardGroup.POST("/:id/remove", clipboard.DeleteClipboard)
}
notificationGroup := adminAuthrized.Group("/notification")
{
// offline notifications
notificationGroup.GET("/offline", notification.ListOfflineNotifications)
notificationGroup.POST("/offline/edit", notification.EditOfflineNotification)
notificationGroup.POST("/offline/enable", notification.EnableOfflineNotification)
notificationGroup.POST("/offline/disable", notification.DisableOfflineNotification)
}
pingTaskGroup := adminAuthrized.Group("/ping")
{
pingTaskGroup.GET("/", admin.GetAllPingTasks)
pingTaskGroup.POST("/add", admin.AddPingTask)
pingTaskGroup.POST("/delete", admin.DeletePingTask)
pingTaskGroup.POST("/edit", admin.EditPingTask)
}
}
public.Static(r.Group("/"), func(handlers ...gin.HandlerFunc) {
r.NoRoute(handlers...)
})
// #region 静态文件服务
public.UpdateIndex(conf)
config.Subscribe(func(event config.ConfigEvent) {
public.UpdateIndex(event.New)
})
srv := &http.Server{
Addr: flags.Listen,
Handler: r,
}
go func() {
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
OnFatal(err)
log.Fatalf("listen: %s\n", err)
}
}()
quit := make(chan os.Signal, 1)
signal.Notify(quit, os.Interrupt, syscall.SIGTERM)
<-quit
OnShutdown()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
log.Fatalf("Server forced to shutdown: %v", err)
}
RunServer()
},
}
func init() {
// 从环境变量获取监听地址
listenAddr := getEnv("KOMARI_LISTEN", "0.0.0.0:25774")
listenAddr := GetEnv("KOMARI_LISTEN", "0.0.0.0:25774")
ServerCmd.PersistentFlags().StringVarP(&flags.Listen, "listen", "l", listenAddr, "监听地址 [env: KOMARI_LISTEN]")
RootCmd.AddCommand(ServerCmd)
}
func InitDatabase() {
// // 打印数据库类型和连接信息
// if flags.DatabaseType == "mysql" {
// log.Printf("使用 MySQL 数据库连接: %s@%s:%s/%s",
// flags.DatabaseUser, flags.DatabaseHost, flags.DatabasePort, flags.DatabaseName)
// log.Printf("环境变量配置: [KOMARI_DB_TYPE=%s] [KOMARI_DB_HOST=%s] [KOMARI_DB_PORT=%s] [KOMARI_DB_USER=%s] [KOMARI_DB_NAME=%s]",
// os.Getenv("KOMARI_DB_TYPE"), os.Getenv("KOMARI_DB_HOST"), os.Getenv("KOMARI_DB_PORT"),
// os.Getenv("KOMARI_DB_USER"), os.Getenv("KOMARI_DB_NAME"))
// } else {
// log.Printf("使用 SQLite 数据库文件: %s", flags.DatabaseFile)
// log.Printf("环境变量配置: [KOMARI_DB_TYPE=%s] [KOMARI_DB_FILE=%s]",
// os.Getenv("KOMARI_DB_TYPE"), os.Getenv("KOMARI_DB_FILE"))
// }
var count int64 = 0
if dbcore.GetDBInstance().Model(&models.User{}).Count(&count); count == 0 {
user, passwd, err := accounts.CreateDefaultAdminAccount()
if err != nil {
panic(err)
}
log.Println("Default admin account created. Username:", user, ", Password:", passwd)
}
}
// RunServer 按显式的生命周期阶段启动服务端。
//
// 具体各阶段的职责与顺序见 App(cmd/app.go)。这里只负责串联:
// 任一初始化阶段失败即中止启动,避免在半初始化状态下对外提供服务。
func RunServer() {
app := NewApp()
// #region 定时任务
func DoScheduledWork() {
tasks.ReloadPingSchedule()
ticker := time.NewTicker(time.Minute * 30)
minute := time.NewTicker(60 * time.Second)
//records.DeleteRecordBefore(time.Now().Add(-time.Hour * 24 * 30))
records.CompactRecord()
cfg, _ := config.Get()
for {
select {
case <-ticker.C:
records.DeleteRecordBefore(time.Now().Add(-time.Hour * time.Duration(cfg.RecordPreserveTime)))
records.CompactRecord()
tasks.ClearTaskResultsByTimeBefore(time.Now().Add(-time.Hour * time.Duration(cfg.RecordPreserveTime)))
tasks.DeletePingRecordsBefore(time.Now().Add(-time.Hour * time.Duration(cfg.PingRecordPreserveTime)))
logOperation.RemoveOldLogs()
case <-minute.C:
api.SaveClientReportToDB()
// 初始化阶段:任一步失败都不应继续对外服务。
type stage struct {
name string
fn func() error
}
stages := []stage{
{"bootstrap", app.Bootstrap},
{"init-stores", app.InitStores},
{"init-providers", app.InitProviders},
{"start-background", app.StartBackground},
{"build-router", app.BuildRouter},
}
for _, s := range stages {
if err := s.fn(); err != nil {
// 已登记的资源尽力回收,再退出。
_ = app.Shutdown()
log.Fatalf("server startup failed at %q: %v", s.name, err)
}
}
}
func OnShutdown() {
logOperation.Log("", "", "server is shutting down", "info")
}
func OnFatal(err error) {
logOperation.Log("", "", "server encountered a fatal error: "+err.Error(), "error")
if err := app.Run(); err != nil {
log.Fatalf("server exited with error: %v", err)
}
}
-117
View File
@@ -1,117 +0,0 @@
package common
import "time"
type Message struct {
Type string `json:"type"`
Content string `json:"content"`
Sender string `json:"sender"`
Timestamp int64 `json:"timestamp"`
}
type ClientConfig struct {
ClientUUID string `json:"client_uuid" gorm:"type:uuid;primaryKey;foreignKey:ClientUUID;references:UUID;constraint:OnDelete:CASCADE"`
CPU bool `json:"cpu" gorm:"default:true"`
GPU bool `json:"gpu" gorm:"default:true"`
RAM bool `json:"ram" gorm:"default:true"`
SWAP bool `json:"swap" gorm:"default:true"`
LOAD bool `json:"load" gorm:"default:true"`
UPTIME bool `json:"uptime" gorm:"default:true"`
TEMP bool `json:"temp" gorm:"default:true"`
OS bool `json:"os" gorm:"default:true"`
DISK bool `json:"disk" gorm:"default:true"`
NET bool `json:"net" gorm:"default:true"`
PROCESS bool `json:"process" gorm:"default:true"`
Connections bool `json:"connections" gorm:"default:true"`
Interval int `json:"interval" gorm:"default:3;check:Interval >= 1"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// ClientInfo stores static client information
// Deprecated: Use models.Client instead.
type ClientInfo struct {
UUID string `json:"uuid,omitempty" gorm:"type:varchar(36);primaryKey;foreignKey:ClientUUID;references:UUID;constraint:OnDelete:CASCADE"`
Name string `json:"name" gorm:"type:varchar(100);not null"`
CpuName string `json:"cpu_name" gorm:"type:varchar(100)"`
Virtualization string `json:"virtualization" gorm:"type:varchar(50)"`
Arch string `json:"arch" gorm:"type:varchar(50)"`
CpuCores int `json:"cpu_cores" gorm:"type:int"`
OS string `json:"os" gorm:"type:varchar(100)"`
GpuName string `json:"gpu_name" gorm:"type:varchar(100)"`
IPv4 string `json:"ipv4,omitempty" gorm:"type:varchar(100)"`
IPv6 string `json:"ipv6,omitempty" gorm:"type:varchar(100)"`
Region string `json:"region" gorm:"type:varchar(100)"`
Remark string `json:"remark,omitempty" gorm:"type:longtext"`
PublicRemark string `json:"public_remark,omitempty" gorm:"type:longtext"`
MemTotal int64 `json:"mem_total" gorm:"type:bigint"`
SwapTotal int64 `json:"swap_total" gorm:"type:bigint"`
DiskTotal int64 `json:"disk_total" gorm:"type:bigint"`
Version string `json:"version,omitempty" gorm:"type:varchar(100)"`
Weight int `json:"weight" gorm:"type:int"`
Price float64 `json:"price"`
BillingCycle int `json:"billing_cycle"`
ExpiredAt time.Time `json:"expired_at" gorm:"type:timestamp"`
CreatedAt time.Time
UpdatedAt time.Time
}
type IPAddress struct {
Ipv4 string `json:"ipv4"`
Ipv6 string `json:"ipv6"`
}
type Report struct {
UUID string `json:"uuid,omitempty"`
CPU CPUReport `json:"cpu"`
Ram RamReport `json:"ram"`
Swap RamReport `json:"swap"`
Load LoadReport `json:"load"`
Disk DiskReport `json:"disk"`
Network NetworkReport `json:"network"`
Connections ConnectionsReport `json:"connections"`
Uptime int64 `json:"uptime"`
Process int `json:"process"`
Message string `json:"message"`
Method string `json:"method,omitempty"`
UpdatedAt time.Time `json:"updated_at"`
}
type CPUReport struct {
Name string `json:"name,omitempty"`
Cores int `json:"cores,omitempty"`
Arch string `json:"arch,omitempty"`
Usage float64 `json:"usage,omitempty"`
}
type GPUReport struct {
Name string `json:"name,omitempty"`
Usage float64 `json:"usage,omitempty"`
}
type RamReport struct {
Total int64 `json:"total"`
Used int64 `json:"used"`
}
type LoadReport struct {
Load1 float64 `json:"load1"`
Load5 float64 `json:"load5"`
Load15 float64 `json:"load15"`
}
type DiskReport struct {
Total int64 `json:"total"`
Used int64 `json:"used"`
}
type NetworkReport struct {
Up int64 `json:"up"`
Down int64 `json:"down"`
TotalUp int64 `json:"totalUp"`
TotalDown int64 `json:"totalDown"`
}
type ConnectionsReport struct {
TCP int `json:"tcp"`
UDP int `json:"udp"`
}
+2 -2
View File
@@ -100,8 +100,8 @@ func CreateDefaultAdminAccount() (username, passwd string, err error) {
Username: username,
Passwd: hashedPassword,
SSOID: "",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
CreatedAt: models.FromTime(time.Now()),
UpdatedAt: models.FromTime(time.Now()),
}
err = db.Create(&user).Error
+35 -3
View File
@@ -2,11 +2,17 @@ package accounts
import (
"errors"
"fmt"
"net"
"time"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
messageevent "github.com/komari-monitor/komari/database/models/messageEvent"
"github.com/komari-monitor/komari/pkg/config"
"github.com/komari-monitor/komari/utils"
"github.com/komari-monitor/komari/utils/geoip"
"github.com/komari-monitor/komari/utils/messageSender"
)
// GetAllSessions 获取所有会话
@@ -27,12 +33,29 @@ func CreateSession(uuid string, expires int, userAgent, ip, login_method string)
sessionRecord := models.Session{
UUID: uuid,
Session: session,
Expires: time.Now().Add(time.Duration(expires) * time.Second),
Expires: models.FromTime(time.Now().Add(time.Duration(expires) * time.Second)),
UserAgent: userAgent,
Ip: ip,
LoginMethod: login_method,
LatestOnline: time.Now(),
LatestOnline: models.FromTime(time.Now()),
}
go func() {
LoginNotification, _ := config.GetAs[bool](config.LoginNotificationKey, false)
if LoginNotification {
ipAddr := net.ParseIP(ip)
ipinfo, _ := geoip.GetGeoInfo(ipAddr)
loc := "unknown"
if ipinfo != nil && ipinfo.Name != "" {
loc = ipinfo.Name
}
messageSender.SendEvent(models.EventMessage{
Event: messageevent.Login,
Time: time.Now(),
Message: fmt.Sprintf("%s: %s (%s)\n%s", login_method, ip, loc, userAgent),
Emoji: "🔑",
})
}
}()
err := db.Create(&sessionRecord).Error
if err != nil {
@@ -50,7 +73,7 @@ func GetSession(session string) (uuid string, err error) {
return "", err
}
if time.Now().After(sessionRecord.Expires) {
if time.Now().After(sessionRecord.Expires.ToTime()) {
// 会话已过期,删除它
_ = DeleteSession(session)
return "", errors.New("session expired")
@@ -110,3 +133,12 @@ func UpdateLatest(session, useragent, ip string) error {
"latest_ip": ip,
}).Error
}
func RemoveExpiredSessions() error {
db := dbcore.GetDBInstance()
result := db.Where("expires < ?", time.Now()).Delete(&models.Session{})
if result.Error != nil {
return result.Error
}
return nil
}
@@ -1,4 +1,4 @@
package logOperation
package auditlog
import (
"log"
@@ -16,11 +16,15 @@ func Log(ip, uuid, message, msgType string) {
UUID: uuid,
Message: message,
MsgType: msgType,
Time: now,
Time: models.FromTime(now),
}
db.Create(logEntry)
}
func EventLog(eventType, message string) {
Log("", "", message, eventType)
}
// Delete logs older than 30 days
func RemoveOldLogs() {
db := dbcore.GetDBInstance()
+97 -113
View File
@@ -1,29 +1,21 @@
package clients
import (
"encoding/json"
"fmt"
"log"
"math"
"time"
"github.com/komari-monitor/komari/common"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/database/tasks"
"github.com/komari-monitor/komari/utils"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"fmt"
"github.com/google/uuid"
)
// Deprecated: DeleteClientConfig is deprecated and will be removed in a future release. Use DeleteClient instead.
func DeleteClientConfig(clientUuid string) error {
db := dbcore.GetDBInstance()
err := db.Delete(&common.ClientConfig{ClientUUID: clientUuid}).Error
if err != nil {
return err
}
return nil
}
func DeleteClient(clientUuid string) error {
db := dbcore.GetDBInstance()
err := db.Delete(&models.Client{}, "uuid = ?", clientUuid).Error
@@ -33,79 +25,6 @@ func DeleteClient(clientUuid string) error {
return nil
}
// Deprecated: UpdateOrInsertBasicInfo is deprecated and will be removed in a future release. Use SaveClientInfo instead.
func UpdateOrInsertBasicInfo(cbi common.ClientInfo) error {
db := dbcore.GetDBInstance()
updates := make(map[string]interface{})
if cbi.Name != "" {
updates["name"] = cbi.Name
}
if cbi.CpuName != "" {
updates["cpu_name"] = cbi.CpuName
}
if cbi.Arch != "" {
updates["arch"] = cbi.Arch
}
if cbi.CpuCores != 0 {
updates["cpu_cores"] = cbi.CpuCores
}
if cbi.OS != "" {
updates["os"] = cbi.OS
}
if cbi.GpuName != "" {
updates["gpu_name"] = cbi.GpuName
}
if cbi.IPv4 != "" {
updates["ipv4"] = cbi.IPv4
}
if cbi.IPv6 != "" {
updates["ipv6"] = cbi.IPv6
}
if cbi.Region != "" {
updates["region"] = cbi.Region
}
if cbi.Remark != "" {
updates["remark"] = cbi.Remark
}
updates["mem_total"] = cbi.MemTotal
updates["swap_total"] = cbi.SwapTotal
updates["disk_total"] = cbi.DiskTotal
updates["version"] = cbi.Version
updates["updated_at"] = time.Now()
// 转换为更新Client表
client := models.Client{
UUID: cbi.UUID,
}
err := db.Model(&client).Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "uuid"}},
DoUpdates: clause.Assignments(updates),
}).Create(map[string]interface{}{
"uuid": cbi.UUID,
"name": cbi.Name,
"cpu_name": cbi.CpuName,
"arch": cbi.Arch,
"cpu_cores": cbi.CpuCores,
"os": cbi.OS,
"gpu_name": cbi.GpuName,
"ipv4": cbi.IPv4,
"ipv6": cbi.IPv6,
"region": cbi.Region,
"remark": cbi.Remark,
"mem_total": cbi.MemTotal,
"swap_total": cbi.SwapTotal,
"disk_total": cbi.DiskTotal,
"version": cbi.Version,
"updated_at": time.Now(),
}).Error
if err != nil {
return err
}
return nil
}
func SaveClientInfo(update map[string]interface{}) error {
db := dbcore.GetDBInstance()
clientUUID, ok := update["uuid"].(string)
@@ -120,17 +39,83 @@ func SaveClientInfo(update map[string]interface{}) error {
update["updated_at"] = time.Now()
err := db.Model(&models.Client{}).Where("uuid = ?", clientUUID).Updates(update).Error
if err != nil {
toFloat64 := func(value interface{}) (float64, bool) {
switch typed := value.(type) {
case float64:
return typed, true
case float32:
return float64(typed), true
case int:
return float64(typed), true
case int8:
return float64(typed), true
case int16:
return float64(typed), true
case int32:
return float64(typed), true
case int64:
return float64(typed), true
case uint:
return float64(typed), true
case uint8:
return float64(typed), true
case uint16:
return float64(typed), true
case uint32:
return float64(typed), true
case uint64:
return float64(typed), true
case json.Number:
parsed, err := typed.Float64()
if err != nil {
return 0, false
}
return parsed, true
default:
return 0, false
}
}
checkOptionalInt := func(name, key string, maxValue float64) error {
value, exists := update[key]
if !exists || value == nil {
return nil
}
numericValue, ok := toFloat64(value)
if !ok {
return fmt.Errorf("%s must be a valid number", name)
}
if numericValue < 0 || numericValue > maxValue {
return fmt.Errorf("%s must be a valid non-negative number: %v", name, value)
}
return nil
}
verify := func(update map[string]interface{}) error {
if err := checkOptionalInt("Cpu.Cores", "cpu_cores", math.MaxInt-1); err != nil {
return err
}
if err := checkOptionalInt("Cpu.PhysicalCores", "cpu_physical_cores", math.MaxInt-1); err != nil {
return err
}
if err := checkOptionalInt("Ram.Total", "mem_total", math.MaxInt64-1); err != nil {
return err
}
if err := checkOptionalInt("Swap.Total", "swap_total", math.MaxInt64-1); err != nil {
return err
}
if err := checkOptionalInt("Disk.Total", "disk_total", math.MaxInt64-1); err != nil {
return err
}
return nil
}
if err := verify(update); err != nil {
return err
}
return nil
}
// 更新客户端设置
func UpdateClientConfig(config common.ClientConfig) error {
db := dbcore.GetDBInstance()
err := db.Save(&config).Error
err := db.Model(&models.Client{}).Where("uuid = ?", clientUUID).Updates(update).Error
if err != nil {
return err
}
@@ -146,21 +131,6 @@ func EditClientName(clientUUID, clientName string) error {
return nil
}
/*
// UpdateClientByUUID 更新指定 UUID 的客户端配置
func UpdateClientByUUID(config common.ClientConfig) error {
db := dbcore.GetDBInstance()
result := db.Model(&common.ClientConfig{}).Where("client_uuid = ?", config.ClientUUID).Updates(config)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return gorm.ErrRecordNotFound
}
return nil
}
*/
func EditClientToken(clientUUID, token string) error {
db := dbcore.GetDBInstance()
err := db.Model(&models.Client{}).Where("uuid = ?", clientUUID).Update("token", token).Error
@@ -180,14 +150,17 @@ func CreateClient() (clientUUID, token string, err error) {
UUID: clientUUID,
Token: token,
Name: "client_" + clientUUID[0:8],
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
CreatedAt: models.FromTime(time.Now()),
UpdatedAt: models.FromTime(time.Now()),
}
err = db.Create(&client).Error
if err != nil {
return "", "", err
}
if err := tasks.AddDefaultOnClientUUID(clientUUID); err != nil {
log.Println("Failed to apply default-on ping tasks to new client:", err)
}
return clientUUID, token, nil
}
@@ -202,14 +175,17 @@ func CreateClientWithName(name string) (clientUUID, token string, err error) {
UUID: clientUUID,
Token: token,
Name: name,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
CreatedAt: models.FromTime(time.Now()),
UpdatedAt: models.FromTime(time.Now()),
}
err = db.Create(&client).Error
if err != nil {
return "", "", err
}
if err := tasks.AddDefaultOnClientUUID(clientUUID); err != nil {
log.Println("Failed to apply default-on ping tasks to new client:", err)
}
return clientUUID, token, nil
}
@@ -278,6 +254,14 @@ func SaveClient(updates map[string]interface{}) error {
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 {
return fmt.Errorf("traffic_limit must be a valid non-negative int64 value, got %v", val)
}
}
}
updates["updated_at"] = time.Now()
err := db.Model(&models.Client{}).Where("uuid = ?", clientUUID).Updates(updates).Error
+75 -107
View File
@@ -1,34 +1,14 @@
package clients
import (
"encoding/json"
"fmt"
"time"
"math"
"github.com/komari-monitor/komari/common"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
"gorm.io/gorm"
v1 "github.com/komari-monitor/komari/protocol/v1"
)
// Report 表示客户端报告数据
// SaveReport 保存报告数据
func SaveReport(uuid string, data map[string]interface{}) (err error) {
report, err := ParseReport(data)
if err != nil {
return err
}
err = SaveClientReport(uuid, report)
if err != nil {
return err
}
return nil
}
func GetClientUUIDByToken(token string) (clientUUID string, err error) {
db := dbcore.GetDBInstance()
var client models.Client
@@ -39,98 +19,86 @@ func GetClientUUIDByToken(token string) (clientUUID string, err error) {
return client.UUID, nil
}
func ParseReport(data map[string]interface{}) (report common.Report, err error) {
jsonData, err := json.Marshal(data)
if err != nil {
return common.Report{}, err
}
err = json.Unmarshal(jsonData, &report)
if err != nil {
return common.Report{}, err
}
return report, nil
}
// SaveClientReport 保存客户端报告到 Record 表
func SaveClientReport(clientUUID string, report common.Report) (err error) {
db := dbcore.GetDBInstance()
Record := models.Record{
Client: clientUUID,
Time: time.Now(),
Cpu: float32(report.CPU.Usage),
Gpu: 0, // Report 未提供 GPU Usage,设为 0(与原 nil 行为类似)
Ram: report.Ram.Used,
RamTotal: report.Ram.Total,
Swap: report.Swap.Used,
SwapTotal: report.Swap.Total,
Load: float32(report.Load.Load1), // 使用 Load1 作为主要负载指标
Temp: 0, // Report 未提供 TEMP,设为 0(与原 nil 行为类似)
Disk: report.Disk.Used,
DiskTotal: report.Disk.Total,
NetIn: report.Network.Down,
NetOut: report.Network.Up,
NetTotalUp: report.Network.TotalUp,
NetTotalDown: report.Network.TotalDown,
Process: report.Process,
Connections: report.Connections.TCP,
ConnectionsUdp: report.Connections.UDP,
// 检查数据防止异常数据导致数据库损坏
func ReportVerify(report v1.Report) error {
// 防止输入不合理范围
if report.CPU.Usage < 0 || report.CPU.Usage > 100 {
return fmt.Errorf("CPU.Usage must be between 0 and 100")
}
// 使用事务确保 Record 和 ClientsInfo 一致性
err = db.Transaction(func(tx *gorm.DB) error {
// 保存 Record
if err := tx.Create(&Record).Error; err != nil {
return fmt.Errorf("failed to save Record: %v", err)
if report.Load.Load1 < 0 || report.Load.Load1 > 1000 {
return fmt.Errorf("Load.Load1 must be non-negative, got %.2f", report.Load.Load1)
}
checkFloat64 := func(name string, val float64) error {
if val > math.MaxFloat64-1 || val < -math.MaxFloat64+1 {
return fmt.Errorf("%s value exceeds float64 range: %g", name, val)
}
return nil
})
}
if err != nil {
// [float64] 防止数据溢出
if err := checkFloat64("CPU.Usage", report.CPU.Usage); err != nil {
return err
}
if err := checkFloat64("Load.Load1", report.Load.Load1); err != nil {
return err
}
checkInt64 := func(name string, val int64) error {
if val < 0 {
return fmt.Errorf("%s must be non-negative, got %d", name, val)
}
if val > math.MaxInt64-1 {
return fmt.Errorf("%s exceeds int64 max limit: %d", name, val)
}
return nil
}
// [int64] 防止数据溢出
// Ram 验证
if err := checkInt64("Ram.Used", report.Ram.Used); err != nil {
return err
}
if err := checkInt64("Ram.Total", report.Ram.Total); err != nil {
return err
}
// Swap 验证
if err := checkInt64("Swap.Used", report.Swap.Used); err != nil {
return err
}
if err := checkInt64("Swap.Total", report.Swap.Total); err != nil {
return err
}
// Disk 验证
if err := checkInt64("Disk.Used", report.Disk.Used); err != nil {
return err
}
if err := checkInt64("Disk.Total", report.Disk.Total); err != nil {
return err
}
// Network 验证
if err := checkInt64("Network.Up", report.Network.Up); err != nil {
return err
}
if err := checkInt64("Network.Down", report.Network.Down); err != nil {
return err
}
if err := checkInt64("Network.TotalUp", report.Network.TotalUp); err != nil {
return err
}
if err := checkInt64("Network.TotalDown", report.Network.TotalDown); err != nil {
return err
}
// 拒绝所有负数Int
if report.Process < 0 {
return fmt.Errorf("Process must be non-negative: %d", report.Process)
}
if report.Connections.TCP < 0 {
return fmt.Errorf("Connections.TCP must be non-negative: %d", report.Connections.TCP)
}
if report.Connections.UDP < 0 {
return fmt.Errorf("Connections.UDP must be non-negative: %d", report.Connections.UDP)
}
return nil
}
/*
// getString 从 map 中获取字符串
func getString(data map[string]interface{}, key string) string {
if val, ok := data[key]; ok {
if str, ok := val.(string); ok {
return str
}
}
return ""
}
// getInt 从 map 中获取整数
func getInt(data map[string]interface{}, key string) int {
if val, ok := data[key]; ok {
if num, ok := val.(float64); ok {
return int(num)
}
}
return 0
}
// getInt64 从 map 中获取 int64
func getInt64(data map[string]interface{}, key string) int64 {
if val, ok := data[key]; ok {
if num, ok := val.(float64); ok {
return int64(num)
}
}
return 0
}
// getFloat 从 map 中获取 float64
func getFloat(data map[string]interface{}, key string) float64 {
if val, ok := data[key]; ok {
if num, ok := val.(float64); ok {
return num
}
}
return 0.0
}
*/
+28
View File
@@ -0,0 +1,28 @@
package clients
import (
"testing"
"github.com/komari-monitor/komari/utils"
"github.com/stretchr/testify/assert"
)
func TestComputeTrafficDeltaHandlesZeroAndReset(t *testing.T) {
tests := []struct {
name string
current int64
previous int64
want int64
}{
{name: "previous zero counts current delta", current: 120, previous: 0, want: 120},
{name: "monotonic counter uses difference", current: 250, previous: 200, want: 50},
{name: "counter reset uses current", current: 15, previous: 250, want: 15},
{name: "negative previous remains guarded", current: 15, previous: -1, want: 0},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
assert.Equal(t, test.want, utils.ComputeTrafficDelta(test.current, test.previous))
})
}
}
-102
View File
@@ -1,102 +0,0 @@
package config
import (
"errors"
"log"
"time"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/models"
"gorm.io/gorm"
)
func Get() (models.Config, error) {
db := dbcore.GetDBInstance()
var config models.Config
if err := db.First(&config).Error; err != nil {
if err == gorm.ErrRecordNotFound {
config = models.Config{
ID: 1,
Sitename: "Komari",
Description: "Komari Monitor, a simple server monitoring tool.",
AllowCors: false,
OAuthEnabled: false,
GeoIpEnabled: true,
GeoIpProvider: "mmdb",
UpdatedAt: time.Now(),
CreatedAt: time.Now(),
}
if err := db.Create(&config).Error; err != nil {
log.Fatal("Failed to create default config:", err)
}
return config, nil
}
return config, err
}
return config, nil
}
func Save(cst models.Config) error {
db := dbcore.GetDBInstance()
oldConfig, _ := Get()
// Only one records
cst.ID = 1
cst.UpdatedAt = time.Now()
// Do not update CreatedAt
if err := db.Model(&models.Config{}).Where("id = ?", cst.ID).
Select("sitename",
"description",
"allow_cors",
"geo_ip_enabled",
"geo_ip_provider",
"o_auth_client_id",
"o_auth_client_secret",
"o_auth_enabled",
"custom_head",
"updated_at").
Updates(cst).Error; err != nil {
return err
}
newConfig, _ := Get()
publishEvent(oldConfig, newConfig)
return nil
}
func Update(cst map[string]interface{}) error {
db := dbcore.GetDBInstance()
oldConfig, _ := Get()
// Proceed with update
cst["id"] = 1
cst["updated_at"] = time.Now()
delete(cst, "created_at")
delete(cst, "CreatedAt")
// 至少有一种登录方式启用
newDisablePasswordLogin := oldConfig.DisablePasswordLogin
newOAuthEnabled := oldConfig.OAuthEnabled
if val, exists := cst["disable_password_login"]; exists {
newDisablePasswordLogin = val.(bool)
}
if val, exists := cst["o_auth_enabled"]; exists {
newOAuthEnabled = val.(bool)
}
if newDisablePasswordLogin && !newOAuthEnabled {
return errors.New("at least one login method must be enabled (password/oauth)")
}
err := db.Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&models.Config{}).Where("id = ?", oldConfig.ID).Updates(cst).Error; err != nil {
return errors.Join(err, errors.New("failed to update configuration"))
}
newConfig := &models.Config{}
if err := tx.Where("id = ?", oldConfig.ID).First(newConfig).Error; err != nil {
return errors.Join(err, errors.New("failed to retrieve updated configuration"))
}
publishEvent(oldConfig, *newConfig)
return nil
})
if err != nil {
return err
}
return nil
}
-38
View File
@@ -1,38 +0,0 @@
package config
import (
"sync"
"github.com/komari-monitor/komari/database/models"
)
// ConfigEvent represents a configuration change event
type ConfigEvent struct {
Old models.Config
New models.Config
}
// ConfigSubscriber handles config events
type ConfigSubscriber func(event ConfigEvent)
var (
subscribersMu sync.RWMutex
subscribers []ConfigSubscriber
)
// Subscribe registers a subscriber for all config events.
func Subscribe(subscriber ConfigSubscriber) {
subscribersMu.Lock()
defer subscribersMu.Unlock()
subscribers = append(subscribers, subscriber)
}
// publishEvent notifies all subscribers of a config change.
func publishEvent(oldVal, newVal models.Config) {
subscribersMu.RLock()
defer subscribersMu.RUnlock()
event := ConfigEvent{Old: oldVal, New: newVal}
for _, sub := range subscribers {
go sub(event)
}
}
+432 -131
View File
@@ -1,165 +1,466 @@
package dbcore
import (
"archive/zip"
"fmt"
"io"
"log"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/komari-monitor/komari/cmd/flags"
"github.com/komari-monitor/komari/common"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/utils"
"gorm.io/driver/mysql"
"github.com/komari-monitor/komari/pkg/config"
"github.com/komari-monitor/komari/pkg/migrations"
logutil "github.com/komari-monitor/komari/utils/log"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
// mergeClientInfo 将旧版ClientInfo数据迁移到新版Client表
func mergeClientInfo(db *gorm.DB) {
var clientInfos []common.ClientInfo
if err := db.Find(&clientInfos).Error; err != nil {
log.Printf("Failed to read ClientInfo table: %v", err)
return
// zipDirectoryExcluding 将 srcDir 打包为 dstZip,exclude 是绝对路径集合需要排除
func zipDirectoryExcluding(srcDir, dstZip string, exclude map[string]struct{}) error {
// 规范化排除路径为绝对路径
normExclude := make(map[string]struct{}, len(exclude))
for p := range exclude {
abs, _ := filepath.Abs(p)
normExclude[abs] = struct{}{}
}
for _, info := range clientInfos {
var client models.Client
if err := db.Where("uuid = ?", info.UUID).First(&client).Error; err != nil {
log.Printf("Could not find Client record with UUID %s: %v", info.UUID, err)
continue
out, err := os.Create(dstZip)
if err != nil {
return err
}
defer out.Close()
zw := zip.NewWriter(out)
defer zw.Close()
absSrc, _ := filepath.Abs(srcDir)
walkErr := filepath.Walk(absSrc, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
// 更新Client记录
client.Name = info.Name
client.CpuName = info.CpuName
client.Virtualization = info.Virtualization
client.Arch = info.Arch
client.CpuCores = info.CpuCores
client.OS = info.OS
client.GpuName = info.GpuName
client.IPv4 = info.IPv4
client.IPv6 = info.IPv6
client.Region = info.Region
client.Remark = info.Remark
client.PublicRemark = info.PublicRemark
client.MemTotal = info.MemTotal
client.SwapTotal = info.SwapTotal
client.DiskTotal = info.DiskTotal
client.Version = info.Version
client.Weight = info.Weight
client.Price = info.Price
client.BillingCycle = info.BillingCycle
client.ExpiredAt = info.ExpiredAt
// Save updated Client record
if err := db.Save(&client).Error; err != nil {
log.Printf("Failed to update Client record: %v", err)
continue
// 排除 backup.zip 本身
if _, ok := normExclude[path]; ok {
if info.IsDir() {
return filepath.SkipDir
}
return nil
}
}
// 计算 zip 内相对路径
rel, err := filepath.Rel(absSrc, path)
if err != nil {
return err
}
// 根目录跳过
if rel == "." {
return nil
}
// 替换为正斜杠
zipName := filepath.ToSlash(rel)
// Backup and rename old table after migration
if err := db.Migrator().RenameTable("client_infos", "client_infos_backup"); err != nil {
log.Printf("Failed to backup ClientInfo table: %v", err)
return
if info.IsDir() {
_, err := zw.Create(zipName + "/")
return err
}
// 普通文件
fh, err := os.Open(path)
if err != nil {
return err
}
w, err := zw.Create(zipName)
if err != nil {
fh.Close()
return err
}
if _, err := io.Copy(w, fh); err != nil {
fh.Close()
return err
}
fh.Close()
return nil
})
if walkErr != nil {
return walkErr
}
log.Println("Data migration completed, old table has been backed up as client_infos_backup")
return zw.Close()
}
func MergeDatabase(db *gorm.DB) {
if db.Migrator().HasTable("client_infos") {
log.Println("[>0.0.5] Legacy ClientInfo table detected, starting data migration...")
mergeClientInfo(db)
// removeAllInDirExcept 删除 dir 下除 exclude 指定绝对路径外的所有文件和文件夹
func removeAllInDirExcept(dir string, exclude map[string]struct{}) error {
absDir, err := filepath.Abs(dir)
if err != nil {
return err
}
if db.Migrator().HasColumn(&models.Config{}, "allow_cros") {
log.Println("[>0.0.5a] Renaming column 'allow_cros' to 'allow_cors' in config table...")
db.Migrator().RenameColumn(&models.Config{}, "allow_cros", "allow_cors")
normExclude := make(map[string]struct{}, len(exclude))
for p := range exclude {
abs, _ := filepath.Abs(p)
normExclude[abs] = struct{}{}
}
entries, err := os.ReadDir(absDir)
if err != nil {
return err
}
for _, e := range entries {
full := filepath.Join(absDir, e.Name())
if _, ok := normExclude[full]; ok {
continue
}
if err := os.RemoveAll(full); err != nil {
return err
}
}
return nil
}
// unzipToDir 将 zipPath 解压到 dstDir,包含路径遍历保护
func unzipToDir(zipPath, dstDir string) error {
zr, err := zip.OpenReader(zipPath)
if err != nil {
return err
}
defer zr.Close()
if err := os.MkdirAll(dstDir, 0755); err != nil {
return err
}
absDst, _ := filepath.Abs(dstDir)
for _, f := range zr.File {
// 构造目标路径并做路径遍历保护
cleanName := filepath.Clean(f.Name)
targetPath := filepath.Join(absDst, cleanName)
if !strings.HasPrefix(targetPath, absDst+string(os.PathSeparator)) && targetPath != absDst {
return fmt.Errorf("illegal file path in zip: %s", f.Name)
}
if f.FileInfo().IsDir() {
if err := os.MkdirAll(targetPath, 0755); err != nil {
return err
}
continue
}
if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil {
return err
}
rc, err := f.Open()
if err != nil {
return err
}
out, err := os.Create(targetPath)
if err != nil {
rc.Close()
return err
}
if _, err := io.Copy(out, rc); err != nil {
out.Close()
rc.Close()
return err
}
out.Close()
rc.Close()
}
return nil
}
var (
instance *gorm.DB
once sync.Once
initErr error
)
func GetDBInstance() *gorm.DB {
// SystemVersionKey 是记录“上次启动所用版本标识”的配置键(存于 configs 表)。
// 取代旧的 ./data/.komari-version 文件:版本标识随配置库一起备份/恢复,
// 也避免额外的裸文件依赖。
const SystemVersionKey = "system_version"
// versionID 是当前构建的版本标识,由 SetVersionID 在 Initialize 前注入。
var versionID string
// dbFileExistedAtStartup 记录本次进程启动、打开数据库之前 komari.db 是否已存在,
// 用于区分“全新安装”与“从旧版升级(无版本标记)”。在 doInitialize 打开数据库
// 之前采集。
var dbFileExistedAtStartup bool
// SetVersionID 设置当前构建的版本标识(通常为 CurrentVersion+"-"+VersionHash),
// 用于版本升级检测与自动备份。应在 Initialize() 之前调用;为空则跳过升级备份。
func SetVersionID(id string) {
versionID = id
}
// resolveDatabaseFile 返回当前使用的 SQLite 数据库文件路径。
func resolveDatabaseFile() string {
dbFile := flags.DatabaseFile
if dbFile == "" {
dbFile = "./data/komari.db"
}
return dbFile
}
// backupOnVersionUpgrade 在检测到版本升级时,把当前 ./data 打包到
// ./backup/upgrade-{time}.zip,便于升级(含 metrics 迁移)异常时回滚。
//
// 版本标识存放于配置库(configs 表,键 system_version),因此本函数必须在
// config.SetDb 之后、一次性 metrics 迁移(InitStores)之前调用。
//
// 触发规则:
// - versionID 为空:跳过(未注入版本,如部分测试场景)。
// - 配置中无版本且启动前无数据库文件:全新安装,仅写版本,不备份。
// - 配置中无版本但启动前已有数据库文件:从无版本标记的旧稳定版升级,备份。
// - 配置中版本与当前不同:版本升级,备份。
// - 配置中版本与当前一致:无需备份。
//
// 备份失败不阻止启动,但打印明确错误;备份成功(或无需备份)后写入/更新版本。
func backupOnVersionUpgrade() {
if versionID == "" {
return
}
prevVersion, readErr := config.GetAs[string](SystemVersionKey)
prevVersion = strings.TrimSpace(prevVersion)
versionRecorded := readErr == nil && prevVersion != ""
// 版本未变化,无需备份。
if versionRecorded && prevVersion == versionID {
return
}
// 全新安装:配置中无版本且启动前无数据库文件,直接写版本不备份。
if !versionRecorded && !dbFileExistedAtStartup {
writeVersionMarker()
return
}
// 需要备份(升级或从旧稳定版首次带版本标记启动)。
// 先做一次 WAL checkpoint,确保 komari.db 主文件包含最新数据,
// 避免备份出的库缺少仍留在 -wal 中的写入。
if instance != nil {
instance.Exec("PRAGMA wal_checkpoint(TRUNCATE);")
}
if err := os.MkdirAll("./backup", 0755); err != nil {
log.Printf("[upgrade-backup] failed to create backup dir: %v", err)
return
}
tsName := time.Now().Format("20060102-150405")
bakPath := filepath.Join("./backup", fmt.Sprintf("upgrade-%s.zip", tsName))
backupZipPath := filepath.Join(".", "data", "backup.zip")
if zipErr := zipDirectoryExcluding("./data", bakPath, map[string]struct{}{backupZipPath: {}}); zipErr != nil {
log.Printf("[upgrade-backup] failed to backup ./data before upgrade (from %q to %q): %v", prevVersion, versionID, zipErr)
return
}
log.Printf("[upgrade-backup] ./data backed up to %s before upgrade (from %q to %q)", bakPath, prevVersion, versionID)
writeVersionMarker()
}
// writeVersionMarker 将当前 versionID 写入配置库。
func writeVersionMarker() {
if err := config.Set(SystemVersionKey, versionID); err != nil {
log.Printf("[upgrade-backup] failed to persist version marker: %v", err)
}
}
func buildSQLiteDSN(databaseFile string) string {
if databaseFile == "" {
databaseFile = "./data/komari.db"
}
params := "_busy_timeout=5000&_txlock=immediate&_journal_mode=WAL&_synchronous=NORMAL"
separator := "?"
if strings.Contains(databaseFile, "?") {
separator = "&"
}
if strings.HasPrefix(databaseFile, "file:") {
return databaseFile + separator + params
}
if databaseFile == ":memory:" {
return "file::memory:?cache=shared&" + params
}
return "file:" + filepath.ToSlash(databaseFile) + separator + params
}
// Initialize 显式初始化数据库连接与表结构,仅执行一次。
// 与 GetDBInstance 不同,Initialize 返回错误而非直接退出进程,
// 便于启动生命周期统一处理错误、以及在测试/CLI 命令中做隔离。
func Initialize() error {
once.Do(func() {
var err error
logConfig := &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
}
if utils.VersionHash == "unknown" {
logConfig = &gorm.Config{
Logger: logger.Default.LogMode(logger.Info),
}
}
// 根据数据库类型选择不同的连接方式
switch flags.DatabaseType {
case "sqlite", "":
// SQLite 连接
instance, err = gorm.Open(sqlite.Open(flags.DatabaseFile), logConfig)
if err != nil {
log.Fatalf("Failed to connect to SQLite3 database: %v", err)
}
log.Printf("Using SQLite database file: %s", flags.DatabaseFile)
case "mysql":
// MySQL 连接
dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&collation=utf8mb4_unicode_ci&parseTime=True&loc=Local",
flags.DatabaseUser,
flags.DatabasePass,
flags.DatabaseHost,
flags.DatabasePort,
flags.DatabaseName)
instance, err = gorm.Open(mysql.Open(dsn), logConfig)
if err != nil {
log.Fatalf("Failed to connect to MySQL database: %v", err)
}
log.Printf("Using MySQL database: %s@%s:%s/%s", flags.DatabaseUser, flags.DatabaseHost, flags.DatabasePort, flags.DatabaseName)
default:
log.Fatalf("Unsupported database type: %s", flags.DatabaseType)
}
MergeDatabase(instance)
// 自动迁移模型
err = instance.AutoMigrate(
&models.User{},
&models.Client{},
&models.Record{},
&models.Config{},
&models.Log{},
&models.Clipboard{},
&models.LoadNotification{},
&models.OfflineNotification{},
&models.PingRecord{},
&models.PingTask{},
)
if err != nil {
log.Fatalf("Failed to create tables: %v", err)
}
err = instance.Table("records_long_term").AutoMigrate(
&models.Record{},
)
if err != nil {
log.Printf("Failed to create records_long_term table, it may already exist: %v", err)
}
err = instance.AutoMigrate(
&models.Session{},
)
if err != nil {
log.Printf("Failed to create Session table, it may already exist: %v", err)
}
err = instance.AutoMigrate(
&models.Task{},
&models.TaskResult{},
)
if err != nil {
log.Printf("Failed to create Task and TaskResult table, it may already exist: %v", err)
}
initErr = doInitialize()
})
return initErr
}
// GetDBInstance 返回全局数据库实例。
// 为兼容既有大量调用点,这里保留“出错即退出”的语义;
// 需要错误处理的启动流程应优先调用 Initialize()。
func GetDBInstance() *gorm.DB {
if err := Initialize(); err != nil {
log.Fatalf("Failed to initialize database: %v", err)
}
return instance
}
// Close 关闭底层数据库连接,供关闭流程调用。
func Close() error {
if instance == nil {
return nil
}
sqlDB, err := instance.DB()
if err != nil {
return err
}
return sqlDB.Close()
}
func doInitialize() error {
var err error
// 在数据库初始化前执行:如果存在 ./data/backup.zip,则进行恢复逻辑
func() {
backupZipPath := filepath.Join(".", "data", "backup.zip")
if _, statErr := os.Stat(backupZipPath); statErr == nil {
// 4. 把除了 ./data/backup.zip 之外的所有文件压缩到 ./backup/{time}.zip
if err := os.MkdirAll("./backup", 0755); err != nil {
log.Printf("[restore] failed to create backup dir: %v", err)
} else {
tsName := time.Now().Format("20060102-150405")
bakPath := filepath.Join("./backup", fmt.Sprintf("%s.zip", tsName))
if zipErr := zipDirectoryExcluding("./data", bakPath, map[string]struct{}{backupZipPath: {}}); zipErr != nil {
log.Printf("[restore] failed to zip current data: %v", zipErr)
} else {
log.Printf("[restore] current data zipped to %s", bakPath)
}
}
// 5. 删除除了 ./data/backup.zip 之外的所有文件
if delErr := removeAllInDirExcept("./data", map[string]struct{}{backupZipPath: {}}); delErr != nil {
log.Printf("[restore] failed to cleanup data dir: %v", delErr)
}
// 6. 解压 ./data/backup.zip 到 ./data
if unzipErr := unzipToDir(backupZipPath, "./data"); unzipErr != nil {
log.Printf("[restore] failed to unzip backup into data: %v", unzipErr)
} else {
log.Printf("[restore] backup.zip extracted to ./data")
}
// 7. 删除 ./data/backup.zip
if rmErr := os.Remove(backupZipPath); rmErr != nil {
log.Printf("[restore] failed to remove backup.zip: %v", rmErr)
} else {
log.Printf("[restore] backup.zip removed")
}
// 8. 删除标记
if rmErr := os.Remove("./data/komari-backup-markup"); rmErr != nil {
log.Printf("[restore] failed to remove komari-backup-markup: %v", rmErr)
} else {
log.Printf("[restore] komari-backup-markup removed")
}
}
}()
// 记录“打开数据库之前”komari.db 是否已存在,用于区分全新安装与旧版升级。
// 必须在(可能的)恢复逻辑之后、gorm.Open 之前采集:恢复会解压出旧库,
// gorm.Open 会创建空库。
if _, statErr := os.Stat(resolveDatabaseFile()); statErr == nil {
dbFileExistedAtStartup = true
}
logConfig := &gorm.Config{
Logger: logutil.NewGormLogger(),
}
// 根据数据库类型选择不同的连接方式
switch flags.ApplyDatabaseTypeNormalization() {
case flags.DatabaseTypeSQLite:
// SQLite 连接
// 通过 DSN 传入 _busy_timeout / _txlock 等参数,确保连接池中的每一条连接
// 都生效:
// - _busy_timeout=5000:遇到写锁时最多等待 5s 再返回,避免瞬时
// "database is locked" 直接失败(仅靠后续 PRAGMA Exec 只作用于
// 当时执行该语句的单条连接,池内其它连接不生效)。
// - _txlock=immediate:事务一开始即获取写锁,避免「先 SELECT 后写」
// 的锁升级在并发写入下产生死锁式的立即 SQLITE_BUSY。
// - _journal_mode=WAL / _synchronous=NORMAL:与下方 PRAGMA 保持一致,
// 在 DSN 层为所有连接预设。
dsn := buildSQLiteDSN(flags.DatabaseFile)
instance, err = gorm.Open(sqlite.Open(dsn), logConfig)
if err != nil {
return fmt.Errorf("failed to connect to SQLite3 database: %w", err)
}
log.Printf("Using SQLite database file: %s", flags.DatabaseFile)
if sqlDB, dbErr := instance.DB(); dbErr == nil {
// SQLite 同一时刻只允许一个写者;限制连接数可避免连接池层面的写写竞争。
// 负载历史每分钟会执行包含读和写的事务,若连接池允许多个连接,容易与
// ping 结果等短写入撞锁并导致整批负载记录回滚。
sqlDB.SetMaxOpenConns(1)
sqlDB.SetMaxIdleConns(1)
sqlDB.SetConnMaxLifetime(0)
} else {
log.Printf("Failed to access underlying sql.DB for SQLite tuning: %v", dbErr)
}
instance.Exec("PRAGMA wal = ON;")
if err := instance.Exec("PRAGMA journal_mode = WAL;").Error; err != nil {
log.Printf("Failed to enable WAL mode for SQLite: %v", err)
}
instance.Exec("PRAGMA synchronous = NORMAL;")
instance.Exec("PRAGMA busy_timeout = 5000;")
instance.Exec("PRAGMA cache_size = -65536;")
instance.Exec("PRAGMA temp_store = MEMORY;")
instance.Exec("PRAGMA wal_checkpoint(TRUNCATE);")
default:
return fmt.Errorf("unsupported database type: %s (supported: %s)", flags.DatabaseType, flags.SupportedDatabaseTypes())
}
if err := migrations.Run(migrations.Context{DB: instance}); err != nil {
return fmt.Errorf("failed to run startup migrations: %w", err)
}
config.SetDb(instance)
// 配置库就绪后、执行后续 AutoMigrate 与一次性 metrics 迁移之前:
// 基于配置中的版本标记检测升级并自动备份 ./data,便于回滚。
backupOnVersionUpgrade()
// 自动迁移模型
//
// 注意:负载/GPU/ping 历史监控数据运行期全部走 metric store(默认 SQLite
// ./data/metrics.db,或配置的 MySQL/PostgreSQL)。旧的 records /
// records_long_term / gpu_records / ping_records 表不再建表、不再写入。
// 若升级时旧表仍存在,会在 pkg/migrations.RunMetricStoreMigrations 中先导入再清理。
// models.Record / models.PingRecord / models.GPURecord 结构体仍作为
// metric store 的读写 DTO 和旧表导入 DTO 保留在 models 包中。
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{},
)
if err != nil {
return fmt.Errorf("failed to create tables: %w", err)
}
if err := instance.AutoMigrate(
&models.Session{},
); err != nil {
log.Printf("Failed to create Session table, it may already exist: %v", err)
}
if err := instance.AutoMigrate(
&models.Task{},
&models.TaskResult{},
); err != nil {
log.Printf("Failed to create Task and TaskResult table, it may already exist: %v", err)
}
return nil
}
+92
View File
@@ -0,0 +1,92 @@
package dbcore
import (
"context"
"database/sql"
"errors"
"fmt"
"os"
"strings"
"sync"
"github.com/komari-monitor/komari/cmd/flags"
)
var maintenanceMu sync.Mutex
// StorageSize returns the bytes occupied by the main SQLite database and its
// WAL/SHM sidecar files.
func StorageSize() (int64, error) {
if !flags.IsSQLite() {
return 0, errors.New("main database size is only available for SQLite")
}
return sqliteFileSetSize(resolveDatabaseFile())
}
// ReclaimSpace checkpoints the main database WAL and rewrites the SQLite file.
func ReclaimSpace(ctx context.Context) error {
if !flags.IsSQLite() {
return errors.New("main database maintenance is only supported for SQLite")
}
maintenanceMu.Lock()
defer maintenanceMu.Unlock()
db, err := GetDBInstance().DB()
if err != nil {
return fmt.Errorf("get main database connection: %w", err)
}
if err := checkpointSQLiteWAL(ctx, db); err != nil {
return err
}
if _, err := db.ExecContext(ctx, "VACUUM"); err != nil {
return fmt.Errorf("vacuum main database: %w", err)
}
return checkpointSQLiteWAL(ctx, db)
}
func checkpointSQLiteWAL(ctx context.Context, db *sql.DB) error {
var busy, logFrames, checkpointedFrames int
if err := db.QueryRowContext(ctx, "PRAGMA wal_checkpoint(TRUNCATE)").Scan(&busy, &logFrames, &checkpointedFrames); err != nil {
return fmt.Errorf("checkpoint main database WAL: %w", err)
}
if busy != 0 {
return fmt.Errorf(
"checkpoint main database WAL: database is busy (%d log frames, %d checkpointed)",
logFrames,
checkpointedFrames,
)
}
return nil
}
func sqliteFileSetSize(dsn string) (int64, error) {
path := strings.TrimPrefix(strings.TrimSpace(dsn), "file:")
if index := strings.IndexByte(path, '?'); index >= 0 {
path = path[:index]
}
if path == "" || path == ":memory:" || strings.Contains(strings.ToLower(dsn), "mode=memory") {
return 0, errors.New("database is not backed by a local file")
}
var total int64
foundDatabase := false
for _, suffix := range []string{"", "-wal", "-shm"} {
info, err := os.Stat(path + suffix)
switch {
case err == nil:
total += info.Size()
if suffix == "" {
foundDatabase = true
}
case errors.Is(err, os.ErrNotExist):
continue
default:
return 0, fmt.Errorf("stat database file %q: %w", path+suffix, err)
}
}
if !foundDatabase {
return 0, fmt.Errorf("database file %q does not exist", path)
}
return total, nil
}
+37
View File
@@ -0,0 +1,37 @@
package dbcore
import (
"os"
"path/filepath"
"testing"
)
func TestSQLiteFileSetSizeIncludesWALAndSHM(t *testing.T) {
databasePath := filepath.Join(t.TempDir(), "komari.db")
files := map[string]int{
databasePath: 11,
databasePath + "-wal": 7,
databasePath + "-shm": 5,
}
for path, size := range files {
if err := os.WriteFile(path, make([]byte, size), 0o600); err != nil {
t.Fatalf("write fixture %s: %v", path, err)
}
}
got, err := sqliteFileSetSize("file:" + filepath.ToSlash(databasePath) + "?mode=rwc")
if err != nil {
t.Fatalf("sqliteFileSetSize: %v", err)
}
if got != 23 {
t.Fatalf("size = %d, want 23", got)
}
}
func TestSQLiteFileSetSizeRejectsNonFileDatabase(t *testing.T) {
for _, dsn := range []string{":memory:", "file:metrics?MODE=MEMORY&cache=shared"} {
if _, err := sqliteFileSetSize(dsn); err == nil {
t.Fatalf("sqliteFileSetSize(%q) should fail", dsn)
}
}
}
+29
View File
@@ -0,0 +1,29 @@
package database
import (
"github.com/komari-monitor/komari/database/dbcore"
"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
if err := db.Where("name = ?", name).First(&config).Error; err != nil {
return nil, err
}
return &config, nil
}
func SaveMessageSenderConfig(config *models.MessageSenderProvider) error {
db := dbcore.GetDBInstance()
return db.Save(config).Error
}
+95
View File
@@ -0,0 +1,95 @@
package metricstore
import (
"context"
"errors"
"fmt"
"github.com/komari-monitor/komari/pkg/metric"
)
var (
ErrStoreNotInitialized = errors.New("metric store not initialized")
ErrStoreBusy = errors.New("metric store is busy")
)
// StorageInfo describes the physical storage owned by the active metric store.
// Size remains useful even when the store points at an external database because
// pkg/metric limits its query to the three tables managed by this Store.
type StorageInfo struct {
Driver metric.Driver
Action metric.MaintenanceAction
Size int64
}
// MaintenanceResult keeps measurement failures separate from the maintenance
// error. A database may allow table maintenance while denying catalog queries,
// and callers should still be able to report that the operation succeeded.
type MaintenanceResult struct {
Driver metric.Driver
Action metric.MaintenanceAction
Before int64
After int64
BeforeSizeError error
AfterSizeError error
}
// InspectStorage reads physical storage information while preventing a store
// reload from closing the active connection underneath the query.
func InspectStorage(ctx context.Context) (StorageInfo, error) {
if err := storeOperations.Acquire(ctx); err != nil {
return StorageInfo{}, fmt.Errorf("wait for metric store operations before inspection: %w", err)
}
defer storeOperations.Release()
storeMu.RLock()
activeStore := store
storeMu.RUnlock()
if activeStore == nil {
return StorageInfo{}, ErrStoreNotInitialized
}
info := StorageInfo{
Driver: activeStore.Driver(),
Action: activeStore.MaintenanceAction(),
}
size, err := activeStore.StorageSize(ctx)
info.Size = size
return info, err
}
// ReclaimSpace performs the driver-specific physical maintenance operation.
// It shares the exclusive operation lock with Compact so retention compaction
// and a table/file rewrite can never run against the metric store concurrently.
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
}
defer storeOperations.Release()
storeMu.RLock()
activeStore := store
storeMu.RUnlock()
if activeStore == nil {
return MaintenanceResult{}, ErrStoreNotInitialized
}
result := MaintenanceResult{
Driver: activeStore.Driver(),
Action: activeStore.MaintenanceAction(),
}
result.Before, result.BeforeSizeError = activeStore.StorageSize(ctx)
maintenanceErr := activeStore.ReclaimSpace(ctx)
result.After, result.AfterSizeError = activeStore.StorageSize(ctx)
return result, maintenanceErr
}
+167
View File
@@ -0,0 +1,167 @@
package metricstore
import (
"context"
"errors"
"testing"
"time"
"github.com/komari-monitor/komari/pkg/metric"
)
func TestInspectAndReclaimStorage(t *testing.T) {
ctx := context.Background()
s, err := metric.Open(ctx, metric.SQLite(":memory:"))
if err != nil {
t.Fatalf("open metric store: %v", err)
}
installTestStore(t, s)
info, err := InspectStorage(ctx)
if err != nil {
t.Fatalf("inspect storage: %v", err)
}
if info.Driver != metric.DriverSQLite || info.Action != metric.MaintenanceVacuum {
t.Fatalf("unexpected storage info: %#v", info)
}
if info.Size != 0 {
t.Fatalf("in-memory storage size = %d, want 0", info.Size)
}
result, err := ReclaimSpace(ctx)
if err != nil {
t.Fatalf("reclaim space: %v", err)
}
if result.Driver != metric.DriverSQLite || result.Action != metric.MaintenanceVacuum {
t.Fatalf("unexpected maintenance result: %#v", result)
}
if result.BeforeSizeError != nil || result.AfterSizeError != nil {
t.Fatalf("unexpected size errors: before=%v after=%v", result.BeforeSizeError, result.AfterSizeError)
}
}
func TestReclaimSpaceReportsBusyStore(t *testing.T) {
s, err := metric.Open(context.Background(), metric.SQLite(":memory:"))
if err != nil {
t.Fatalf("open metric store: %v", err)
}
installTestStore(t, s)
if !storeOperations.TryAcquire() {
t.Fatal("acquire store operation gate")
}
defer storeOperations.Release()
result, err := ReclaimSpace(context.Background())
if !errors.Is(err, ErrStoreBusy) {
t.Fatalf("reclaim error = %v, want %v", err, ErrStoreBusy)
}
if result.Driver != metric.DriverSQLite || result.Action != metric.MaintenanceVacuum {
t.Fatalf("busy 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 _, err := Compact(context.Background(), time.Now()); !errors.Is(err, ErrCompactInProgress) {
t.Fatalf("compact error = %v, want %v", err, ErrCompactInProgress)
}
}
func TestInspectStorageRequiresInitializedStore(t *testing.T) {
storeMu.Lock()
previous := store
store = nil
storeMu.Unlock()
t.Cleanup(func() {
storeMu.Lock()
store = previous
storeMu.Unlock()
})
if _, err := InspectStorage(context.Background()); !errors.Is(err, ErrStoreNotInitialized) {
t.Fatalf("inspect error = %v, want %v", err, ErrStoreNotInitialized)
}
}
func TestCloseStoreContextCancelsMigrationBeforeTakingStoreLock(t *testing.T) {
s, err := metric.Open(context.Background(), metric.SQLite(":memory:"))
if err != nil {
t.Fatalf("open metric store: %v", err)
}
installTestStore(t, s)
migrationCtx, cancelMigration := context.WithCancel(context.Background())
done := make(chan struct{})
if !storeOperations.TryAcquire() {
t.Fatal("acquire store operation gate")
}
storeMigMu.Lock()
storeMigCancel = cancelMigration
storeMigDone = done
storeMigMu.Unlock()
go func() {
<-migrationCtx.Done()
storeOperations.Release()
storeMigMu.Lock()
storeMigCancel = nil
storeMigDone = nil
close(done)
storeMigMu.Unlock()
}()
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
if err := CloseStoreContext(ctx); err != nil {
t.Fatalf("close store: %v", err)
}
if migrationCtx.Err() == nil {
t.Fatal("store migration was not canceled before close")
}
if err := Reload(context.Background()); !errors.Is(err, ErrStoreBusy) {
t.Fatalf("reload after close error = %v, want %v", err, ErrStoreBusy)
}
}
func TestStoreOperationWaitsRespectContext(t *testing.T) {
if !storeOperations.TryAcquire() {
t.Fatal("acquire store operation gate")
}
defer storeOperations.Release()
ctx, cancel := context.WithCancel(context.Background())
cancel()
if _, err := InspectStorage(ctx); !errors.Is(err, context.Canceled) {
t.Fatalf("inspect error = %v, want context canceled", err)
}
if err := Reload(ctx); !errors.Is(err, context.Canceled) {
t.Fatalf("reload error = %v, want context canceled", err)
}
if err := CloseStoreContext(ctx); !errors.Is(err, context.Canceled) {
t.Fatalf("close error = %v, want context canceled", err)
}
}
func installTestStore(t *testing.T, s *metric.Store) {
t.Helper()
storeMigMu.Lock()
previousClosing := storeClosing
storeClosing = false
storeMigMu.Unlock()
storeMu.Lock()
previous := store
previousFingerprint := storeFingerprint
store = s
storeFingerprint = "test|memory"
storeMu.Unlock()
t.Cleanup(func() {
storeMigMu.Lock()
storeClosing = previousClosing
storeMigMu.Unlock()
storeMu.Lock()
store = previous
storeFingerprint = previousFingerprint
storeMu.Unlock()
_ = s.Close()
})
}
File diff suppressed because it is too large. Load diff
+260
View File
@@ -0,0 +1,260 @@
package metricstore
import (
"context"
"testing"
"time"
"github.com/komari-monitor/komari/database/models"
"github.com/komari-monitor/komari/pkg/metric"
)
func TestDefaultRollupPolicy(t *testing.T) {
policy := defaultRollupPolicy(30)
if err := policy.Validate(); err != nil {
t.Fatalf("default rollup policy should validate: %v", err)
}
if policy.RawRetention != DefaultRollupRawRetention {
t.Fatalf("raw retention = %s, want %s", policy.RawRetention, DefaultRollupRawRetention)
}
if len(policy.Tiers) != 3 {
t.Fatalf("expected 3 rollup tiers, got %d", len(policy.Tiers))
}
wantIntervals := []time.Duration{time.Minute, 5 * time.Minute, time.Hour}
wantRetentions := []time.Duration{48 * time.Hour, 14 * 24 * time.Hour, 30 * 24 * time.Hour}
for i := range wantIntervals {
if policy.Tiers[i].Interval != wantIntervals[i] {
t.Fatalf("tier %d interval = %s, want %s", i, policy.Tiers[i].Interval, wantIntervals[i])
}
if policy.Tiers[i].Retention != wantRetentions[i] {
t.Fatalf("tier %d retention = %s, want %s", i, policy.Tiers[i].Retention, wantRetentions[i])
}
}
}
func TestBuildMetricConfigEnablesDefaultRollupPolicy(t *testing.T) {
cfg, err := buildMetricConfig(&MetricStoreConfig{
Driver: "sqlite",
DSN: ":memory:",
RetentionDays: 30,
DownsamplingEnabled: true,
TablePrefix: "metric_",
}, false)
if err != nil {
t.Fatalf("build metric config: %v", err)
}
if !cfg.RollupPolicy.Enabled() {
t.Fatal("expected default rollup policy to be enabled")
}
if cfg.RollupPolicy.RawRetention != DefaultRollupRawRetention {
t.Fatalf("raw retention = %s, want %s", cfg.RollupPolicy.RawRetention, DefaultRollupRawRetention)
}
}
func TestBuildMetricConfigDefaultsRetentionToNinetyDays(t *testing.T) {
cfg, err := buildMetricConfig(&MetricStoreConfig{
Driver: "sqlite",
DSN: ":memory:",
DownsamplingEnabled: true,
}, false)
if err != nil {
t.Fatalf("build metric config: %v", err)
}
if cfg.DefaultRetentionDays != DefaultMetricRetentionDays {
t.Fatalf(
"default retention = %d, want %d",
cfg.DefaultRetentionDays,
DefaultMetricRetentionDays,
)
}
wantRollupRetention := time.Duration(DefaultMetricRetentionDays) * 24 * time.Hour
lastTier := cfg.RollupPolicy.Tiers[len(cfg.RollupPolicy.Tiers)-1]
if lastTier.Retention != wantRollupRetention {
t.Fatalf("rollup retention = %s, want %s", lastTier.Retention, wantRollupRetention)
}
}
func TestBuildMetricConfigCanDisableDownsampling(t *testing.T) {
cfg, err := buildMetricConfig(&MetricStoreConfig{
Driver: "sqlite",
DSN: ":memory:",
DownsamplingEnabled: false,
}, false)
if err != nil {
t.Fatalf("build metric config: %v", err)
}
if cfg.RollupPolicy.Enabled() {
t.Fatal("expected rollup policy to be disabled")
}
}
func TestCompactCleansExpiredRawPointsWhenDownsamplingDisabled(t *testing.T) {
ctx := context.Background()
s, err := metric.Open(ctx, metric.SQLite(":memory:", metric.WithMaxOpenConns(1)))
if err != nil {
t.Fatalf("open metric store: %v", err)
}
if err := s.UpsertMetric(ctx, metric.Definition{
Name: "raw.metric",
Type: metric.TypeGauge,
RetentionDays: 1,
}); err != nil {
t.Fatalf("upsert metric: %v", err)
}
now := time.Date(2026, 7, 11, 12, 0, 0, 0, time.UTC)
if err := s.WriteBatch(ctx, []metric.Point{
{MetricName: "raw.metric", EntityID: "node", Timestamp: now.Add(-48 * time.Hour), Value: 1},
{MetricName: "raw.metric", EntityID: "node", Timestamp: now.Add(-time.Hour), Value: 2},
}); err != nil {
t.Fatalf("write points: %v", err)
}
storeMu.Lock()
oldStore := store
oldCompactAt := compactAt
store = s
compactAt = 0
storeMu.Unlock()
defer func() {
storeMu.Lock()
store = oldStore
compactAt = oldCompactAt
storeMu.Unlock()
_ = s.Close()
}()
if _, err := Compact(ctx, now); err != nil {
t.Fatalf("compact: %v", err)
}
points, err := s.Query(ctx, metric.Query{
MetricName: "raw.metric",
EntityID: "node",
Start: now.Add(-72 * time.Hour),
End: now,
})
if err != nil {
t.Fatalf("query points: %v", err)
}
if len(points) != 1 || points[0].Value != 2 {
t.Fatalf("expected only the retained raw point, got %#v", points)
}
}
func TestCompactKeepsRotatingCursorAfterFullCycle(t *testing.T) {
ctx := context.Background()
s, err := metric.Open(ctx, metric.SQLite(":memory:",
metric.WithMaxOpenConns(1),
metric.WithRollupPolicy(defaultRollupPolicy(30)),
))
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)
}
}
func TestGetRecordsByClientAndTimeReadsRollupsAfterRawCompaction(t *testing.T) {
ctx := context.Background()
s, err := metric.Open(ctx, metric.SQLite(":memory:",
metric.WithMaxOpenConns(1),
metric.WithRollupPolicy(defaultRollupPolicy(30)),
))
if err != nil {
t.Fatalf("open metric store: %v", err)
}
if err := createMetricDefinitions(ctx, s); err != nil {
t.Fatalf("create metric definitions: %v", err)
}
storeMu.Lock()
oldStore := store
oldCompactAt := compactAt
store = s
compactAt = 0
storeMu.Unlock()
defer func() {
storeMu.Lock()
store = oldStore
compactAt = oldCompactAt
storeMu.Unlock()
_ = s.Close()
}()
now := time.Now().UTC().Truncate(time.Minute)
ts := now.Add(-time.Hour)
rec := models.Record{
Client: "node-a",
Time: models.FromTime(ts),
Cpu: 42.5,
Ram: 123456,
RamTotal: 999999,
Disk: 456789,
DiskTotal: 777777,
Load: 0.75,
Connections: 321,
ConnectionsUdp: 12,
}
if err := WriteRecord(ctx, rec); err != nil {
t.Fatalf("write record: %v", err)
}
if _, err := s.Compact(ctx, now); err != nil {
t.Fatalf("compact raw into rollup: %v", err)
}
raw, err := s.Query(ctx, metric.Query{
MetricName: MetricCPU,
EntityID: rec.Client,
Start: ts.Add(-time.Minute),
End: now,
})
if err != nil {
t.Fatalf("query raw cpu: %v", err)
}
if len(raw) != 0 {
t.Fatalf("expected old raw cpu point to be deleted after compaction, got %d", len(raw))
}
got, err := GetRecordsByClientAndTime(ctx, rec.Client, ts.Add(-time.Minute), now)
if err != nil {
t.Fatalf("get records: %v", err)
}
if len(got) != 1 {
t.Fatalf("expected 1 reconstructed record from rollup, got %d: %#v", len(got), got)
}
if got[0].Cpu == 0 || got[0].Ram == 0 || got[0].Disk == 0 || got[0].Connections == 0 {
t.Fatalf("record was not reconstructed from rollup: %#v", got[0])
}
all, err := GetRecordsByTime(ctx, ts.Add(-time.Minute), now)
if err != nil {
t.Fatalf("get all records: %v", err)
}
if len(all) != 1 || all[0].Client != rec.Client || all[0].Cpu == 0 {
t.Fatalf("all-client records were not reconstructed from rollup: %#v", all)
}
}
+72
View File
@@ -0,0 +1,72 @@
package metricstore
import (
"fmt"
"log"
"strings"
"github.com/komari-monitor/komari/pkg/config"
)
// migration.go
//
// 启动阶段的 metrics 数据迁移。这里只处理 metrics 存储后端切换时的数据搬运
// (例如默认 SQLite ./data/metrics.db 切换到 MySQL/PostgreSQL)。旧 komari.db
// 监控表导入属于一次性迁移,见 pkg/migrations.RunMetricStoreMigrations。
// targetFingerprint 返回当前 metrics 目标库的指纹(driver + 归一化 DSN),
// 用于判断 metrics 存储后端是否发生变化(例如从 SQLite 切换到 MySQL/PostgreSQL)。
func targetFingerprint(cfg *MetricStoreConfig) string {
driver := ResolveDriverFromConfig(cfg.Driver, cfg.DSN)
dsn := strings.TrimSpace(cfg.DSN)
return fmt.Sprintf("%s|%s", driver, dsn)
}
// RunStartupMigration 在服务启动时检查 metrics 存储后端是否发生变化,
// 若变化则把上一个 metrics 目标库的全部数据搬运到当前目标。
//
// 判定依据 MigrationTargetKey(记录“数据当前完整所在的 metrics 目标指纹”):
// - saved == current:数据已在当前目标,无需处理。
// - saved == "":无历史目标记录。老快照的 metrics 数据固定落在默认 SQLite
// (./data/metrics.db);若当前目标不是默认 SQLite(例如已切到 MySQL/PostgreSQL),
// 把默认 SQLite 中可能存在的历史数据搬运过来,否则仅登记指纹。
// - saved != current:后端已切换,把上一个目标(saved)的数据搬运到当前目标。
//
// 搬运以 upsert 写入,幂等,中断后重启可安全重跑。任一失败都返回错误,
// 调用方应让启动失败并打印明确错误。
func RunStartupMigration() error {
s := GetStore()
if s == nil {
return fmt.Errorf("metric store not initialized")
}
cfg, err := config.GetManyAs[MetricStoreConfig]()
if err != nil {
return fmt.Errorf("failed to load metric store config: %w", err)
}
current := targetFingerprint(cfg)
saved, _ := config.GetAs[string](MigrationTargetKey, "")
// 数据已完整位于当前目标:无需搬运。
if saved == current {
return nil
}
// 上一个目标:优先使用已保存指纹;无记录时按老快照默认 SQLite 推断。
prev := saved
if prev == "" {
prev = defaultSQLiteFingerprint()
}
if prev != current {
if err := migrateFromPreviousStore(prev, cfg, s); err != nil {
return err
}
}
if err := config.Set(MigrationTargetKey, current); err != nil {
log.Printf("Failed to persist migration target fingerprint: %v", err)
}
return nil
}
+209
View File
@@ -0,0 +1,209 @@
package metricstore
import (
"context"
"fmt"
"log"
"strings"
"time"
"github.com/komari-monitor/komari/pkg/metric"
)
// storeMigrationWindow 是 store-to-store 迁移时按时间分窗查询的窗口大小。
// 采样点按时间窗口分批拉取,避免一次性把整段序列读入内存。
const storeMigrationWindow = 6 * time.Hour
// configFromFingerprint 从目标指纹(driver|dsn)重建一个 MetricStoreConfig,
// 用于以只读方式打开“上一次的 metrics 目标库”。表前缀、保留天数、连接数等
// 沿用当前配置(切换后端时这些通常不变)。
func configFromFingerprint(fingerprint string, base *MetricStoreConfig) (*MetricStoreConfig, error) {
idx := strings.Index(fingerprint, "|")
if idx < 0 {
return nil, fmt.Errorf("invalid target fingerprint: %q", fingerprint)
}
driver := fingerprint[:idx]
dsn := fingerprint[idx+1:]
if strings.TrimSpace(driver) == "" {
return nil, fmt.Errorf("empty driver in target fingerprint: %q", fingerprint)
}
return &MetricStoreConfig{
Driver: driver,
DSN: dsn,
RetentionDays: base.RetentionDays,
DownsamplingEnabled: base.DownsamplingEnabled,
TablePrefix: base.TablePrefix,
MaxOpenConns: base.MaxOpenConns,
MaxIdleConns: base.MaxIdleConns,
}, nil
}
// openSourceStore 打开一个已存在的 metrics 目标库作为数据搬运的源库读取。
//
// 使用 autoMigrate=true:GORM 的 AutoMigrate 只新增表/列/索引、从不删除数据,
// 因此对真实旧库幂等无害;而当源库文件/表不存在(例如老快照记录了 completed
// 但 metrics.db 缺失)时,可创建空表让后续 ListMetrics 返回空集而非报
// "no such table",从而把“无历史可迁移”识别为正常情况而非错误。
func openSourceStore(ctx context.Context, cfg *MetricStoreConfig) (*metric.Store, error) {
metricCfg, err := buildMetricConfig(cfg, true)
if err != nil {
return nil, err
}
return metric.Open(ctx, metricCfg)
}
// defaultSQLiteFingerprint 返回默认 SQLite metrics 库(./data/metrics.db)的目标指纹。
// 老快照的 metrics 数据固定落在该 SQLite 文件,用于在缺失指纹时推断上一个源库。
func defaultSQLiteFingerprint() string {
return targetFingerprint(&MetricStoreConfig{Driver: "sqlite", DSN: "./data/metrics.db"})
}
// migrateFromPreviousStore 打开由 prevFingerprint 指定的上一个 metrics 目标库作为源,
// 把其中的全部指标搬运到当前目标 dst(例如 SQLite metrics.db → MySQL/PostgreSQL)。
//
// 该过程幂等:dst 以 (metric, entity, tags, ts) 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()
log.Printf("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)
}
log.Printf("Store-to-store metrics migration completed (%d points)", total)
return nil
}
// storeMigrationObserver 在 store-to-store 迁移过程中接收进度回调。
// - currentMetric:当前正在搬运的指标名。
// - metricIndex:该指标在全部指标中的序号(0 起),即已完成的指标数。
// - totalMetrics:指标定义总数。
// - addedPoints:本次新写入目标库的采样点数(用于外部累计)。
type storeMigrationObserver func(currentMetric string, metricIndex, totalMetrics int, addedPoints int64)
// MigrateBetweenStores 把 src metric store 的全部指标定义与采样点搬运到 dst。
//
// 用于 metrics 后端切换(例如默认 SQLite metrics.db → MySQL/PostgreSQL):
// - 先在 dst 建立/更新全部指标定义;
// - 再按指标、按时间窗口分批读取源库采样点并写入 dst。
//
// 采样点在 dst 以 (metric_name, entity_id, tags, ts) upsert 写入,窗口边界重叠也
// 幂等,因此整个过程可安全重试。返回搬运的采样点总数。
func MigrateBetweenStores(ctx context.Context, src, dst *metric.Store) (int64, error) {
return migrateBetweenStores(ctx, src, dst, nil)
}
// migrateBetweenStores 是 MigrateBetweenStores 的内部实现,额外接受一个可选的进度
// 观察者 observe(为 nil 时行为与旧版本完全一致)。启动迁移走 nil,WebUI/API 触发
// 的迁移传入回调以实时更新进度。
func migrateBetweenStores(ctx context.Context, src, dst *metric.Store, observe storeMigrationObserver) (int64, error) {
if src == nil || dst == nil {
return 0, fmt.Errorf("source or destination metric store is nil")
}
defs, err := src.ListMetrics(ctx)
if err != nil {
return 0, fmt.Errorf("list source metrics: %w", err)
}
var total int64
for i, def := range defs {
if observe != nil {
// 进入下一个指标:已完成 i 个指标。
observe(def.Name, i, len(defs), 0)
}
// 目标库先建立指标定义,保证后续写入的指标存在。
if err := dst.UpsertMetric(ctx, def); err != nil {
return total, fmt.Errorf("upsert metric %q on target: %w", def.Name, err)
}
earliest, latest, ok, err := metricTimeBounds(ctx, src, def.Name)
if err != nil {
return total, fmt.Errorf("resolve time bounds for metric %q: %w", def.Name, err)
}
if !ok {
// 该指标只有定义没有数据,跳过。
continue
}
var migrated int64
for windowStart := earliest; !windowStart.After(latest); windowStart = windowStart.Add(storeMigrationWindow) {
select {
case <-ctx.Done():
return total, ctx.Err()
default:
}
windowEnd := windowStart.Add(storeMigrationWindow)
points, err := src.Query(ctx, metric.Query{
MetricName: def.Name,
Start: windowStart,
End: windowEnd,
Order: metric.OrderAsc,
})
if err != nil {
return total, fmt.Errorf("query metric %q window [%s,%s): %w", def.Name, windowStart, windowEnd, err)
}
if len(points) == 0 {
continue
}
if err := dst.WriteBatch(ctx, points); err != nil {
return total, fmt.Errorf("write metric %q batch to target: %w", def.Name, err)
}
migrated += int64(len(points))
total += int64(len(points))
if observe != nil {
observe(def.Name, i, len(defs), int64(len(points)))
}
}
if migrated > 0 {
log.Printf("[store-migration] metric %q: migrated %d points", def.Name, migrated)
}
}
return total, nil
}
// metricTimeBounds 返回某指标在 src 中最早/最晚采样时间。ok=false 表示无数据。
// 通过升/降序各取一条采样点定位边界,避免把整段序列读入内存。
func metricTimeBounds(ctx context.Context, src *metric.Store, name string) (time.Time, time.Time, bool, error) {
wide := metric.Query{
MetricName: name,
Start: time.Unix(0, 0),
End: time.Now().Add(24 * time.Hour),
}
asc := wide
asc.Order = metric.OrderAsc
asc.Limit = 1
first, err := src.Query(ctx, asc)
if err != nil {
return time.Time{}, time.Time{}, false, err
}
if len(first) == 0 {
return time.Time{}, time.Time{}, false, nil
}
desc := wide
desc.Order = metric.OrderDesc
desc.Limit = 1
last, err := src.Query(ctx, desc)
if err != nil {
return time.Time{}, time.Time{}, false, err
}
if len(last) == 0 {
return time.Time{}, time.Time{}, false, nil
}
return first[0].Timestamp, last[0].Timestamp, true, nil
}
+38
View File
@@ -0,0 +1,38 @@
package metricstore
import "context"
// storeOperationGate serializes operations that need a stable active Store.
// A channel-backed gate supports both non-blocking scheduled work and
// context-aware admin/shutdown waits.
type storeOperationGate struct {
token chan struct{}
}
func newStoreOperationGate() *storeOperationGate {
gate := &storeOperationGate{token: make(chan struct{}, 1)}
gate.token <- struct{}{}
return gate
}
func (g *storeOperationGate) Acquire(ctx context.Context) error {
select {
case <-g.token:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
func (g *storeOperationGate) TryAcquire() bool {
select {
case <-g.token:
return true
default:
return false
}
}
func (g *storeOperationGate) Release() {
g.token <- struct{}{}
}
+312
View File
@@ -0,0 +1,312 @@
package metricstore
import (
"context"
"fmt"
"log"
"strings"
"sync"
"time"
"github.com/komari-monitor/komari/pkg/config"
"github.com/komari-monitor/komari/pkg/metric"
)
// store_migration.go
//
// 面向 WebUI/API 的“存储后端迁移”支撑:把一个 metrics 源库(默认本地 SQLite
// ./data/metrics.db)中的全部指标数据搬运到当前正在运行的 metrics 目标库
// (通常是用户新配置并热重载后的 MySQL/PostgreSQL)。
//
// 与启动迁移(RunStartupMigration)的区别:
// - 启动迁移在进程启动时自动、同步执行(komari.db → 当前 metrics 目标,或上一个
// metrics 目标 → 当前目标)。
// - 此处的 store 迁移由管理员在 WebUI 中「切换数据库后」手动触发,异步执行并可
// 查询进度 / 取消。数据以 upsert 写入,可安全重复执行。
//
// 典型流程:
// 1. 管理员在设置中把 metric_db_dsn 改为 MySQL/PostgreSQL,保存 → 后端连接测试
// 通过后热重载,当前 store 切到远端(此时远端为空)。
// 2. 管理员调用 startMetricMigration(不带 source 参数),系统以上一个 metrics
// 目标(默认 SQLite metrics.db)为源,把历史数据搬运到远端。
// 3. 完成后登记目标指纹,下次启动不再重复搬运。
// StoreMigrationProgress 描述一次 store-to-store 迁移的实时进度。
type StoreMigrationProgress struct {
Status string `json:"status"` // idle, running, completed, failed, canceled
SourceDriver string `json:"source_driver"` // 源库驱动
SourceDSN string `json:"source_dsn"` // 源库 DSN(脱敏后)
TargetDriver string `json:"target_driver"` // 目标库驱动
TargetDSN string `json:"target_dsn"` // 目标库 DSN(脱敏后)
TotalMetrics int `json:"total_metrics"` // 指标定义总数
CurrentMetric string `json:"current_metric"` // 当前正在搬运的指标名
MetricsDone int `json:"metrics_done"` // 已完成的指标数
MigratedPoints int64 `json:"migrated_points"` // 已搬运的采样点数
StartTime time.Time `json:"start_time"` // 开始时间
EndTime time.Time `json:"end_time"` // 结束时间
Error string `json:"error,omitempty"` // 错误信息
}
var (
storeMigMu sync.Mutex
storeMigProgress StoreMigrationProgress
storeMigCancel context.CancelFunc
storeMigDone chan struct{}
storeClosing bool
)
// IsStoreMigrationRunning 报告是否有 store-to-store 迁移正在运行。
func IsStoreMigrationRunning() bool {
storeMigMu.Lock()
defer storeMigMu.Unlock()
return storeMigCancel != nil
}
// GetStoreMigrationProgress 返回当前 store-to-store 迁移进度快照。
func GetStoreMigrationProgress() StoreMigrationProgress {
storeMigMu.Lock()
defer storeMigMu.Unlock()
return storeMigProgress
}
// ResolveStoreMigrationSourceFingerprint 推断本次 store 迁移的源库指纹。
//
// 优先级:
// 1. 显式传入的 driver+dsn(driver 可留空,交由 DSN 推断)。
// 2. 已保存的上一个 metrics 目标指纹(MigrationTargetKey)。
// 3. 默认 SQLite(./data/metrics.db)。
func ResolveStoreMigrationSourceFingerprint(driver, dsn string) string {
dsn = strings.TrimSpace(dsn)
if dsn != "" {
d := ResolveDriverFromConfig(driver, dsn)
return fmt.Sprintf("%s|%s", d, dsn)
}
if saved, _ := config.GetAs[string](MigrationTargetKey, ""); saved != "" {
return saved
}
return defaultSQLiteFingerprint()
}
// StartStoreMigration 异步启动一次 store-to-store 迁移:把源库(由 sourceDriver +
// sourceDSN 指定,留空则自动推断)的全部指标数据搬运到当前运行中的 metrics 目标库。
//
// 返回错误表示“未能启动”(例如已有迁移在跑、源与目标相同、目标未初始化等);
// 迁移过程中的错误通过 GetStoreMigrationProgress().Status == "failed" 与 Error 暴露。
func StartStoreMigration(sourceDriver, sourceDSN string) error {
cfg, err := config.GetManyAs[MetricStoreConfig]()
if err != nil {
return fmt.Errorf("failed to load metric store config: %w", err)
}
targetFP := targetFingerprint(cfg)
sourceFP := ResolveStoreMigrationSourceFingerprint(sourceDriver, sourceDSN)
if sourceFP == targetFP {
return fmt.Errorf("source and target metrics database are the same; nothing to migrate")
}
srcCfg, err := configFromFingerprint(sourceFP, cfg)
if err != nil {
return fmt.Errorf("resolve source metrics store: %w", err)
}
if !storeOperations.TryAcquire() {
return ErrStoreBusy
}
exclusiveLeaseHandedOff := false
defer func() {
if !exclusiveLeaseHandedOff {
storeOperations.Release()
}
}()
storeMu.RLock()
dst := store
activeFingerprint := storeFingerprint
storeMu.RUnlock()
if dst == nil {
return ErrStoreNotInitialized
}
if activeFingerprint != targetFP {
return fmt.Errorf("active metric store does not match the configured migration target; wait for hot reload and retry")
}
storeMigMu.Lock()
if storeClosing {
storeMigMu.Unlock()
return ErrStoreBusy
}
if storeMigCancel != nil {
storeMigMu.Unlock()
return fmt.Errorf("a store migration is already in progress")
}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
storeMigCancel = cancel
storeMigDone = done
storeMigProgress = StoreMigrationProgress{
Status: "running",
SourceDriver: string(ResolveDriverFromConfig(srcCfg.Driver, srcCfg.DSN)),
SourceDSN: maskDSN(srcCfg.DSN),
TargetDriver: string(ResolveDriverFromConfig(cfg.Driver, cfg.DSN)),
TargetDSN: maskDSN(cfg.DSN),
StartTime: time.Now(),
}
storeMigMu.Unlock()
exclusiveLeaseHandedOff = true
go runStoreMigration(ctx, cancel, done, srcCfg, cfg, dst, targetFP)
return nil
}
// runStoreMigration 执行实际的搬运逻辑(在独立 goroutine 中)。
func runStoreMigration(ctx context.Context, cancel context.CancelFunc, done chan struct{}, srcCfg *MetricStoreConfig, cfg *MetricStoreConfig, dst *metric.Store, targetFP string) {
defer func() {
cancel()
storeOperations.Release()
storeMigMu.Lock()
storeMigCancel = nil
storeMigDone = nil
close(done)
storeMigMu.Unlock()
}()
src, err := openSourceStore(ctx, srcCfg)
if err != nil {
finishStoreMigration("failed", fmt.Errorf("open source metrics store: %w", err))
return
}
defer src.Close()
observe := func(currentMetric string, metricIndex, totalMetrics int, addedPoints int64) {
storeMigMu.Lock()
storeMigProgress.CurrentMetric = currentMetric
storeMigProgress.MetricsDone = metricIndex
storeMigProgress.TotalMetrics = totalMetrics
storeMigProgress.MigratedPoints += addedPoints
storeMigMu.Unlock()
}
total, err := migrateBetweenStores(ctx, src, dst, observe)
if err != nil {
if ctx.Err() != nil {
finishStoreMigration("canceled", nil)
log.Printf("[store-migration] canceled after %d points", total)
return
}
finishStoreMigration("failed", err)
return
}
// 搬运成功:登记当前目标指纹,避免下次启动重复搬运。
if err := config.Set(MigrationTargetKey, targetFP); err != nil {
log.Printf("[store-migration] failed to persist migration target fingerprint: %v", err)
}
finishStoreMigration("completed", nil)
log.Printf("[store-migration] completed via API (%d points) target_driver=%s", total, ResolveDriverFromConfig(cfg.Driver, cfg.DSN))
}
// finishStoreMigration 统一收尾:设置终态状态、结束时间与错误信息。
func finishStoreMigration(status string, err error) {
storeMigMu.Lock()
defer storeMigMu.Unlock()
storeMigProgress.Status = status
storeMigProgress.EndTime = time.Now()
if err != nil {
storeMigProgress.Error = err.Error()
}
// 完成/取消时把当前指标标记为已全部处理,便于前端进度条归位。
if status == "completed" && storeMigProgress.TotalMetrics > 0 {
storeMigProgress.MetricsDone = storeMigProgress.TotalMetrics
}
}
// CancelStoreMigration 请求取消正在运行的 store-to-store 迁移,并等待其退出。
// 由于写入是幂等 upsert,取消后可安全重新发起,不会产生重复数据。
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
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())
}
}
func clearStoreClosing() {
storeMigMu.Lock()
storeClosing = false
storeMigMu.Unlock()
}
func isStoreClosing() bool {
storeMigMu.Lock()
defer storeMigMu.Unlock()
return storeClosing
}
// maskDSN 对 DSN 做粗粒度脱敏,避免在 API 响应/日志中泄露密码。
// 处理两类常见格式:URL(scheme://user:pass@host/...)与 key=value(password=...)。
func maskDSN(dsn string) string {
dsn = strings.TrimSpace(dsn)
if dsn == "" {
return ""
}
// key=value 形式:屏蔽 password=... 段。
if strings.Contains(dsn, "password=") {
parts := strings.Fields(dsn)
for i, p := range parts {
if strings.HasPrefix(strings.ToLower(p), "password=") {
parts[i] = "password=***"
}
}
return strings.Join(parts, " ")
}
// URL / user:pass@host 形式:屏蔽 user:pass@ 中的密码。
if at := strings.LastIndex(dsn, "@"); at > 0 {
head := dsn[:at]
tail := dsn[at:]
if colon := strings.LastIndex(head, ":"); colon >= 0 {
// 保留 scheme://user,屏蔽密码。
return head[:colon] + ":***" + tail
}
}
return dsn
}
+121
View File
@@ -0,0 +1,121 @@
package models
import (
"database/sql/driver"
"fmt"
"log"
"os"
"strings"
"sync"
"time"
)
// LocalTime is a custom time type for GORM to handle time.Time correctly.
// It ensures that all times are stored and retrieved based on the application's configured timezone.
type LocalTime time.Time
// Value implements the driver.Valuer interface.
// It converts UTCTime to a TEXT format that the database understands.
// The time is formatted as a string in the application's local timezone, without timezone information.
func (t LocalTime) Value() (driver.Value, error) {
if time.Time(t).IsZero() {
return nil, nil
}
return time.Time(t).In(GetAppLocation()).Format("2006-01-02 15:04:05.0000000"), nil
}
// Scan implements the sql.Scanner interface.
// It scans a value from the database into a UTCTime object.
func (t *LocalTime) Scan(v interface{}) error {
if v == nil {
*t = LocalTime(time.Time{})
return nil
}
loc := GetAppLocation()
switch val := v.(type) {
case time.Time:
// CRITICAL FIX: When the driver reads a timezone-less string (e.g., "16:00:00"),
// it often incorrectly assumes it's UTC. We must correct this by re-interpreting
// the date and clock values in the application's actual timezone.
year, month, day := val.Date()
hour, min, sec := val.Clock()
nanosec := val.Nanosecond()
*t = LocalTime(time.Date(year, month, day, hour, min, sec, nanosec, loc))
return nil
case []byte:
return t.parseTime(string(val), loc)
case string:
return t.parseTime(val, loc)
default:
return fmt.Errorf("UTCTime scan source was not string, []byte or time.Time: %T (%v)", v, v)
}
}
// parseTime handles parsing a string into UTCTime.
func (t *LocalTime) parseTime(timeStr string, loc *time.Location) error {
timeStr = strings.TrimSpace(timeStr)
if timeStr == "" {
*t = LocalTime(time.Time{})
return nil
}
layouts := []string{
time.RFC3339Nano, time.RFC3339,
"2006-01-02 15:04:05.0000000-07:00", "2006-01-02 15:04:05-07:00",
"2006-01-02 15:04:05.0000000", "2006-01-02 15:04:05", "2006-01-02",
}
for _, layout := range layouts {
if parsedTime, err := time.ParseInLocation(layout, timeStr, loc); err == nil {
*t = LocalTime(parsedTime)
return nil
}
}
return fmt.Errorf("unable to parse time string '%s' into UTCTime", timeStr)
}
// MarshalJSON implements the json.Marshaler interface.
// Serializes UTCTime to JSON in RFC3339 format with the correct timezone offset.
func (t LocalTime) MarshalJSON() ([]byte, error) {
if time.Time(t).IsZero() {
return []byte("null"), nil
}
formattedTime := time.Time(t).In(GetAppLocation()).Format(time.RFC3339)
return []byte(fmt.Sprintf(`"%s"`, formattedTime)), nil
}
// ToTime converts UTCTime to Go's native time.Time type.
func (t LocalTime) ToTime() time.Time { return time.Time(t) }
// FromTime converts Go's native time.Time type to UTCTime.
func FromTime(t time.Time) LocalTime { return LocalTime(t) }
// Now returns the current time in the application's configured location.
func Now() LocalTime { return LocalTime(time.Now().In(GetAppLocation())) }
var (
appLocation *time.Location
locationOnce sync.Once
)
// GetAppLocation retrieves the application's global timezone from the "TZ" environment variable.
func GetAppLocation() *time.Location {
locationOnce.Do(func() {
tz := os.Getenv("TZ")
if tz == "" {
tz = "UTC"
}
loc, err := time.LoadLocation(tz)
if err != nil {
log.Printf("Warning: Failed to load timezone '%s', falling back to UTC. Error: %v", tz, err)
appLocation = time.UTC
} else {
appLocation = loc
}
time.Local = appLocation
log.Printf("Application timezone is set to '%s'.", appLocation.String())
})
return appLocation
}
+2 -4
View File
@@ -1,13 +1,11 @@
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"`
CreatedAt LocalTime `json:"created_at"`
UpdatedAt LocalTime `json:"updated_at"`
}
-35
View File
@@ -1,35 +0,0 @@
package models
import "time"
type Config struct {
ID uint `json:"id,omitempty" gorm:"primaryKey;autoIncrement"` // 1
Sitename string `json:"sitename" gorm:"type:varchar(100);not null"`
Description string `json:"description" gorm:"type:text"`
AllowCors bool `json:"allow_cors" gorm:"column:allow_cors;default:false"`
// GeoIP 配置
GeoIpEnabled bool `json:"geo_ip_enabled" gorm:"default:true"`
GeoIpProvider string `json:"geo_ip_provider" gorm:"type:varchar(20);default:'mmdb'"` // mmdb, bilibili, ip-api. 暂时只实现了mmdb
// OAuth 配置
OAuthClientID string `json:"o_auth_client_id" gorm:"type:varchar(255)"`
OAuthClientSecret string `json:"o_auth_client_secret" gorm:"type:varchar(255)"`
OAuthEnabled bool `json:"o_auth_enabled" gorm:"default:false"`
DisablePasswordLogin bool `json:"disable_password_login" gorm:"default:false"`
// 自定义美化
CustomHead string `json:"custom_head" gorm:"type:longtext"`
CustomBody string `json:"custom_body" gorm:"type:longtext"`
// Telegram Bot 配置
// TelegramEnabled bool `json:"telegram_enabled" gorm:"default:false"`
TelegramEndpoint string `json:"telegram_endpoint" gorm:"type:varchar(255);default:'https://api.telegram.org/bot'"`
TelegramBotToken string `json:"telegram_bot_token" gorm:"type:varchar(255)"`
TelegramChatID string `json:"telegram_chat_id" gorm:"type:varchar(255)"`
// 通知
NotificationEnabled bool `json:"notification_enabled" gorm:"default:false"` // 通知总开关
NotificationMethod string `json:"notification_method" gorm:"type:varchar(64);default:'none'"`
// Record
RecordEnabled bool `json:"record_enabled" gorm:"default:true"` // 是否启用记录功能
RecordPreserveTime int `json:"record_preserve_time" gorm:"default:720"` // 记录保留时间,单位小时,默认30天
PingRecordPreserveTime int `json:"ping_record_preserve_time" gorm:"default:72"` // Ping 记录保留时间,单位小时,默认3天
CreatedAt time.Time
UpdatedAt time.Time
}
+1 -3
View File
@@ -1,12 +1,10 @@
package models
import "time"
type Log struct {
ID uint `json:"id,omitempty" gorm:"primaryKey;autoIncrement"`
IP string `json:"ip" gorm:"type:varchar(45);"` // IPv4 or IPv6
UUID string `json:"uuid" gorm:"type:varchar(36);"`
Message string `json:"message" gorm:"type:text;not null"`
MsgType string `json:"msg_type" gorm:"type:varchar(20);not null"`
Time time.Time `json:"time" gorm:"autoCreateTime;not null"`
Time LocalTime `json:"time" gorm:"autoCreateTime;not null"`
}
@@ -0,0 +1,14 @@
package messageevent
const (
Offline = "Offline"
Online = "Online"
Expire = "Expire"
Renew = "Renew"
Login = "Login"
Alert = "Alert"
Traffic = "Traffic"
DReport = "DReport" // 日报
WReport = "WReport" // 周报
MReport = "MReport" // 月报
)
+16
View File
@@ -0,0 +1,16 @@
package models
import "time"
type MessageSenderProvider struct {
Name string `json:"name" gorm:"primaryKey;unique;not null"`
Addition string `json:"addition" gorm:"type:longtext" default:"{}"`
}
type EventMessage struct {
Event string `json:"event"`
Clients []Client `json:"clients"`
Time time.Time `json:"time"`
Message string `json:"message"`
Emoji string `json:"emoji"`
}
+73 -38
View File
@@ -4,36 +4,43 @@ import (
"database/sql/driver"
"encoding/json"
"fmt"
"time"
)
// Client represents a registered client device
type Client struct {
UUID string `json:"uuid,omitempty" gorm:"type:varchar(36);primaryKey"`
Token string `json:"token,omitempty" gorm:"type:varchar(255);unique;not null"`
Name string `json:"name" gorm:"type:varchar(100)"`
CpuName string `json:"cpu_name" gorm:"type:varchar(100)"`
Virtualization string `json:"virtualization" gorm:"type:varchar(50)"`
Arch string `json:"arch" gorm:"type:varchar(50)"`
CpuCores int `json:"cpu_cores" gorm:"type:int"`
OS string `json:"os" gorm:"type:varchar(100)"`
GpuName string `json:"gpu_name" gorm:"type:varchar(100)"`
IPv4 string `json:"ipv4,omitempty" gorm:"type:varchar(100)"`
IPv6 string `json:"ipv6,omitempty" gorm:"type:varchar(100)"`
Region string `json:"region" gorm:"type:varchar(100)"`
Remark string `json:"remark,omitempty" gorm:"type:longtext"`
PublicRemark string `json:"public_remark,omitempty" gorm:"type:longtext"`
MemTotal int64 `json:"mem_total" gorm:"type:bigint"`
SwapTotal int64 `json:"swap_total" gorm:"type:bigint"`
DiskTotal int64 `json:"disk_total" gorm:"type:bigint"`
Version string `json:"version,omitempty" gorm:"type:varchar(100)"`
Weight int `json:"weight" gorm:"type:int"`
Price float64 `json:"price"`
BillingCycle int `json:"billing_cycle"`
Currency string `json:"currency" gorm:"type:varchar(20);default:'$'"`
ExpiredAt time.Time `json:"expired_at" gorm:"type:timestamp"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
UUID string `json:"uuid,omitempty" gorm:"type:varchar(36);primaryKey"`
Token string `json:"token,omitempty" gorm:"type:varchar(255);unique;not null"`
Name string `json:"name" gorm:"type:varchar(100)"`
CpuName string `json:"cpu_name" gorm:"type:varchar(100)"`
Virtualization string `json:"virtualization" gorm:"type:varchar(50)"`
Arch string `json:"arch" gorm:"type:varchar(50)"`
CpuCores int `json:"cpu_cores" gorm:"type:int"`
CpuPhysicalCores int `json:"cpu_physical_cores" gorm:"type:int"`
OS string `json:"os" gorm:"type:varchar(100)"`
KernelVersion string `json:"kernel_version" gorm:"type:varchar(100)"`
GpuName string `json:"gpu_name" gorm:"type:varchar(100)"`
IPv4 string `json:"ipv4,omitempty" gorm:"type:varchar(100)"`
IPv6 string `json:"ipv6,omitempty" gorm:"type:varchar(100)"`
Region string `json:"region" gorm:"type:varchar(100)"`
Remark string `json:"remark,omitempty" gorm:"type:longtext"`
PublicRemark string `json:"public_remark,omitempty" gorm:"type:longtext"`
MemTotal int64 `json:"mem_total" gorm:"type:bigint"`
SwapTotal int64 `json:"swap_total" gorm:"type:bigint"`
DiskTotal int64 `json:"disk_total" gorm:"type:bigint"`
Version string `json:"version,omitempty" gorm:"type:varchar(100)"`
Weight int `json:"weight" gorm:"type:int"`
Price float64 `json:"price"`
BillingCycle int `json:"billing_cycle"`
AutoRenewal bool `json:"auto_renewal" gorm:"default:false"` // 是否自动续费
Currency string `json:"currency" gorm:"type:varchar(20);default:'$'"`
ExpiredAt LocalTime `json:"expired_at" gorm:"type:timestamp"`
Group string `json:"group" gorm:"type:varchar(100)"`
Tags string `json:"tags" gorm:"type:text"` // split by ';'
Hidden bool `json:"hidden" gorm:"default:false"`
TrafficLimit int64 `json:"traffic_limit" gorm:"type:bigint"`
TrafficLimitType string `json:"traffic_limit_type" gorm:"type:varchar(10);default:'max'"` // 流量阈值类型:sum max min up down
CreatedAt LocalTime `json:"created_at"`
UpdatedAt LocalTime `json:"updated_at"`
}
// User represents an authenticated user
@@ -44,28 +51,29 @@ type User struct {
SSOType string `json:"sso_type" gorm:"type:varchar(20)"` // e.g., "github", "google"
SSOID string `json:"sso_id" gorm:"type:varchar(100)"` // OAuth provider's user ID
TwoFactor string `json:"two_factor,omitempty" gorm:"type:varchar(255)"` // 2FA secret
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Sessions []Session `json:"sessions,omitempty" gorm:"foreignKey:UUID;references:UUID;constraint:OnDelete:CASCADE,OnUpdate:CASCADE"`
CreatedAt LocalTime `json:"created_at"`
UpdatedAt LocalTime `json:"updated_at"`
}
// Session manages user sessions
type Session struct {
UUID string `json:"uuid" gorm:"type:varchar(36);foreignKey:UserUUID;references:UUID;constraint:OnDelete:CASCADE"`
Session string `json:"session" gorm:"type:varchar(255);uniqueIndex:idx_sessions_session;not null"`
UUID string `json:"uuid" gorm:"type:varchar(36)"`
Session string `json:"session" gorm:"type:varchar(255);primaryKey;uniqueIndex:idx_sessions_session;not null"`
UserAgent string `json:"user_agent" gorm:"type:text"`
Ip string `json:"ip" gorm:"type:varchar(100)"`
LoginMethod string `json:"login_method" gorm:"type:varchar(50)"`
LatestOnline time.Time `json:"latest_online" gorm:"type:timestamp"`
LatestOnline LocalTime `json:"latest_online" gorm:"type:timestamp"`
LatestUserAgent string `json:"latest_user_agent" gorm:"type:text"`
LatestIp string `json:"latest_ip" gorm:"type:varchar(100)"`
Expires time.Time `json:"expires" gorm:"not null"`
CreatedAt time.Time `json:"created_at"`
Expires LocalTime `json:"expires" gorm:"not null"`
CreatedAt LocalTime `json:"created_at"`
}
// Record logs client metrics over time
type Record struct {
Client string `json:"client" gorm:"type:varchar(36);index;foreignKey:ClientUUID;references:UUID;constraint:OnDelete:CASCADE"`
Time time.Time `json:"time" gorm:"index"`
Client string `json:"client" gorm:"type:varchar(36);index"`
Time LocalTime `json:"time" gorm:"index"`
Cpu float32 `json:"cpu" gorm:"type:decimal(5,2)"` // e.g., 75.50%
Gpu float32 `json:"gpu" gorm:"type:decimal(5,2)"`
Ram int64 `json:"ram" gorm:"type:bigint"`
@@ -80,9 +88,24 @@ type Record struct {
NetOut int64 `json:"net_out" gorm:"type:bigint"`
NetTotalUp int64 `json:"net_total_up" gorm:"type:bigint"`
NetTotalDown int64 `json:"net_total_down" gorm:"type:bigint"`
TrafficUp int64 `json:"traffic_up" gorm:"type:bigint"`
TrafficDown int64 `json:"traffic_down" gorm:"type:bigint"`
Process int `json:"process"`
Connections int `json:"connections"`
ConnectionsUdp int `json:"connections_udp"`
//Uptime int64 `json:"uptime" gorm:"type:bigint"`
}
// GPURecord logs individual GPU metrics over time
type GPURecord struct {
Client string `json:"client" gorm:"type:varchar(36);index"` // 客户端UUID
Time LocalTime `json:"time" gorm:"index"` // 记录时间
DeviceIndex int `json:"device_index" gorm:"index"` // GPU设备索引 (0,1,2...)
DeviceName string `json:"device_name" gorm:"type:varchar(100)"` // GPU型号
MemTotal int64 `json:"mem_total" gorm:"type:bigint"` // 显存总量(字节)
MemUsed int64 `json:"mem_used" gorm:"type:bigint"` // 显存使用(字节)
Utilization float32 `json:"utilization" gorm:"type:decimal(5,2)"` // GPU使用率(%)
Temperature int `json:"temperature"` // GPU温度(°C)
}
// StringArray represents a slice of strings stored as JSON in the database
@@ -90,9 +113,21 @@ type Record struct {
type StringArray []string
func (sa *StringArray) Scan(value interface{}) error {
bytes, ok := value.([]byte)
if !ok {
return fmt.Errorf("failed to scan StringArray: value is not []byte")
var bytes []byte
switch v := value.(type) {
case nil:
*sa = StringArray{}
return nil
case []byte:
bytes = v
case string:
bytes = []byte(v)
default:
return fmt.Errorf("failed to scan StringArray: unsupported value type %T", value)
}
if len(bytes) == 0 {
*sa = StringArray{}
return nil
}
return json.Unmarshal(bytes, sa)
}
+30
View File
@@ -0,0 +1,30 @@
package models
import "testing"
func TestStringArrayScanAcceptsSQLiteTextShapes(t *testing.T) {
tests := map[string]interface{}{
"bytes": []byte(`["a","b"]`),
"text": `["a","b"]`,
"empty": "",
"nil": nil,
}
for name, input := range tests {
t.Run(name, func(t *testing.T) {
var got StringArray
if err := got.Scan(input); err != nil {
t.Fatalf("scan StringArray: %v", err)
}
if input == "" || input == nil {
if len(got) != 0 {
t.Fatalf("expected empty StringArray, got %v", got)
}
return
}
if len(got) != 2 || got[0] != "a" || got[1] != "b" {
t.Fatalf("unexpected StringArray: %v", got)
}
})
}
}
+22 -13
View File
@@ -1,24 +1,33 @@
package models
import "time"
// Notification 定义了通知相关的数据库模型
type OfflineNotification struct {
Client string `json:"client" gorm:"type:varchar(36);not null;index;unique;constraint:OnDelete:CASCADE,OnUpdate:CASCADE;foreignKey:client;references:UUID"`
Enable bool `json:"enable" gorm:"type:boolean;default:false"`
Client string `json:"client" gorm:"type:varchar(36);not null;index;unique;constraint:OnDelete:CASCADE,OnUpdate:CASCADE;foreignKey:client;references:UUID"`
ClientInfo Client `json:"client_info,omitempty" gorm:"foreignKey:Client;references:UUID"`
Enable bool `json:"enable" gorm:"type:boolean;default:false"`
//Cooldown int `json:"cooldown" gorm:"type:int;not null;default:1800"` // 冷却时间(秒),默认 30 分钟
GracePeriod int `json:"grace_period" gorm:"type:int;not null;default:180"` // 宽限期(秒),默认 3 分钟
LastNotified time.Time `json:"last_notified"` // 上次通知时间
LastNotified LocalTime `json:"last_notified"` // 上次通知时间
}
// LoadNotification 定义了基于资源占用达标时间比的负载通知规则
type LoadNotification struct {
Client string `json:"client" gorm:"type:varchar(36);not null;index;unique;constraint:OnDelete:CASCADE,OnUpdate:CASCADE;foreignKey:client;references:UUID"`
Enable bool `json:"enable" gorm:"type:boolean;default:false"`
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"` // 阈值百分比
Window int `json:"window" gorm:"type:int;not null;default:300"` // 观测窗口(秒)
Ratio float32 `json:"ratio" gorm:"type:decimal(5,2);not null;default:0.80"` // 达标时间比
Cooldown int `json:"cooldown" gorm:"type:int;not null;default:1800"` // 冷却时间(秒)
LastNotified time.Time `json:"last_notified"` // 上次通知时间
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 LocalTime `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"`
ClientInfo Client `json:"client_info,omitempty" gorm:"foreignKey:Client;references:UUID"`
Enable bool `json:"enable" gorm:"type:boolean;default:false"`
Daily bool `json:"daily" gorm:"type:boolean;default:false"` // 日报
Weekly bool `json:"weekly" gorm:"type:boolean;default:false"` // 周报
Monthly bool `json:"monthly" gorm:"type:boolean;default:false"` // 月报
}
+6
View File
@@ -0,0 +1,6 @@
package models
type OidcProvider struct {
Name string `json:"name" gorm:"primaryKey;unique;not null"`
Addition string `json:"addition" gorm:"type:longtext" default:"{}"`
}
+28 -12
View File
@@ -1,19 +1,35 @@
package models
import "time"
type PingRecord struct {
Client string `json:"client" gorm:"type:varchar(36);not null;index;constraint:OnDelete:CASCADE,OnUpdate:CASCADE;foreignKey:client;references:UUID"`
TaskId uint `json:"task_id" gorm:"not null;index;constraint:OnDelete:CASCADE,OnUpdate:CASCADE;foreignKey:TaskId;references:Id"`
Time time.Time `json:"time" gorm:"index;not null"`
Value int `json:"value" gorm:"type:int;not null"` // Ping 值,单位毫秒
Client string `json:"client" gorm:"type:varchar(36);not null;index"`
ClientInfo Client `json:"client_info" gorm:"foreignKey:Client;references:UUID;constraint:OnDelete:CASCADE,OnUpdate:CASCADE"`
TaskId uint `json:"task_id" gorm:"not null;index"`
Task PingTask `json:"task" gorm:"foreignKey:TaskId;references:Id;constraint:OnDelete:CASCADE,OnUpdate:CASCADE;"`
Time LocalTime `json:"time" gorm:"index;not null"`
Value int `json:"value" gorm:"type:int;not null"` // Ping 值,单位毫秒
}
// PingTask 表示一次延迟监测任务配置。
type PingTask struct {
Id uint `json:"id,omitempty" gorm:"primaryKey;autoIncrement"`
Name string `json:"name" gorm:"type:varchar(255);not null;index"`
Clients StringArray `json:"clients" gorm:"type:longtext"`
Type string `json:"type" gorm:"type:varchar(12);not null;default:'icmp'"` // icmp tcp http
Target string `json:"target" gorm:"type:varchar(255);not null"`
Interval int `json:"interval" gorm:"type:int;not null;default:60"` // 间隔时间
Id uint `json:"id,omitempty" gorm:"primaryKey;autoIncrement"`
Weight int `json:"weight" gorm:"type:int;not null;default:0;index"`
Name string `json:"name" gorm:"type:varchar(255);not null;index"`
Clients StringArray `json:"clients" gorm:"type:longtext"`
DefaultOn bool `json:"default_on" gorm:"column:all_clients;not null;default:false"` // 新加入的服务器是否自动开启此监测;现有服务器不受此字段影响
Type string `json:"type" gorm:"type:varchar(12);not null;default:'icmp'"` // icmp tcp http
Target string `json:"target" gorm:"type:varchar(255);not null"` // Ping 目标地址
Interval int `json:"interval" gorm:"type:int;not null;default:60"` // 间隔时间
}
// AppliesToClient 判断当前 PingTask 是否适用于指定服务器。
func (task PingTask) AppliesToClient(uuid string) bool {
if uuid == "" {
return false
}
for _, client := range task.Clients {
if client == uuid {
return true
}
}
return false
}
+9 -10
View File
@@ -1,19 +1,18 @@
package models
import (
"time"
)
type Task struct {
TaskId string `json:"task_id" gorm:"type:varchar(36);primaryKey;unique"`
Clients StringArray `json:"clients" gorm:"type:longtext"`
Command string `json:"command" gorm:"type:text"`
TaskId string `json:"task_id" gorm:"type:varchar(36);primaryKey;unique"`
Clients StringArray `json:"clients" gorm:"type:longtext"`
Command string `json:"command" gorm:"type:text"`
Results []TaskResult `gorm:"foreignKey:TaskId;references:TaskId;constraint:OnDelete:CASCADE,OnUpdate:CASCADE"`
}
type TaskResult struct {
TaskId string `json:"task_id" gorm:"type:varchar(36)"`
TaskId string `json:"task_id" gorm:"type:varchar(36);index"`
Client string `json:"client" gorm:"type:varchar(36)"`
ClientInfo Client `json:"client_info" gorm:"foreignKey:Client;references:UUID;constraint:OnDelete:CASCADE,OnUpdate:CASCADE"`
Result string `json:"result" gorm:"type:longtext"`
ExitCode *int `json:"exit_code" gorm:"type:int"`
FinishedAt *time.Time `json:"finished_at" gorm:"type:timestamp"`
CreatedAt time.Time `json:"created_at" gorm:"type:timestamp"`
FinishedAt *LocalTime `json:"finished_at" gorm:"type:timestamp"`
CreatedAt LocalTime `json:"created_at" gorm:"type:timestamp"`
}
+146
View File
@@ -0,0 +1,146 @@
package models
import (
"fmt"
"net/url"
"path"
"strings"
)
const (
ThemeConfigurationManaged = "managed"
ThemeConfigurationRaw = "raw"
ThemeConfigurationRedirect = "redirect"
)
// Theme represents a komari theme information
type Theme struct {
Name string `json:"name"` // 主题名称
Short string `json:"short"` // 短名称,用作文件夹名
Description string `json:"description"` // 主题描述
Version string `json:"version"` // 版本号
Author string `json:"author"` // 作者
URL string `json:"url"` // 主题URL
Preview string `json:"preview"` // 预览图片相对路径
Configuration Configuration `json:"configuration"` // 声明配置项
}
type Configuration struct {
Type string `json:"type"` // managed raw redirect
Icon string `json:"icon"` // 图标
Name any `json:"name"`
Data any `json:"data"` // 配置数据
}
type ManagedThemeConfigurationItem struct {
Key string `json:"key"`
Name any `json:"name"`
Required bool `json:"required"`
Type string `json:"type"` // string number select switch title
Options string `json:"options"`
Default any `json:"default"`
Help any `json:"help"`
}
type ThemeConfiguration struct {
Short string `json:"short" gorm:"primaryKey;unique;not null"`
Data string `json:"data" gorm:"type:longtext" default:"{}"`
}
func (t Theme) ConfigurationType() string {
typ := strings.ToLower(strings.TrimSpace(t.Configuration.Type))
if typ == "" {
return ThemeConfigurationManaged
}
return typ
}
func (t Theme) RawHTML() (string, bool) {
if t.ConfigurationType() != ThemeConfigurationRaw {
return "", false
}
return configurationDataString(t.Configuration.Data)
}
func (t Theme) RedirectTarget() (string, bool) {
if t.ConfigurationType() != ThemeConfigurationRedirect {
return "", false
}
return NormalizeThemeRedirectTarget(t.Configuration.Data)
}
func (t Theme) ValidateConfiguration() error {
switch t.ConfigurationType() {
case ThemeConfigurationManaged:
return nil
case ThemeConfigurationRaw:
html, ok := t.RawHTML()
if !ok || strings.TrimSpace(html) == "" {
return fmt.Errorf("raw 类型主题需要在 configuration.data 中提供 HTML 字符串")
}
return nil
case ThemeConfigurationRedirect:
if _, ok := t.RedirectTarget(); !ok {
return fmt.Errorf("redirect 类型主题需要在 configuration.data 中提供站内相对路径")
}
return nil
default:
return fmt.Errorf("不支持的主题类型: %s", t.Configuration.Type)
}
}
func NormalizeThemeRedirectTarget(data any) (string, bool) {
target, ok := configurationDataString(data)
if !ok {
return "", false
}
target = strings.TrimSpace(target)
if target == "" || strings.Contains(target, "\\") || strings.HasPrefix(target, "//") {
return "", false
}
parsed, err := url.Parse(target)
if err != nil || parsed.IsAbs() || parsed.Host != "" {
return "", false
}
cleanInputPath := parsed.Path
if strings.HasPrefix(cleanInputPath, "/") {
cleanInputPath = strings.TrimLeft(cleanInputPath, "/")
} else {
for strings.HasPrefix(cleanInputPath, "../") {
cleanInputPath = strings.TrimPrefix(cleanInputPath, "../")
}
}
for _, segment := range strings.Split(cleanInputPath, "/") {
if segment == ".." {
return "", false
}
}
cleanPath := cleanInputPath
if cleanPath == "" {
cleanPath = "/"
} else {
cleanPath = path.Clean(cleanPath)
if cleanPath == "." {
cleanPath = "/"
} else {
cleanPath = "/" + strings.TrimPrefix(cleanPath, "/")
}
}
normalized := url.URL{
Path: cleanPath,
RawQuery: parsed.RawQuery,
Fragment: parsed.Fragment,
}
return normalized.String(), true
}
func configurationDataString(data any) (string, bool) {
value, ok := data.(string)
return value, ok
}
+141
View File
@@ -0,0 +1,141 @@
package models
import "testing"
func TestThemeValidateConfiguration(t *testing.T) {
tests := map[string]struct {
theme Theme
wantErr bool
}{
"managed empty data": {
theme: Theme{
Configuration: Configuration{Type: ThemeConfigurationManaged},
},
},
"missing type defaults to managed": {
theme: Theme{},
},
"raw html": {
theme: Theme{
Configuration: Configuration{
Type: ThemeConfigurationRaw,
Data: "<!doctype html><title>raw</title>",
},
},
},
"raw rejects empty html": {
theme: Theme{
Configuration: Configuration{
Type: ThemeConfigurationRaw,
Data: " ",
},
},
wantErr: true,
},
"redirect relative path": {
theme: Theme{
Configuration: Configuration{
Type: ThemeConfigurationRedirect,
Data: "/dashboard?tab=nodes",
},
},
},
"redirect rejects absolute url": {
theme: Theme{
Configuration: Configuration{
Type: ThemeConfigurationRedirect,
Data: "https://example.com",
},
},
wantErr: true,
},
"redirect rejects parent traversal": {
theme: Theme{
Configuration: Configuration{
Type: ThemeConfigurationRedirect,
Data: "/../admin",
},
},
wantErr: true,
},
"unknown type": {
theme: Theme{
Configuration: Configuration{Type: "hosted"},
},
wantErr: true,
},
}
for name, tt := range tests {
t.Run(name, func(t *testing.T) {
err := tt.theme.ValidateConfiguration()
if tt.wantErr && err == nil {
t.Fatal("expected error, got nil")
}
if !tt.wantErr && err != nil {
t.Fatalf("unexpected error: %v", err)
}
})
}
}
func TestNormalizeThemeRedirectTarget(t *testing.T) {
tests := map[string]struct {
input string
want string
ok bool
}{
"leading slash": {
input: "/nodes",
want: "/nodes",
ok: true,
},
"missing leading slash": {
input: "nodes",
want: "/nodes",
ok: true,
},
"parent prefix points to root path": {
input: "../settings",
want: "/settings",
ok: true,
},
"multiple parent prefixes point to root path": {
input: "../../settings/site",
want: "/settings/site",
ok: true,
},
"query and fragment": {
input: "nodes?id=1#cpu",
want: "/nodes?id=1#cpu",
ok: true,
},
"absolute parent traversal": {
input: "/../admin",
},
"nested parent traversal": {
input: "nodes/../admin",
},
"protocol relative": {
input: "//example.com",
},
"backslash": {
input: `\admin`,
},
"absolute url": {
input: "https://example.com",
},
}
for name, tt := range tests {
t.Run(name, func(t *testing.T) {
got, ok := NormalizeThemeRedirectTarget(tt.input)
if ok != tt.ok {
t.Fatalf("ok = %v, want %v", ok, tt.ok)
}
if got != tt.want {
t.Fatalf("target = %q, want %q", got, tt.want)
}
})
}
}
+68
View File
@@ -0,0 +1,68 @@
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(&notification).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(&notifications).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
View File
@@ -1 +0,0 @@
package notification
+144
View File
@@ -0,0 +1,144 @@
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(&notifications).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(&notifications).Error
return notifications, err
}
@@ -0,0 +1,36 @@
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)
}
+37
View File
@@ -0,0 +1,37 @@
package database
import (
"github.com/komari-monitor/komari/database/dbcore"
"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
if err := db.Where("name = ?", name).First(&config).Error; err != nil {
return nil, err
}
return &config, nil
}
func DeleteOidcConfigByName(name string) error {
db := dbcore.GetDBInstance()
return db.Delete(&models.OidcProvider{}, "name = ?", name).Error
}
func SaveOidcConfig(config *models.OidcProvider) error {
db := dbcore.GetDBInstance()
if err := db.Save(config).Error; err != nil {
return err
}
return nil
}
+17 -281
View File
@@ -1,302 +1,38 @@
package records
import (
"log"
"sort"
"strings"
"context"
"time"
"gorm.io/gorm"
"github.com/komari-monitor/komari/cmd/flags"
"github.com/komari-monitor/komari/database/dbcore"
"github.com/komari-monitor/komari/database/metricstore"
"github.com/komari-monitor/komari/database/models"
)
func RecordOne(rec models.Record) error {
db := dbcore.GetDBInstance()
return db.Create(&rec).Error
}
// 历史监控数据已完全迁移到 metric store(默认 SQLite ./data/metrics.db,或配置的
// MySQL/PostgreSQL)。旧的 records / records_long_term / gpu_records / ping_records
// 表仅在启动一次性迁移时作为数据源读取,运行期的读写全部走 metric store。
//
// 因此本文件不再保留对旧表的任何读写、压缩(rollup)与流量增量修复逻辑:
// metric store 自行管理 rollup 与保留策略,旧表压缩已无意义。
func DeleteAll() error {
db := dbcore.GetDBInstance()
if err := db.Exec("DELETE FROM records_long_term").Error; err != nil {
return err
}
return db.Exec("DELETE FROM records").Error
return metricstore.DeleteAllRecords(context.Background())
}
func GetLatestRecord(uuid string) (Record []models.Record, err error) {
db := dbcore.GetDBInstance()
err = db.Where("client = ?", uuid).Order("time DESC").Limit(1).Find(&Record).Error
return
// GetGPURecordsByClientAndTime 获取 GPU 记录数据。
func GetGPURecordsByClientAndTime(uuid string, start, end time.Time) ([]models.GPURecord, error) {
return metricstore.GetGPURecordsByClientAndTime(context.Background(), uuid, start, end)
}
func DeleteRecordBefore(before time.Time) error {
db := dbcore.GetDBInstance()
db.Table("records_long_term").Where("time < ?", before).Delete(&models.Record{})
return db.Where("time < ?", before).Delete(&models.Record{}).Error
return metricstore.DeleteRecordsBefore(context.Background(), before)
}
func GetRecordsByClientAndTime(uuid string, start, end time.Time) ([]models.Record, error) {
db := dbcore.GetDBInstance()
var records []models.Record
threeHoursAgo := time.Now().Add(-3 * time.Hour)
var recentRecords []models.Record
recentStart := start
if end.After(threeHoursAgo) {
if recentStart.Before(threeHoursAgo) {
recentStart = threeHoursAgo
}
err := db.Where("client = ? AND time >= ? AND time <= ?", uuid, recentStart, end).Order("time ASC").Find(&recentRecords).Error
if err != nil {
log.Printf("Error fetching recent records for client %s between %s and %s: %v", uuid, recentStart, end, err)
return nil, err
}
}
var long_term []models.Record
err := db.Table("records_long_term").Where("client = ? AND time >= ? AND time <= ?", uuid, start, end).Order("time ASC").Find(&long_term).Error
if err != nil {
log.Printf("Error fetching long-term records for client %s between %s and %s: %v", uuid, start, end, err)
return recentRecords, nil
}
if len(long_term) == 0 {
// 没有查到long_term,返回全部recentRecords
records = append(records, recentRecords...)
return records, nil
}
// 查到了long_term,recentRecords按15分钟分组,每组只保留一条(取最新一条)
grouped := make(map[string]models.Record)
for _, rec := range recentRecords {
key := rec.Time.Truncate(15 * time.Minute).Format(time.RFC3339)
if old, ok := grouped[key]; !ok || rec.Time.After(old.Time) {
grouped[key] = rec
}
}
var groupedList []models.Record
for _, rec := range grouped {
groupedList = append(groupedList, rec)
}
sort.Slice(groupedList, func(i, j int) bool {
return groupedList[i].Time.Before(groupedList[j].Time)
})
records = append(records, groupedList...)
records = append(records, long_term...)
return records, nil
return metricstore.GetRecordsByClientAndTime(context.Background(), uuid, start, end)
}
func GetAllRecords() ([]models.Record, error) {
db := dbcore.GetDBInstance()
var records []models.Record
var long_term []models.Record
err := db.Table("records").Order("time ASC").Find(&records).Error
if err != nil {
log.Printf("Error fetching all records: %v", err)
return nil, err
}
err = db.Table("records_long_term").Order("time ASC").Find(&long_term).Error
if err != nil {
log.Printf("Error fetching long-term records: %v", err)
return records, nil
}
records = append(records, long_term...)
return records, nil
}
// 压缩数据库
func CompactRecord() error {
db := dbcore.GetDBInstance()
err := migrateOldRecords(db)
if err != nil {
log.Printf("Error migrating old records: %v", err)
return err
}
if flags.DatabaseType == "sqlite" {
if err := db.Exec("VACUUM").Error; err != nil {
log.Printf("Error vacuuming database: %v", err)
}
}
log.Printf("Record compaction completed")
return nil
}
func migrateOldRecords(db *gorm.DB) error {
// 计算 4 小时前的时间
fourHoursAgo := time.Now().Add(-4 * time.Hour)
// 查询 records 表中超过 4 小时的记录
var records []models.Record
if err := db.Table("records").Where("time < ?", fourHoursAgo).Find(&records).Error; err != nil {
return err
}
if len(records) == 0 {
return nil
}
// 按 Client 和 15 分钟时间段分组,并存储所有记录以计算分位数
type groupData struct {
Cpu []float32
Gpu []float32
Load []float32
Temp []float32
Ram []int64
RamTotal []int64
Swap []int64
SwapTotal []int64
Disk []int64
DiskTotal []int64
NetIn []int64
NetOut []int64
NetTotalUp []int64
NetTotalDown []int64
Process []int
Connections []int
ConnectionsUdp []int
}
groupedRecords := make(map[string]*groupData)
for _, record := range records {
key := record.Client + "_" + record.Time.Truncate(15*time.Minute).Format(time.RFC3339)
if _, ok := groupedRecords[key]; !ok {
groupedRecords[key] = &groupData{}
}
data := groupedRecords[key]
data.Cpu = append(data.Cpu, record.Cpu)
data.Gpu = append(data.Gpu, record.Gpu)
data.Load = append(data.Load, record.Load)
data.Temp = append(data.Temp, record.Temp)
data.Ram = append(data.Ram, record.Ram)
data.RamTotal = append(data.RamTotal, record.RamTotal)
data.Swap = append(data.Swap, record.Swap)
data.SwapTotal = append(data.SwapTotal, record.SwapTotal)
data.Disk = append(data.Disk, record.Disk)
data.DiskTotal = append(data.DiskTotal, record.DiskTotal)
data.NetIn = append(data.NetIn, record.NetIn)
data.NetOut = append(data.NetOut, record.NetOut)
data.NetTotalUp = append(data.NetTotalUp, record.NetTotalUp)
data.NetTotalDown = append(data.NetTotalDown, record.NetTotalDown)
data.Process = append(data.Process, record.Process)
data.Connections = append(data.Connections, record.Connections)
data.ConnectionsUdp = append(data.ConnectionsUdp, record.ConnectionsUdp)
}
getPercentile := func(values []float64, percentile float64) float64 {
if len(values) == 0 {
return 0
}
sortedValues := make([]float64, len(values))
copy(sortedValues, values)
sort.Float64s(sortedValues)
index := float64(len(sortedValues)-1) * percentile
lowerIndex := int(index)
if lowerIndex >= len(sortedValues)-1 {
return sortedValues[len(sortedValues)-1]
}
frac := index - float64(lowerIndex)
return sortedValues[lowerIndex] + frac*(sortedValues[lowerIndex+1]-sortedValues[lowerIndex])
}
getIntPercentile := func(values []int64, percentile float64) int64 {
if len(values) == 0 {
return 0
}
floats := make([]float64, len(values))
for i, v := range values {
floats[i] = float64(v)
}
return int64(getPercentile(floats, percentile))
}
getInt32Percentile := func(values []int, percentile float64) int {
if len(values) == 0 {
return 0
}
floats := make([]float64, len(values))
for i, v := range values {
floats[i] = float64(v)
}
return int(getPercentile(floats, percentile))
}
return db.Transaction(func(tx *gorm.DB) error {
for key, data := range groupedRecords {
// 解析 Client 和时间
parts := strings.Split(key, "_")
clientUUID := parts[0]
timeSlot, err := time.Parse(time.RFC3339, strings.Join(parts[1:], "_"))
if err != nil {
return err
}
cpuFloats := make([]float64, len(data.Cpu))
for i, v := range data.Cpu {
cpuFloats[i] = float64(v)
}
gpuFloats := make([]float64, len(data.Gpu))
for i, v := range data.Gpu {
gpuFloats[i] = float64(v)
}
loadFloats := make([]float64, len(data.Load))
for i, v := range data.Load {
loadFloats[i] = float64(v)
}
tempFloats := make([]float64, len(data.Temp))
for i, v := range data.Temp {
tempFloats[i] = float64(v)
}
high_percentile := 0.8
// 检查 records_long_term 表中是否已存在相同的记录
var existingCount int64
if err := tx.Table("records_long_term").Where("client = ? AND time = ?", clientUUID, timeSlot).Count(&existingCount).Error; err != nil {
return err
}
newRec := models.Record{
Client: clientUUID,
Time: timeSlot,
Cpu: float32(getPercentile(cpuFloats, high_percentile)),
Gpu: float32(getPercentile(gpuFloats, high_percentile)),
Load: float32(getPercentile(loadFloats, high_percentile)),
Temp: float32(getPercentile(tempFloats, high_percentile)),
Ram: getIntPercentile(data.Ram, high_percentile),
RamTotal: getIntPercentile(data.RamTotal, high_percentile),
Swap: getIntPercentile(data.Swap, high_percentile),
SwapTotal: getIntPercentile(data.SwapTotal, high_percentile),
Disk: getIntPercentile(data.Disk, high_percentile),
DiskTotal: getIntPercentile(data.DiskTotal, high_percentile),
NetIn: getIntPercentile(data.NetIn, high_percentile),
NetOut: getIntPercentile(data.NetOut, high_percentile),
NetTotalUp: getIntPercentile(data.NetTotalUp, high_percentile),
NetTotalDown: getIntPercentile(data.NetTotalDown, high_percentile),
Process: getInt32Percentile(data.Process, high_percentile),
Connections: getInt32Percentile(data.Connections, high_percentile),
ConnectionsUdp: getInt32Percentile(data.ConnectionsUdp, high_percentile),
}
// 如果记录已存在则更新,否则创建新记录
if existingCount > 0 {
if err := tx.Table("records_long_term").Where("client = ? AND time = ?", clientUUID, timeSlot).Updates(&newRec).Error; err != nil {
return err
}
} else {
if err := tx.Table("records_long_term").Create(&newRec).Error; err != nil {
return err
}
}
}
// 删除 records 表中的旧数据
if err := tx.Table("records").Where("time < ?", fourHoursAgo).Delete(&models.Record{}).Error; err != nil {
return err
}
return nil
})
// GetRecordsByTime 获取所有客户端在时间范围内的记录。
func GetRecordsByTime(start, end time.Time) ([]models.Record, error) {
return metricstore.GetRecordsByTime(context.Background(), start, end)
}
-130
View File
@@ -1,130 +0,0 @@
package records
import (
"encoding/csv"
"os"
"strconv"
"testing"
"time"
"github.com/stretchr/testify/assert"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"github.com/komari-monitor/komari/database/models"
)
var uuid = "7901508c-304f-49aa-b84f-957c33ae6f8a"
var _ = func() bool {
// 确保 Test 环境中使用 sqlite 内存数据库
return true
}()
// TestCompactRecord tests the database compaction logic by inserting 4h30m of data (one record per minute),
// then running migrateOldRecords and verifying the aggregation and cleanup.
func TestCompactRecord(t *testing.T) {
const totalMinutes = 12*60 + 30
now := time.Now()
threshold := now.Add(-4 * time.Hour)
// 使用 sqlite 内存数据库并迁移表结构
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
assert.NoError(t, err)
assert.NoError(t, db.AutoMigrate(&models.Record{}))
assert.NoError(t, db.Table("records_long_term").AutoMigrate(&models.Record{}))
expectedGroups := make(map[time.Time]struct{})
expectedRemain := 0
// 插入数据
for i := 0; i < totalMinutes; i++ {
recTime := now.Add(-time.Duration(i) * time.Minute)
rec := models.Record{Client: uuid, Time: recTime, Cpu: float32(i), Gpu: float32(i), Load: float32(i), Temp: float32(i), Ram: int64(i)}
err := db.Create(&rec).Error
assert.NoError(t, err)
if recTime.Before(threshold) {
slot := recTime.Truncate(time.Hour)
expectedGroups[slot] = struct{}{}
} else {
expectedRemain++
}
}
// 导出原始数据到 CSV
os.MkdirAll("../../data", 0755)
var origRecs []models.Record
db.Order("time desc").Find(&origRecs)
fOrig, err := os.Create("../../data/original.csv")
assert.NoError(t, err)
defer fOrig.Close()
wOrig := csv.NewWriter(fOrig)
defer wOrig.Flush()
wOrig.Write([]string{"Client", "Time", "Cpu", "Gpu", "Load", "Temp", "Ram"})
for _, r := range origRecs {
wOrig.Write([]string{
r.Client,
r.Time.Format(time.RFC3339),
strconv.FormatFloat(float64(r.Cpu), 'f', -1, 32),
strconv.FormatFloat(float64(r.Gpu), 'f', -1, 32),
strconv.FormatFloat(float64(r.Load), 'f', -1, 32),
strconv.FormatFloat(float64(r.Temp), 'f', -1, 32),
strconv.FormatInt(r.Ram, 10),
})
}
// 运行压缩(迁移)逻辑
err = migrateOldRecords(db)
assert.NoError(t, err)
// 验证 long-term 表中的聚合记录数
var longCount int64
assert.NoError(t, db.Table("records_long_term").Count(&longCount).Error)
assert.Equal(t, int64(len(expectedGroups)), longCount)
// 验证原始表中剩余记录数
var remainCount int64
assert.NoError(t, db.Table("records").Count(&remainCount).Error)
assert.Equal(t, int64(expectedRemain), remainCount+1)
// 导出压缩后的数据到 CSV
var compRecs []models.Record
db.Table("records_long_term").Order("time desc").Find(&compRecs)
fComp, err := os.Create("../../data/compressed.csv")
assert.NoError(t, err)
defer fComp.Close()
wComp := csv.NewWriter(fComp)
defer wComp.Flush()
wComp.Write([]string{"Client", "Time", "Cpu", "Gpu", "Load", "Temp", "Ram"})
for _, r := range compRecs {
wComp.Write([]string{
r.Client,
r.Time.Format(time.RFC3339),
strconv.FormatFloat(float64(r.Cpu), 'f', -1, 32),
strconv.FormatFloat(float64(r.Gpu), 'f', -1, 32),
strconv.FormatFloat(float64(r.Load), 'f', -1, 32),
strconv.FormatFloat(float64(r.Temp), 'f', -1, 32),
strconv.FormatInt(r.Ram, 10),
})
}
db.Table("records").Order("time desc").Find(&compRecs)
fComp, err = os.Create("../../data/compressed_records.csv")
assert.NoError(t, err)
defer fComp.Close()
wComp = csv.NewWriter(fComp)
defer wComp.Flush()
wComp.Write([]string{"Client", "Time", "Cpu", "Gpu", "Load", "Temp", "Ram"})
for _, r := range compRecs {
wComp.Write([]string{
r.Client,
r.Time.Format(time.RFC3339),
strconv.FormatFloat(float64(r.Cpu), 'f', -1, 32),
strconv.FormatFloat(float64(r.Gpu), 'f', -1, 32),
strconv.FormatFloat(float64(r.Load), 'f', -1, 32),
strconv.FormatFloat(float64(r.Temp), 'f', -1, 32),
strconv.FormatInt(r.Ram, 10),
})
}
}
+126 -31
View File
@@ -1,24 +1,45 @@
package tasks
import (
"context"
"time"
"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/utils"
"gorm.io/gorm"
)
func AddPingTask(clients []string, name string, target, task_type string, interval int) (uint, error) {
// AddPingTask 创建延迟监测任务。defaultOn 表示新加入的服务器是否自动开启此监测。
func AddPingTask(clients []string, defaultOn bool, name string, target, task_type string, interval int) (uint, error) {
db := dbcore.GetDBInstance()
normalizedClients := normalizePingClients(models.StringArray(clients))
task := models.PingTask{
Clients: clients,
Name: name,
Type: task_type,
Target: target,
Interval: interval,
Clients: normalizedClients,
DefaultOn: defaultOn,
Name: name,
Type: task_type,
Target: target,
Interval: interval,
}
if err := db.Create(&task).Error; err != nil {
err := db.Transaction(func(tx *gorm.DB) error {
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
})
if err != nil {
return 0, err
}
ReloadPingSchedule()
@@ -35,10 +56,21 @@ func DeletePingTask(id []uint) error {
return result.Error
}
// EditPingTask 批量更新延迟监测任务配置。
func EditPingTask(tasks []*models.PingTask) error {
db := dbcore.GetDBInstance()
for _, task := range tasks {
result := db.Model(&models.PingTask{}).Where("id = ?", task.Id).Updates(task)
task.Clients = normalizePingClients(task.Clients)
// 使用 map 显式更新,避免 GORM struct Updates 跳过 false/0/空切片等零值。
updates := map[string]interface{}{
"name": task.Name,
"clients": task.Clients,
"all_clients": task.DefaultOn,
"type": task.Type,
"target": task.Target,
"interval": task.Interval,
}
result := db.Model(&models.PingTask{}).Where("id = ?", task.Id).Updates(updates)
if result.RowsAffected == 0 {
return gorm.ErrRecordNotFound
}
@@ -47,42 +79,71 @@ func EditPingTask(tasks []*models.PingTask) error {
return nil
}
// normalizePingClients 保持 clients 字段序列化为 JSON 数组,避免空值变成 null。
func normalizePingClients(clients models.StringArray) models.StringArray {
if clients == nil {
return models.StringArray{}
}
return clients
}
func GetAllPingTasks() ([]models.PingTask, error) {
db := dbcore.GetDBInstance()
var tasks []models.PingTask
if err := db.Find(&tasks).Error; err != nil {
if err := db.Order("weight ASC").Order("id ASC").Find(&tasks).Error; err != nil {
return nil, err
}
return tasks, nil
}
func SavePingRecord(record models.PingRecord) error {
// GetPingTasksByClient 获取指定服务器需要执行的延迟监测任务。
func GetPingTasksByClient(uuid string) []models.PingTask {
db := dbcore.GetDBInstance()
return db.Create(&record).Error
var tasks []models.PingTask
if err := db.Where("clients LIKE ?", `%"`+uuid+`"%`).Find(&tasks).Error; err != nil {
return nil
}
return tasks
}
func GetPingRecords(client string) ([]models.PingRecord, error) {
func UpdatePingTaskOrder(order map[uint]int) error {
db := dbcore.GetDBInstance()
var records []models.PingRecord
if err := db.Where("client = ?", client).Order("time DESC").Find(&records).Error; err != nil {
return nil, err
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
}
}
return nil
})
if err != nil {
return err
}
return records, nil
ReloadPingSchedule()
return nil
}
// ping 记录已完全迁移到 metric store(指标 ping.latency_ms),运行期读写全部走
// metric store,旧 ping_records 表不再参与。
func SavePingRecord(record models.PingRecord) error {
return metricstore.WritePingRecord(context.Background(), record)
}
func DeletePingRecordsBefore(time time.Time) error {
db := dbcore.GetDBInstance()
err := db.Where("time < ?", time).Delete(&models.PingRecord{}).Error
return err
return metricstore.DeletePingRecordsBefore(context.Background(), time)
}
func DeletePingRecords(id []uint) error {
db := dbcore.GetDBInstance()
result := db.Where("task_id IN ?", id).Delete(&models.PingRecord{})
if result.RowsAffected == 0 {
return gorm.ErrRecordNotFound
}
return result.Error
return metricstore.DeletePingRecordsByTask(context.Background(), id)
}
func DeleteAllPingRecords() error {
return metricstore.DeleteAllPingRecords(context.Background())
}
func ReloadPingSchedule() error {
@@ -93,11 +154,45 @@ func ReloadPingSchedule() error {
}
return utils.ReloadPingSchedule(pingTasks)
}
func GetPingRecordsByClientAndTime(uuid string, start, end time.Time) ([]models.PingRecord, error) {
db := dbcore.GetDBInstance()
var records []models.PingRecord
if err := db.Where("client = ? AND time >= ? AND time <= ?", uuid, start, end).Order("time DESC").Find(&records).Error; err != nil {
return nil, err
// AddDefaultOnClientUUID 在新客户端注册后,把该 UUID 追加到所有 default_on=true 的任务的 clients 中(去重)。
func AddDefaultOnClientUUID(uuid string) error {
if uuid == "" {
return nil
}
return records, 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 {
continue
}
next := append(models.StringArray{}, task.Clients...)
next = append(next, uuid)
if err := db.Model(&models.PingTask{}).Where("id = ?", task.Id).Update("clients", next).Error; err != nil {
return err
}
changed = true
}
if changed {
return ReloadPingSchedule()
}
return nil
}
func GetPingRecords(uuid string, taskId int, start, end time.Time) ([]models.PingRecord, error) {
return metricstore.GetPingRecords(context.Background(), uuid, taskId, start, end)
}
+2 -2
View File
@@ -26,7 +26,7 @@ func CreateTask(taskId string, clients []string, command string) error {
Result: "",
ExitCode: nil,
FinishedAt: nil,
CreatedAt: time.Now(),
CreatedAt: models.FromTime(time.Now()),
})
}
if len(taskResults) > 0 {
@@ -83,7 +83,7 @@ func DeleteTaskByTaskId(taskId string) error {
return dbcore.GetDBInstance().Where("task_id = ?", taskId).Delete(&models.Task{}).Error
}
func SaveTaskResult(taskId, clientId, result string, exitCode int, timestamp time.Time) error {
func SaveTaskResult(taskId, clientId, result string, exitCode int, timestamp models.LocalTime) error {
return dbcore.GetDBInstance().
Model(&models.TaskResult{}).
Where("task_id = ? AND client = ?", taskId, clientId).
+141
View File
@@ -0,0 +1,141 @@
package database
import (
"encoding/json"
"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/pkg/config"
)
func GetPublicInfo() (map[string]interface{}, error) {
cstPtr, err := config.GetManyAs[config.Settings]()
if err != nil {
return nil, err
}
cst := *cstPtr
all, allErr := config.GetAll()
hasKey := func(k string) bool {
if allErr != nil {
return false
}
_, ok := all[k]
return ok
}
// Apply defaults only when a key is missing.
if !hasKey("sitename") {
cst.Sitename = "Komari"
}
if !hasKey("description") {
cst.Description = "Komari Monitor, a simple server monitoring tool."
}
if !hasKey("theme") {
cst.Theme = "default"
}
if !hasKey("o_auth_provider") {
cst.OAuthProvider = "github"
}
// Fallback defaults if we couldn't enumerate keys.
if allErr != nil {
if cst.Sitename == "" {
cst.Sitename = "Komari"
}
if cst.Description == "" {
cst.Description = "Komari Monitor, a simple server monitoring tool."
}
}
metricCfg, err := config.GetManyAs[metricstore.MetricStoreConfig]()
if err != nil {
return nil, err
}
db := dbcore.GetDBInstance()
tc := models.ThemeConfiguration{}
err = db.Model(&models.ThemeConfiguration{}).Where("short = ?", cst.Theme).First(&tc).Error
if err != nil {
tc.Data = "{}"
}
tc_data := gin.H{}
err = json.Unmarshal([]byte(tc.Data), &tc_data)
if err != nil {
log.Printf("%v", err)
}
// Try to load theme declaration file and merge defaults for managed configuration
// Theme declarations live in ./data/theme/<short>/komari-theme.json
if cst.Theme != "" && cst.Theme != "default" {
themeConfigPath := filepath.Join("./data/theme", cst.Theme, "komari-theme.json")
if _, err := os.Stat(themeConfigPath); err == nil {
b, err := os.ReadFile(themeConfigPath)
if err == nil {
var themeDecl struct {
Configuration struct {
Type string `json:"type"`
Data []models.ManagedThemeConfigurationItem `json:"data"`
} `json:"configuration"`
}
if err := json.Unmarshal(b, &themeDecl); err == nil {
if themeDecl.Configuration.Type == "managed" {
for _, item := range themeDecl.Configuration.Data {
if item.Key == "" {
continue
}
// missing
if _, exists := tc_data[item.Key]; !exists {
var def any = item.Default
// select
if item.Type == "select" {
if def == nil || def == "" {
if item.Options != "" {
opts := strings.Split(item.Options, ",")
if len(opts) > 0 {
def = strings.TrimSpace(opts[0])
}
}
}
}
// number->0, string->"", switch->false
if def == nil {
switch item.Type {
case "number":
def = 0
case "switch":
def = false
default:
def = ""
}
}
tc_data[item.Key] = def
}
}
}
}
}
}
}
return gin.H{
"sitename": cst.Sitename,
"description": cst.Description,
"custom_head": cst.CustomHead,
"custom_body": cst.CustomBody,
"oauth_enable": cst.OAuthEnabled,
"oauth_provider": cst.OAuthProvider,
"disable_password_login": cst.DisablePasswordLogin,
"cors_origin_check_enabled": cst.CorsOriginCheckEnabled,
"record_enabled": metricCfg.RetentionDays > 0, //兼容旧版本主题
"record_preserve_time": metricCfg.RetentionDays * 24, //兼容旧版本主题
"ping_record_preserve_time": metricCfg.RetentionDays * 24, //兼容旧版本主题
"metric_retention_days": metricCfg.RetentionDays,
"private_site": cst.PrivateSite,
"theme": cst.Theme,
"theme_settings": tc_data,
}, nil
}
+148
View File
@@ -0,0 +1,148 @@
# Komari
![Badge](https://hitscounter.dev/api/hit?url=https%3A%2F%2Fgithub.com%2Fkomari-monitor%2Fkomari&label=&icon=github&color=%23a370f7&message=&style=flat&tz=UTC)
![komari](https://socialify.git.ci/komari-monitor/komari/image?description=1&font=Inter&forks=1&issues=1&language=1&logo=https%3A%2F%2Fraw.githubusercontent.com%2Fkomari-monitor%2Fkomari-web%2Fd54ce1288df41ead08aa19f8700186e68028a889%2Fpublic%2Ffavicon.png&name=1&owner=1&pattern=Plus&pulls=1&stargazers=1&theme=Auto)
Komariは、サーバーのパフォーマンスを監視するためのシンプルで効率的なソリューションを提供することを目的とした、軽量の自己ホスト型サーバー監視ツールです。Webインターフェースを介してサーバーのステータスを表示し、軽量エージェントを介してデータを収集します。
> [!WARNING]
> Komariは、自己ホスト型の監視/制御プログラムであり、所有しているシステム、または管理権限を得ているシステムでのみ使用してください。Komariを武器化したり、許可なく展開、アクセス、永続化、コマンド実行、その他の不正利用に使用したりしないでください。実際の悪用リスクについては、Huntressの分析をご参照ください:[Komari C2 agent abuse](https://www.huntress.com/blog/komari-c2-agent-abuse)。
> Komariの展開および運用方法については、利用者自身が責任を負います。開発者は、無許可または不正な利用、およびその結果について責任を負いません。
> Windows端末でリモートコントロールを有効にした場合、クライアントはユーザーがログインするたびに、KomariがリモートコントロールソフトウェアであることをWindows通知で知らせます。
[ドキュメント](https://komari-document.pages.dev/) | [Telegramグループ](https://t.me/komari_monitor)
## 特徴
- **軽量で効率的**: リソース消費が少なく、あらゆる規模のサーバーに適しています。
- **自己ホスト型**: データプライバシーを完全に制御でき、展開も簡単です。
- **Webインターフェース**: 直感的な監視ダッシュボードで、使いやすいです。
## クイックスタート
### 0. クラウドホスティングによるワンクリック展開
- Rainyun - CNY 4.5/月
[![](https://rainyun-apps.cn-nb1.rains3.com/materials/deploy-on-rainyun-cn.svg)](https://app.rainyun.com/apps/rca/store/6780/NzYxNzAz_)
- 1Panel アプリストア
1Panel アプリストアで利用可能です。「アプリストア」>「ユーティリティ」>「Komari」からインストールしてください。
### 1. ワンクリックインストールスクリプトを使用する
systemdを使用するディストリビューション(Ubuntu、Debianなど)に適しています。
```bash
curl -fsSL https://raw.githubusercontent.com/komari-monitor/komari/main/install-komari.sh -o install-komari.sh
chmod +x install-komari.sh
sudo ./install-komari.sh
```
### 2. Docker展開
1. データディレクトリを作成します:
```bash
mkdir -p ./data
```
2. Dockerコンテナを実行します:
```bash
docker run -d \
-p 25774:25774 \
-v $(pwd)/data:/app/data \
--name komari \
ghcr.io/komari-monitor/komari:latest
```
3. デフォルトのユーザー名とパスワードを表示します:
```bash
docker logs komari
```
4. ブラウザで `http://<your_server_ip>:25774` にアクセスします。
> [!NOTE]
> 環境変数 `ADMIN_USERNAME` と `ADMIN_PASSWORD` を使用して、初期のユーザー名とパスワードをカスタマイズすることもできます。
### 3. バイナリファイル展開
1. Komariの[GitHubリリース](https://github.com/komari-monitor/komari/releases)ページにアクセスして、お使いのオペレーティングシステム用の最新のバイナリをダウンロードします。
2. Komariを実行します:
```bash
./komari server -l 0.0.0.0:25774
```
3. ブラウザで `http://<your_server_ip>:25774` にアクセスします。デフォルトのポートは `25774` です。
4. デフォルトのユーザー名とパスワードは、起動ログで確認するか、環境変数 `ADMIN_USERNAME` と `ADMIN_PASSWORD` を介して設定できます。
> [!NOTE]
> バイナリに実行権限があることを確認してください(`chmod +x komari`)。データは実行ディレクトリの `data` フォルダに保存されます。
### 手動ビルド
#### 依存関係
- Go 1.18+ および Node.js 20+(手動ビルド用)
1. フロントエンドの静的ファイルをビルドします:
```bash
git clone https://github.com/komari-monitor/komari-web
cd komari-web
npm install
npm run build
```
2. バックエンドをビルドします:
```bash
git clone https://github.com/komari-monitor/komari
cd komari
```
ステップ1で生成された静的ファイルを `komari` プロジェクトのルートにある `/web/public/defaultTheme/dist` フォルダにコピーし、`komari-theme.json` と `preview.png`/`perview.png` を `/web/public/defaultTheme` にコピーします。
```bash
go build -o komari
```
3. 実行:
```bash
./komari server -l 0.0.0.0:25774
```
デフォルトのリスニングポートは `25774` です。`http://localhost:25774` にアクセスします。
## フロントエンド開発ガイド
[Komariテーマ開発ガイド | Komari](https://komari-document.pages.dev/dev/theme.html)
[CrowdinでKomariを翻訳する](https://crowdin.com/project/komari/invite?h=cd051bf172c9a9f7f1360e87ffb521692507706)
## クライアントエージェント開発ガイド
[Komariエージェント情報レポートおよびイベント処理ドキュメント](https://komari-document.pages.dev/dev/agent.html)
## 貢献
IssueやPull Requestを歓迎します!
## 謝辞
### 破碎工坊云 (Crash Work)
[破碎工坊云 - 効率的で安定した、安全な高防御サーバーとCDNソリューションを提供する専門的なクラウドコンピューティングプラットフォーム](https://www.crash.work/)
### DreamCloud
[DreamCloud - コストパフォーマンスに優れたアジア太平洋向け高防御直通](https://as211392.com/)
### 🚀 SharonNetworks スポンサー
[![Sharon Networks](https://raw.githubusercontent.com/komari-monitor/public/refs/heads/main/images/sharon-networks.webp)](https://sharon.io)
SharonNetworks は、あなたのビジネスの離陸を力強くサポートします!
アジア太平洋のデータセンターから中国最適化ネットワーク接続を提供。低レイテンシー & 高帯域幅、Tbps 級のローカル洗浄 DDoS 防御で、ビジネスとお客様の体験を守ります。コミュニティ [Telegram グループ](https://t.me/SharonNetwork) に参加すると、チャリティまたはグループ内抽選で無料利用のチャンスがあります。
### オープンソースコミュニティ
PR を送ってくれた方、テーマを作成してくれた全ての開発者
—— そして:こんなに暇でいられる自分に感謝
## Star履歴
[![Star History Chart](https://api.star-history.com/svg?repos=komari-monitor/komari&type=Date)](https://www.star-history.com/#komari-monitor/komari&Date)
+148
View File
@@ -0,0 +1,148 @@
# Komari
![Badge](https://hitscounter.dev/api/hit?url=https%3A%2F%2Fgithub.com%2Fkomari-monitor%2Fkomari&label=&icon=github&color=%23a370f7&message=&style=flat&tz=UTC)
![komari](https://socialify.git.ci/komari-monitor/komari/image?description=1&font=Inter&forks=1&issues=1&language=1&logo=https%3A%2F%2Fraw.githubusercontent.com%2Fkomari-monitor%2Fkomari-web%2Fd54ce1288df41ead08aa19f8700186e68028a889%2Fpublic%2Ffavicon.png&name=1&owner=1&pattern=Plus&pulls=1&stargazers=1&theme=Auto)
Komari 是一款輕量級的自託管伺服器監控工具,旨在提供簡單、高效的伺服器性能監控解決方案。它支援透過 Web 介面查看伺服器狀態,並透過輕量級 Agent 收集數據。
> [!WARNING]
> Komari 是一款自託管的監控/控制程式,僅應部署在你擁有或已獲授權管理的系統上。請勿將 Komari 武器化,或在未獲授權的情況下部署、存取、持久化、執行命令及從事其他濫用行為。關於現實中的濫用風險,可參考 Huntress 的分析:[Komari C2 agent abuse](https://www.huntress.com/blog/komari-c2-agent-abuse)。
> 使用者需要自行承擔部署和使用 Komari 的責任。開發者不對未經授權或濫用行為及其後果承擔責任。
> 在 Windows 端啟用遠端控制後,客戶端會在每次使用者登入時透過 Windows 通知提醒使用者 Komari 是一款遠端控制軟體。
[文檔](https://komari-document.pages.dev/) | [Telegram 群組](https://t.me/komari_monitor)
## 特性
- **輕量高效**:低資源佔用,適合各種規模的伺服器。
- **自託管**:完全掌控數據隱私,部署簡單。
- **Web 介面**:直觀的監控儀表盤,易於使用。
## 快速開始
### 0. 容器雲一鍵部署
- 雨雲雲應用 - CNY 4.5/月
[![](https://rainyun-apps.cn-nb1.rains3.com/materials/deploy-on-rainyun-cn.svg)](https://app.rainyun.com/apps/rca/store/6780/NzYxNzAz_)
- 1Panel 應用商店
已上架 1Panel 應用商店,應用商店-實用工具-Komari 即可安裝
### 1. 使用一鍵安裝腳本
適用於使用了 systemd 的發行版(Ubuntu、Debian...)。
```bash
curl -fsSL https://raw.githubusercontent.com/komari-monitor/komari/main/install-komari.sh -o install-komari.sh
chmod +x install-komari.sh
sudo ./install-komari.sh
```
### 2. Docker 部署
1. 建立資料目錄:
```bash
mkdir -p ./data
```
2. 執行 Docker 容器:
```bash
docker run -d \
-p 25774:25774 \
-v $(pwd)/data:/app/data \
--name komari \
ghcr.io/komari-monitor/komari:latest
```
3. 查看預設帳號和密碼:
```bash
docker logs komari
```
4. 在瀏覽器中存取 `http://<your_server_ip>:25774`。
> [!NOTE]
> 你也可以透過環境變數 `ADMIN_USERNAME` 和 `ADMIN_PASSWORD` 自訂初始使用者名稱和密碼。
### 3. 二進位檔案部署
1. 存取 Komari 的 [GitHub Release 頁面](https://github.com/komari-monitor/komari/releases) 下載適用於你作業系統的最新二進位檔案。
2. 執行 Komari:
```bash
./komari server -l 0.0.0.0:25774
```
3. 在瀏覽器中存取 `http://<your_server_ip>:25774`,預設監聽 `25774` 連接埠。
4. 預設帳號和密碼可在啟動日誌中查看,或透過環境變數 `ADMIN_USERNAME` 和 `ADMIN_PASSWORD` 設定。
> [!NOTE]
> 確保二進位檔案具有可執行權限(`chmod +x komari`)。資料將保存在執行目錄下的 `data` 資料夾中。
### 手工建置
#### 依賴
- Go 1.18+ 和 Node.js 20+(手工建置)
1. 建置前端靜態檔案:
```bash
git clone https://github.com/komari-monitor/komari-web
cd komari-web
npm install
npm run build
```
2. 建置後端:
```bash
git clone https://github.com/komari-monitor/komari
cd komari
```
將步驟1中產生的靜態檔案複製到 `komari` 專案根目錄下的 `/web/public/defaultTheme/dist` 資料夾,並將 `komari-theme.json` 與 `preview.png`/`perview.png` 複製到 `/web/public/defaultTheme`。
```bash
go build -o komari
```
3. 執行:
```bash
./komari server -l 0.0.0.0:25774
```
預設監聽 `25774` 連接埠,存取 `http://localhost:25774`。
## 前端開發指南
[Komari 主題開發指南 | Komari](https://komari-document.pages.dev/dev/theme.html)
[在 Crowdin 上翻譯 Komari](https://crowdin.com/project/komari/invite?h=cd051bf172c9a9f7f1360e87ffb521692507706)
## 客戶端 Agent 開發指南
[Komari Agent 資訊上報與事件處理文檔](https://komari-document.pages.dev/dev/agent.html)
## 貢獻
歡迎提交 Issue 或 Pull Request!
## 鳴謝
### 破碎工坊雲
[破碎工坊雲 - 專業雲計算服務平台,提供高效、穩定、安全的高防伺服器與CDN解決方案](https://www.crash.work/)
### DreamCloud
[DreamCloud - 極高性價比解鎖直連亞太高防](https://as211392.com/)
### 🚀 由 SharonNetworks 贊助
[![Sharon Networks](https://raw.githubusercontent.com/komari-monitor/public/refs/heads/main/images/sharon-networks.webp)](https://sharon.io)
SharonNetworks 為您的業務起飛保駕護航!
亞太資料中心提供頂級的中國優化網路接入 · 低延遲 & 高頻寬 & 提供 Tbps 級本地清洗高防服務,為您的業務保駕護航,為您的客戶提供極致體驗。加入社群 [Telegram 群組](https://t.me/SharonNetwork) 可參與公益募捐或群內抽獎免費使用。
### 開源社群
提交 PR、製作主題的各位開發者
—— 以及:感謝我自己能這麼閒
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=komari-monitor/komari&type=Date)](https://www.star-history.com/#komari-monitor/komari&Date)
+148
View File
@@ -0,0 +1,148 @@
# Komari
![Badge](https://hitscounter.dev/api/hit?url=https%3A%2F%2Fgithub.com%2Fkomari-monitor%2Fkomari&label=&icon=github&color=%23a370f7&message=&style=flat&tz=UTC)
![komari](https://socialify.git.ci/komari-monitor/komari/image?description=1&font=Inter&forks=1&issues=1&language=1&logo=https%3A%2F%2Fraw.githubusercontent.com%2Fkomari-monitor%2Fkomari-web%2Fd54ce1288df41ead08aa19f8700186e68028a889%2Fpublic%2Ffavicon.png&name=1&owner=1&pattern=Plus&pulls=1&stargazers=1&theme=Auto)
Komari 是一款轻量级的自托管服务器监控工具,旨在提供简单、高效的服务器性能监控解决方案。它支持通过 Web 界面查看服务器状态,并通过轻量级 Agent 收集数据。
> [!WARNING]
> Komari 是一款自托管的监控/控制程序,仅应部署在你拥有或已获得授权管理的系统上。请勿将 Komari 武器化,或在未获授权的情况下部署、访问、持久化、执行命令及从事其他滥用行为。关于现实中的滥用风险,可参考 Huntress 的分析:[Komari C2 agent abuse](https://www.huntress.com/blog/komari-c2-agent-abuse)。
> 用户需要自行承担部署和使用 Komari 的责任。开发者不对未经授权或滥用行为及其后果承担责任。
> 在 Windows 端开启远程控制后,客户端会在每次用户登录时通过 Windows 通知提醒用户 Komari 是一款远程控制软件。
[文档](https://komari-document.pages.dev/) | [文档(镜像站 By Geekertao)](https://www.komari.wiki) | [Telegram 群组](https://t.me/komari_monitor)
## 特性
- **轻量高效**:低资源占用,适合各种规模的服务器。
- **自托管**:完全掌控数据隐私,部署简单。
- **Web 界面**:直观的监控仪表盘,易于使用。
## 快速开始
### 0. 容器云一键部署
- 雨云云应用 - CNY 4.5/月
[![](https://rainyun-apps.cn-nb1.rains3.com/materials/deploy-on-rainyun-cn.svg)](https://app.rainyun.com/apps/rca/store/6780/NzYxNzAz_)
- 1Panel 应用商店
已上架1Panel应用商店,应用商店-实用工具-Komari 即可安装
### 1. 使用一键安装脚本
适用于使用了 systemd 的发行版(Ubuntu、Debian...)。
```bash
curl -fsSL https://raw.githubusercontent.com/komari-monitor/komari/main/install-komari.sh -o install-komari.sh
chmod +x install-komari.sh
sudo ./install-komari.sh
```
### 2. Docker 部署
1. 创建数据目录:
```bash
mkdir -p ./data
```
2. 运行 Docker 容器:
```bash
docker run -d \
-p 25774:25774 \
-v $(pwd)/data:/app/data \
--name komari \
ghcr.io/komari-monitor/komari:latest
```
3. 查看默认账号和密码:
```bash
docker logs komari
```
4. 在浏览器中访问 `http://<your_server_ip>:25774`。
> [!NOTE]
> 你也可以通过环境变量 `ADMIN_USERNAME` 和 `ADMIN_PASSWORD` 自定义初始用户名和密码。
### 3. 二进制文件部署
1. 访问 Komari 的 [GitHub Release 页面](https://github.com/komari-monitor/komari/releases) 下载适用于你操作系统的最新二进制文件。
2. 运行 Komari:
```bash
./komari server -l 0.0.0.0:25774
```
3. 在浏览器中访问 `http://<your_server_ip>:25774`,默认监听 `25774` 端口。
4. 默认账号和密码可在启动日志中查看,或通过环境变量 `ADMIN_USERNAME` 和 `ADMIN_PASSWORD` 设置。
> [!NOTE]
> 确保二进制文件具有可执行权限(`chmod +x komari`)。数据将保存在运行目录下的 `data` 文件夹中。
### 手工构建
#### 依赖
- Go 1.18+ 和 Node.js 20+(手工构建)
1. 构建前端静态文件:
```bash
git clone https://github.com/komari-monitor/komari-web
cd komari-web
npm install
npm run build
```
2. 构建后端:
```bash
git clone https://github.com/komari-monitor/komari
cd komari
```
将步骤1中生成的静态文件复制到 `komari` 项目根目录下的 `/web/public/defaultTheme/dist` 文件夹,并将 `komari-theme.json` 与 `preview.png`/`perview.png` 复制到 `/web/public/defaultTheme`。
```bash
go build -o komari
```
3. 运行:
```bash
./komari server -l 0.0.0.0:25774
```
默认监听 `25774` 端口,访问 `http://localhost:25774`。
## 前端开发指南
[Komari 主题开发指南 | Komari](https://komari-document.pages.dev/dev/theme.html)
[在 Crowdin 上翻译 Komari](https://crowdin.com/project/komari/invite?h=cd051bf172c9a9f7f1360e87ffb521692507706)
## 客户端 Agent 开发指南
[Komari Agent 信息上报与事件处理文档](https://komari-document.pages.dev/dev/agent.html)
## 贡献
欢迎提交 Issue 或 Pull Request!
## 鸣谢
### 破碎工坊云
[破碎工坊云 - 专业云计算服务平台,提供高效、稳定、安全的高防服务器与CDN解决方案](https://www.crash.work/)
### DreamCloud
[DreamCloud - 极高性价比解锁直连亚太高防](https://as211392.com/)
### 🚀 由 SharonNetworks 赞助
[![Sharon Networks](https://raw.githubusercontent.com/komari-monitor/public/refs/heads/main/images/sharon-networks.webp)](https://sharon.io)
SharonNetworks 为您的业务起飞保驾护航!
亚太数据中心提供顶级的中国优化网络接入 · 低延时 & 高带宽 & 提供 Tbps 级本地清洗高防服务,为您的业务保驾护航,为您的客户提供极致体验。加入社区 [Telegram 群组](https://t.me/SharonNetwork) 可参与公益募捐或群内抽奖免费使用。
### 开源社区
提交 PR、制作主题的各位开发者
—— 以及:感谢我自己能这么闲
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=komari-monitor/komari&type=Date)](https://www.star-history.com/#komari-monitor/komari&Date)
Loaded 100 of 294 files, more files were not shown because too many files have changed in this diff. Show more