Compare commits

...
456 Commits
Author SHA1 Message Date
CityFun 6ea07421ac feat: change feishu channel logic (#12465) 2026-04-10 13:36:25 +08:00
蘭 326d52e311 fix: Fix some issues with sharing files (#12466) 2026-04-10 13:36:10 +08:00
CityFun 6e3f751dba feat: add model name required check for agent (#12463) 2026-04-10 03:30:40 +00:00
ssongliu 6982fd22d4 fix: extend agent role command timeout (#12464) 2026-04-10 03:28:39 +00:00
ssongliu 04fd456b63 fix: silence csrf forbidden toast (#12461) 2026-04-09 22:23:47 +08:00
ssongliu bea4bfeee8 feat: update ai agent config texts 2026-04-09 21:33:16 +08:00
蘭 fa6157b610 feat: enhance AI search functionality with response language support (#12460) 2026-04-09 19:00:26 +08:00
CityFun 30af26779c style: change openclaw channel style (#12459) 2026-04-09 10:48:38 +00:00
CityFun 16d5c00b9a fix: fix issue with openclaw discord config error (#12456) 2026-04-09 17:39:01 +08:00
蘭 3f14f8f30e feat: File management supports link share file (#12453) 2026-04-09 17:38:38 +08:00
ssongliu 218f92a960 fix: fallback to password login when mfa needs captcha (#12438) 2026-04-09 10:28:39 +08:00
ssongliu 75be8d3a23 fix: extend ai agent api timeouts (#12443) 2026-04-09 10:27:28 +08:00
ssongliu e2d8125b3d chore: shorten tamper translations (#12442) 2026-04-09 10:26:41 +08:00
CityFun 7ec3c517c2 feat: Modify the configuration of the OpenClaw DingTalk channel. (#12440) 2026-04-09 10:26:22 +08:00
CityFun e186e30d8c feat: remove deployment type for agent website binding (#12435) 2026-04-09 10:26:00 +08:00
CityFun 67a8ba2421 feat: Add app status sync logic for agent (#12436) 2026-04-08 08:36:40 +00:00
dependabot[bot] 24fc15beaf chore(deps): bump github.com/go-jose/go-jose/v4 in /agent (#12432)
Bumps [github.com/go-jose/go-jose/v4](https://github.com/go-jose/go-jose) from 4.1.3 to 4.1.4.
- [Release notes](https://github.com/go-jose/go-jose/releases)
- [Commits](https://github.com/go-jose/go-jose/compare/v4.1.3...v4.1.4)

---
updated-dependencies:
- dependency-name: github.com/go-jose/go-jose/v4
  dependency-version: 4.1.4
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-07 18:12:42 +08:00
王贺 c1fe70b53e chore: align build workflow runtime versions (#12434) 2026-04-07 18:11:20 +08:00
CityFun 30bf4a8d46 feat: add agent website check for delete (#12433) 2026-04-07 09:56:39 +00:00
CityFun 9e501ce02b feat: Modify the certificate auto-renewal logic. (#12430)
Refs https://github.com/1Panel-dev/1Panel/issues/12389
2026-04-07 09:54:38 +00:00
王贺 d74692a68b fix: optimize runtime script dropdown (#12431) 2026-04-07 17:45:09 +08:00
王贺 57fd80fb3d fix: optimize openresty other page layout (#12429) 2026-04-07 17:44:28 +08:00
KOMATA 39d2c34682 fix: ensure proper resource management in file operations (#12358)
* fix: Ensure proper resource management in file operations by closing files after creation and compression

* fix: Improve error handling for log file operations in MediaFile function
2026-04-07 17:35:31 +08:00
CityFun 3e4a48c892 feat: Modify the OpenClaw plugin installation parameters. (#12427) 2026-04-07 07:33:32 +00:00
CityFun 2c8f8f32fd feat: Add default external port access to application settings. (#12426) 2026-04-07 07:31:32 +00:00
maninhill 3cf6d9ac51 Update user count in README 2026-04-07 15:26:42 +08:00
BugKing 64d8fe8bd0 docs: Update README.md 2026-04-07 15:26:13 +08:00
蘭 8129260508 feat: File management supports AI search (#12415) 2026-04-03 22:11:01 +08:00
ssongliu a40786a7fa refactor: clean up ollama pull log parsing (#12414) 2026-04-03 22:10:47 +08:00
CityFun 368193a777 feat: Add website binding support for the agent (#12413) 2026-04-03 09:15:54 +00:00
ssongliu 61ef725892 feat: improve terminal ai runtime updates (#12409) 2026-04-03 07:58:13 +00:00
CityFun 0c49c41085 feat: change ai menu (#12407) 2026-04-03 07:56:12 +00:00
ssongliu 2a498de96e fix: tighten clean tree delete semantics (#12406)
Refs #12399
2026-04-03 07:54:12 +00:00
ssongliu d055cbb053 feat: improve terminal ai notice interaction 2026-04-03 14:28:09 +08:00
ssongliu 4b3f8a9fd0 style: refine app launcher card layout (#12404) 2026-04-03 11:23:27 +08:00
CityFun 4c26945910 feat: update default model config (#12403) 2026-04-02 10:30:07 +00:00
ssongliu 55b6104b56 fix: reset dashboard delta baseline after resume (#12402)
Refs #12067
2026-04-02 10:28:08 +00:00
CityFun 12dc8aae81 feat: Optimize Monaco editor loading (#12401) 2026-04-02 10:26:08 +00:00
ssongliu 37e2b93693 refactor: replace el-dialog with DialogPro (#12400) 2026-04-02 10:24:09 +00:00
ssongliu 62daf1e019 fix: improve ssh config file editing and include handling (#12398)
Refs #11899
2026-04-02 10:22:09 +00:00
CityFun f5e75d4bfa feat: The skill marketplace supports ClawHub China acceleration. (#12396) 2026-04-02 06:02:06 +00:00
ssongliu 32fed45c1e fix: skip proxy check when disabled (#12388)
Refs #12379
2026-04-01 11:22:08 +00:00
ssongliu 8b7597abef chore: upgrade grpc buildkit and x/image (#12387) 2026-04-01 11:20:08 +00:00
CityFun 01fb9a461b feat: OpenClaw supports configuring backup models. (#12386) 2026-04-01 11:18:09 +00:00
ssongliu bc022a4d21 refactor: simplify agent role binding inputs (#12385) 2026-04-01 11:16:09 +00:00
蘭 973cab2837 fix: Update ESLint plugin and solve the issue of code filter overflow in the development environment (#12383) 2026-04-01 09:14:09 +00:00
ssongliu 5ce155eee6 feat: add agent role binding management (#12382) 2026-04-01 09:12:07 +00:00
CityFun d83b49cc70 feat: Add more configuration options to channels. (#12380) 2026-04-01 08:24:08 +00:00
ssongliu ec981f8153 fix: harden login IP checks and entrance cookie (#12378) 2026-04-01 08:22:09 +00:00
ssongliu b2853277e7 fix: improve auth lock handling and login feedback (#12377) 2026-03-31 11:00:06 +00:00
ssongliu f573a0fa0e fix: improve request validation and session lifecycle (#12375) 2026-03-31 10:58:05 +00:00
CityFun f787734feb feat: Support uninstallation and upgrades of OpenClaw plugins. (#12373) 2026-03-31 10:08:05 +00:00
CityFun 52778f520a feat: QQ and Feishu use official plugins.QQ, Feishu, DingTalk, Telegram, and Discord support multiple accounts. (#12369)
feat: QQ and Feishu use official plugins.QQ, Feishu, DingTalk, Telegram, and Discord support multiple accounts.
2026-03-31 05:48:04 +00:00
ssongliu b5aba18248 fix: harden core memory session handling (#12366) 2026-03-30 14:30:06 +00:00
ssongliu bc9882da50 fix: guard mfa login with ip tracker (#12365) 2026-03-30 22:28:32 +08:00
ssongliu 0ba7e98e8d feat: add agent config role management (#12364) 2026-03-30 08:59:06 +00:00
王贺 e6dc44d135 chore: refine i18n (#12350)
* chore: refine i18n

* fix overview i18n

* feat: remove joinLabel funciton
2026-03-30 16:54:19 +08:00
蘭 a9f30e9f5f feat: Enhance Bark alert configuration options and visibility (#12362) 2026-03-30 06:55:05 +00:00
CityFun f3bf9d0905 feat: support remark for openclaw (#12361)
Refs https://github.com/1Panel-dev/1Panel/issues/12191
2026-03-30 06:53:05 +00:00
Alan 05ea384a00 feat: Add Bark as a new alert notification channel. (#12338) 2026-03-30 10:58:52 +08:00
maninhill 62c50aafe1 Add video introduction link to README
Added a link to a video introduction of 1Panel.
2026-03-27 09:59:17 +08:00
CityFun 6e052b8ef2 fix: Fixed issue with openclaw skill page return empty (#12347) 2026-03-26 17:57:07 +08:00
ssongliu 1d40bdc11b fix: resolve menu item wrapping issue (#12346) 2026-03-26 09:16:24 +00:00
CityFun 3b62197f29 fix: Fixed issue with openclaw skill page return empty (#12345) 2026-03-26 09:10:24 +00:00
CityFun 2d78ca5a86 fix: Fixed issue with agent acount model name is empty (#12343) 2026-03-26 05:44:27 +00:00
KOMATA a9b65b7ce5 feat: add support for trusted SSL certificates in website proxy configuration (#12342) 2026-03-26 13:42:11 +08:00
wanghe-fit2cloud 291b40a45d chore: fix ru locale formatting 2026-03-26 11:08:36 +08:00
CityFun a959da7d44 feat: Optimize the agent's decision-making logic (#12341) 2026-03-26 10:57:48 +08:00
ssongliu 26c4e8323f fix: update quick command batch separator (#12340) 2026-03-26 02:46:10 +00:00
王贺 0534badce7 fix: unify vLLM copy casing (#12339) 2026-03-26 10:32:36 +08:00
CityFun 594d818db0 style: change openclaw style (#12336) 2026-03-25 10:58:17 +00:00
ssongliu 04dceb93a7 fix: unify terminal scroll sensitivity defaults (#12334)
Refs #11911
2026-03-25 09:34:18 +00:00
CityFun 7b554c0d61 feat: Add skill hub for openclaw (#12333) 2026-03-25 09:32:18 +00:00
ssongliu bb642cd8a8 feat: improve terminal quick command workflow (#12331) 2026-03-25 08:56:18 +00:00
CityFun 40249b527d feat: add version control for openclaw config menu (#12329) 2026-03-25 08:54:18 +00:00
蘭 74ff7ddc19 feat: Upload multiple files and add overall and current file upload progress messages (#12327)
https://github.com/1Panel-dev/1Panel/issues/10593
2026-03-25 07:30:19 +00:00
ssongliu 096ec8edc7 chore: refine terminal ai prefix handling (#12328) 2026-03-25 07:28:22 +00:00
ssongliu 0d19069059 feat: improve terminal ai command interception (#12326) 2026-03-25 06:04:18 +00:00
CityFun 0da833302b feat: change openclaw upgrade logic (#12325)
#### What this PR does / why we need it?

#### Summary of your change

#### Please indicate you've done the following:

- [ ] Made sure tests are passing and test coverage is added if needed.
- [ ] Made sure commit message follow the rule of [Conventional Commits specification](https://www.conventionalcommits.org/).
- [ ] Considered the docs impact and opened a new docs issue or PR with docs changes if needed.
2026-03-25 06:02:17 +00:00
ssongliu 4a69cf0ff5 fix: migrate ai agents menu title 2026-03-24 19:54:38 +08:00
CityFun 39704ef4e5 feat: Add openclaw overview button (#12319) 2026-03-24 10:42:05 +00:00
王贺 e52cfeeb78 fix: sync fit2cloud table locale on first load (#12316) 2026-03-24 18:37:17 +08:00
蘭 476429005c ref: Change tooltip placement from top to left for better visibility (#12317)
#11922
2026-03-24 10:26:05 +00:00
ssongliu 6fb41dc711 feat: refine terminal ai assistant settings (#12315) 2026-03-24 09:10:07 +00:00
CityFun 2cb2058cfa feat: support edit config file (#12313) 2026-03-24 09:08:07 +00:00
蘭 8533dd1d33 fix: Fix the issue where the gz file cannot be previewed after decompression (#12314) 2026-03-24 09:06:06 +00:00
CityFun 0cfbcd25fc feat: Optimize plugin installation logic (#12312) 2026-03-24 08:04:05 +00:00
CityFun b91ca8bdb3 feat: add openclaw skill management menu (#12311) 2026-03-24 05:48:06 +00:00
ssongliu 72764863bc fix: unify terminal ai validation and loading state (#12310) 2026-03-24 05:46:06 +00:00
蘭 a7b9fd0181 feat: add validation and error messages for wget URL input (#12309) 2026-03-24 05:44:07 +00:00
ssongliu 2b3164fe82 chore: format terminal ai i18n entries (#12303) 2026-03-23 17:26:50 +08:00
ssongliu 2c6307049a fix: handle upgrade clean leaf selection (#12302) 2026-03-23 17:15:50 +08:00
CityFun acc1b9270f feat: add npm registry config for openclaw (#12300) 2026-03-23 09:12:03 +00:00
王贺 60e4143525 Format aiSummary string for better readability (#12301) 2026-03-23 16:48:09 +08:00
KOMATA 99bcd6d059 feat: add SSL verification option to website proxy configuration (#12258)
* feat: add SSL verification option to website proxy configuration

* localization: update proxy SSL verification messages in multiple languages
2026-03-23 16:32:27 +08:00
ssongliu c4259dd004 feat: warn when compose file is missing (#12299) 2026-03-23 08:22:03 +00:00
ssongliu 1c5a4bc978 feat: improve container file browser path jump (#12293)
Refs #12282
2026-03-23 08:20:04 +00:00
CityFun 42f6e9a9a6 feat: add wenxin channel for openclaw (#12298) 2026-03-23 07:54:03 +00:00
蘭 569363530a fix: Fix the issue of preserving archive modification time during the decompression process of gz files (#12297) 2026-03-23 07:52:04 +00:00
蘭 ec9eba0b9a feat: enhance tab management and file handling in editor (#12294)
#12098
2026-03-23 07:50:03 +00:00
ssongliu 5376d54089 feat: refine terminal ai settings and safeguards (#12289) 2026-03-23 07:48:04 +00:00
KOMATA c10e5747b0 fix: trim whitespace from command in ExecComposer method (#12290) 2026-03-23 15:47:31 +08:00
jakub961241 5a80931a49 fix: auto theme does not switch when OS changes day/night mode (#6813) (#12278)
When theme is set to "auto" (follow system), changing the OS from
light to dark (or vice versa) without reloading the page had no
effect. The theme was only evaluated once on page load.

Added a matchMedia 'change' event listener that calls switchTheme()
whenever the OS color scheme changes, but only when theme is "auto".
Listener is properly cleaned up on component unmount.

Fixes #6813 (point 1)
2026-03-23 14:57:15 +08:00
jakub961241 f541379daa fix: open container directory at WorkingDir instead of root (#12265)
When clicking "Directory" on a container, the file browser always
opened at `/` (root). Now it inspects the container first and opens
at the container's configured WorkingDir (e.g., `/app`, `/var/www`).

Falls back to `/` if WorkingDir is not set or inspect fails.

Fixes #12255
2026-03-23 14:52:57 +08:00
王贺 0018335d64 fix: unify sidebar menu item heights (#12253) 2026-03-20 18:32:17 +08:00
CityFun df90286253 feat: optimize the validation logic for model accounts (#12251) 2026-03-20 09:55:54 +00:00
ssongliu 8dfa7945d7 feat: add xpack node multi overview entry (#12248)
Refs #11735
2026-03-20 09:53:54 +00:00
王贺 1f4292d9cf fix: add missing ssl column controls (#12252) 2026-03-20 17:41:50 +08:00
蘭 e635a7beba feat: add stop functionality for wget file downloads (#12245)
#9791
2026-03-20 09:05:54 +00:00
CityFun 59c657e403 feat: agent model account support xiaomi (#12242)
https://github.com/1Panel-dev/1Panel/issues/12220
2026-03-20 07:27:55 +00:00
ssongliu ff8f79ed52 fix: align compose partial status actions (#12239)
Refs #12232
2026-03-20 06:51:54 +00:00
CityFun 57a7edf481 feat: improve Minimax validation (#12240) 2026-03-20 06:39:56 +00:00
CityFun bbd33c66f1 fix: fix the issue where the website fails to start after being stopped (#12241) 2026-03-20 14:38:18 +08:00
ssongliu b05ed1a0ca fix: refine sidebar node dropdown style (#12238)
Refs #12231
2026-03-20 03:21:54 +00:00
蘭 b645c4edb4 feat: enhance file operations to preserve file attributes during copy (#12237)
#11542
2026-03-20 03:19:54 +00:00
CityFun 9f61aac8be feat: improve the OpenClaw terminal (#12230) 2026-03-19 18:26:37 +08:00
CityFun 11d78644ee feat: add dingTalk channel for openclaw (#12229) 2026-03-19 09:55:44 +00:00
蘭 b96428e1d6 feat: Extract the file and retain the original file modification time (#12228)
#11542
2026-03-19 09:53:47 +00:00
蘭 6432c35b1a feat: File Selection Add Favorite Pop Up Window for Quick Access to Favorite Files (#12227)
#11326
2026-03-19 09:53:14 +00:00
CityFun fa843f0940 feat: improve agent module logic (#12226)
* feat: openclaw supports configuring multiple models

* feat: change openclaw bind type

* feat: change openclaw bind type

* feat: improve agent module logic
2026-03-19 17:00:15 +08:00
ssongliu 5c7ffc561b chore: upgrade agent docker dependencies (#12224) 2026-03-19 07:27:20 +00:00
ssongliu c2f0c8c6ac feat: add ai terminal settings (#12215) 2026-03-19 07:25:19 +00:00
ssongliu 5bf6d17451 fix: handle mfa and entrance auth errors (#12222) 2026-03-19 15:11:37 +08:00
王贺 7f799004c3 fix: fix openresty logs (#12219) 2026-03-19 14:31:54 +08:00
CityFun c68251cada feat: openclaw supports configuring multiple models (#12213) 2026-03-18 10:36:11 +00:00
王贺 23e7d03d8d fix: missing form fields in es/pt (#12211) 2026-03-18 18:32:28 +08:00
王贺 5085db7a2f chore: upgrade qiniu sdk to fix kodo hash mismatch (#12214) 2026-03-18 18:31:51 +08:00
CityFun a9cf7a7d1d feat: Optimize the logic of the OpenClaw Caddyfile (#12209)
* feat: Optimize the logic of the OpenClaw Caddyfile

* feat: Optimize the logic of the OpenClaw Caddyfile
2026-03-18 15:38:01 +08:00
CityFun cf18995182 fix: Fix the issue preventing the successful creation of the OpenClaw deployment website. (#12206)
* fix: Fix the issue preventing the successful creation of the OpenClaw  deployment website.

* fix: change update app_install port logic
2026-03-18 15:37:48 +08:00
CityFun 24d7dc0786 fix: Fixed the issue where the OpenClaw access address could not be set to a domain name. (#12201) 2026-03-17 09:55:21 +00:00
CityFun 142c9b9a71 fix: Fixed issue with build backend failed (#12199) 2026-03-17 14:50:45 +08:00
ssongliu f3c7603f6e fix: respect region-based release doc source (#12198) 2026-03-17 14:33:11 +08:00
ssongliuandssongliu db26da1700 chore: format english proxy helper text (#12197)
Co-authored-by: ssongliu <songlius11@163.com>
2026-03-17 14:06:20 +08:00
ssongliuandssongliu d5ff59f4e7 fix: remove unused compose backup status param (#12196)
Co-authored-by: ssongliu <songlius11@163.com>
2026-03-17 13:56:34 +08:00
王贺 85b88db99e docs: refine Docker proxy desc (#12195) 2026-03-17 11:15:27 +08:00
ssongliuandssongliu f7053934b5 fix: improve compose backup and container restore handling (#12192)
Co-authored-by: ssongliu <songlius11@163.com>
2026-03-16 22:39:34 +08:00
wangdage12andfanbook-wangdage bf1339bb40 fix: show loading state when confirming deletion of multiple folders and reduce unnecessary basedir requests
Co-authored-by: fanbook-wangdage <124357765+fanbook-wangdage@users.noreply.github.com>
2026-03-16 19:07:19 +08:00
CityFun 43c5e62c23 feat: change allowedOrigin param for openclaw upgrade (#12188) 2026-03-16 10:53:50 +00:00
ssongliuandssongliu 28634ce631 fix(middleware): preserve numeric format in operation logs (#12173) (#12185)
Co-authored-by: ssongliu <songlius11@163.com>
2026-03-16 17:33:07 +08:00
ssongliuandssongliu 72263dfdda fix(container): allow backup for stopped containers (#12184)
Co-authored-by: ssongliu <songlius11@163.com>
2026-03-16 16:39:16 +08:00
ssongliuandssongliu 479d36e25f fix(log): localize upgrade and rebuild operation labels (#12183)
Co-authored-by: ssongliu <songlius11@163.com>
2026-03-16 16:25:34 +08:00
ssongliu 2f3ad3537e feat: add docs source config and runtime env linkage (#12182) 2026-03-16 08:13:51 +00:00
CityFun 2dfe5096f0 feat: Optimize reverse proxy handling for applications with HTTPS ports. (#12181) 2026-03-16 08:07:51 +00:00
CityFun 4375722aef feat: add checkOnStart config for openclaw (#12178) 2026-03-16 06:45:49 +00:00
CityFun bde4d06842 fix: Fixed issue with build source code failed (#12177)
Refs https://github.com/1Panel-dev/1Panel/issues/12176
2026-03-16 05:51:50 +00:00
CityFun 17a486b2c4 feat: Modify the OpenClaw upgrade logic. (#12175) 2026-03-16 04:11:50 +00:00
CityFun 8499c09571 feat: Add OpenClaw security adaptation (#12168) 2026-03-13 18:41:44 +08:00
CityFun 7cb70f785a feat: change openclaw port jump logic (#12164) 2026-03-12 14:27:34 +00:00
CityFun f3781320de feat: add icon for agent (#12163) 2026-03-12 18:52:04 +08:00
CityFun 869ee75020 feat: Add allowedOrigins config for openclaw (#12162) 2026-03-12 09:37:33 +00:00
ssongliu 1d5a26373d chore: optimize lint and cleanup warnings (#12161) 2026-03-12 09:35:33 +00:00
ssongliu 176fd0f8ad feat: support file operations inside containers (#12160)
Refs #523
2026-03-12 09:33:33 +00:00
ssongliu 341a55ae63 feat: add Agent quick jump with priority show strategy (#12159)
Refs #12158
2026-03-12 09:31:33 +00:00
CityFun fb87cf545f feat: Modify the Gemini account verification logic (#12155)
Refs https://github.com/1Panel-dev/1Panel/issues/12149
2026-03-12 09:07:33 +00:00
ssongliu 6db2a9352e feat: refactor image update flow and align i18n messages (#12154)
Refs #10786
2026-03-12 09:05:34 +00:00
ssongliu 21826bcc1a feat: complete backup and recovery for containers and compose (#12153)
Refs #9656
2026-03-12 09:03:34 +00:00
ssongliu eedbc2e95c feat: load registry auth from docker cli config when pulling image (#12152)
Refs #10460
2026-03-12 09:01:33 +00:00
CityFun 46763df7bb fix: Fixed issue with ai menu change failed (#12147) 2026-03-11 10:55:27 +00:00
CityFun 175162753e feat: openclaw channel support wecom (#12146) 2026-03-11 10:53:27 +00:00
CityFun d114933fe9 feat: agent account add openRouter (#12143) 2026-03-11 04:00:55 +00:00
蘭 432412e596 ref: Adjust the column widths of the file system (#12141) 2026-03-11 10:38:15 +08:00
ssongliu 1b577e3d41 fix(core): resolve operation data by agent for non-local nodes (#12136) 2026-03-10 17:23:06 +08:00
CityFun eda9641d35 feat: change some prompt (#12135) 2026-03-10 16:11:31 +08:00
CityFun 6808e4892c fix: Fixed ai menu router error (#12134) 2026-03-10 14:55:00 +08:00
CityFun 0d71cd4db2 feat: change custom model name logic (#12132) 2026-03-10 03:25:58 +00:00
CityFun b6b3304a71 style: change some prompt (#12131) 2026-03-10 11:09:20 +08:00
CityFun 59a0a573ed feat: 增加 vLLM 管理 (#12129) 2026-03-09 20:01:39 +08:00
CityFun e82d686940 feat: add vLLm management (#12128) 2026-03-09 19:55:20 +08:00
ssongliu b48bb88aa1 fix(postgresql): check active sessions before dropping db (#12127) 2026-03-09 16:04:58 +08:00
ssongliu 5ce9ec5b64 feat(container): refine compose and setting i18n (#12125)
Refs #12109
2026-03-09 08:03:52 +00:00
ssongliu 740f3549ad fix: optimize operation log resolve flow and i18n replacements (#12123) 2026-03-09 16:02:12 +08:00
zhengkunwang223 aa8a9e8b1e feat: add QQbot channel for ai Agent 2026-03-09 15:11:04 +08:00
ssongliu 14218e3495 fix(compose): load directory when compose path changes (#12122)
Refs #12106
2026-03-09 06:57:50 +00:00
蘭 c526eb2962 fix: Resolve the issue of not receiving emails when the sender's name is in Chinese (#12121)
#12006
2026-03-09 06:55:50 +00:00
CityFun 1ff8292be4 feat: add ai agent create limit (#12124) 2026-03-09 13:54:06 +08:00
A_Words d4394616af feat: support toggling file list columns (#12099)
* feat: support toggling file list columns

* feat: use fu table column select for file list

* chore: remove unused file column i18n keys

* feat: simplify file column visibility logic and remove unused configurations
2026-03-09 09:51:53 +08:00
王贺 462ca6b373 feat: rename AI model menu to local model (#12108) 2026-03-09 09:51:24 +08:00
ssongliu 657c3ad37a chore: localize newly added i18n keys (#12104) 2026-03-06 14:05:40 +00:00
王贺 90d11a3b74 remove ssl helper (#12102) 2026-03-06 15:31:28 +08:00
CityFun 883f3db725 feat: add openclaw default tool config (#12096) 2026-03-06 13:40:03 +08:00
ssongliu 75da72c265 fix: avoid pulling build-only compose images (#12095)
Refs  #12092
2026-03-06 02:29:39 +00:00
ssongliu 78eef15f5b feat(ai): hide coding plan providers in intl mode (#12090) 2026-03-05 10:47:38 +00:00
ssongliu 735cf3cc71 chore(agent): downgrade docker/cli to v28.5.1 (#12091) 2026-03-05 18:38:42 +08:00
Rowan Chen 99c5d9a8b2 feat: show SSL certificate issuer and expiry date in certificate selectors (#12071) 2026-03-05 15:44:12 +08:00
Rowan Chen 3a0d2039d0 fix: set sort_order for newly installed apps to avoid random positioning (#12069) 2026-03-05 15:43:18 +08:00
dependabot[bot] 98e2d6390a chore(deps): bump github.com/docker/cli in /agent (#12079)
Bumps [github.com/docker/cli](https://github.com/docker/cli) from 28.5.1+incompatible to 29.2.0+incompatible.
- [Commits](https://github.com/docker/cli/compare/v28.5.1...v29.2.0)

---
updated-dependencies:
- dependency-name: github.com/docker/cli
  dependency-version: 29.2.0+incompatible
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-05 12:02:34 +08:00
ssongliu 975e7f9f58 revert: drop nginx safe string validation changes (#12076) 2026-03-05 11:55:23 +08:00
王贺 77c90a26f8 chore: change screenshot (#12075)
Updated UI display screenshot in README.
2026-03-05 11:07:33 +08:00
ssongliu 17cb12df8b fix: avoid sidebar menu flicker when loading and sorting (#12066) 2026-03-05 02:37:04 +00:00
王贺 c986b04667 Update installation script URL in README (#12065) 2026-03-04 18:11:06 +08:00
CityFun 754e016f20 feat: Optimize the Ollama configuration workflow (#12063) 2026-03-04 08:39:03 +00:00
ssongliu f67ce75492 fix: update hook initialization handling (#12062) 2026-03-04 16:38:59 +08:00
ssongliu e236ca5607 fix(swagger): correct api annotation types and methods (#12060) 2026-03-04 07:13:02 +00:00
CityFun 10e74c4fe4 feat: add agent analytics req (#12059) 2026-03-04 07:01:03 +00:00
MoeShin 473c0a5ff2 chore: optimize website proxy api and log (#12052) 2026-03-04 14:35:05 +08:00
ssongliu 7fbe6e4091 fix(auth): update 1panel token hash validation (#12056) 2026-03-04 14:30:17 +08:00
ssongliu 65dbb53a07 fix(terminal): remove command completion protocol (#12055) 2026-03-04 06:25:05 +00:00
KOMATA c21910c203 feat: enhance container compose view with additional status indicators and environment configuration options (#12042)
- Added visual indicators for container status and creation date.
- Introduced a new radio button for environment configuration alongside existing options.
- Improved layout and styling for better user experience.
- Adjusted table column widths and added a class for styling.
2026-03-03 18:31:47 +08:00
CityFun 1728511bad feat: update kimi-coding logic (#12049) 2026-03-03 09:32:03 +00:00
ssongliu e63f6672f8 fix: persist terminal font settings (#12047) 2026-03-03 09:30:03 +00:00
ssongliu 7fa9754ce7 refine passkey prerequisite checks and i18n guidance 2026-03-03 17:08:06 +08:00
王贺 475e4ca629 ci: configure Go dependency cache (#12046)
Add cache dependency path for Go setup
2026-03-03 16:04:26 +08:00
ssongliu 32dee74edf fix: update intl domain links to 1panel.pro (#12045) 2026-03-03 07:20:02 +00:00
CityFun 6715559925 feat: support z.ai agent account (#12044) 2026-03-03 15:18:24 +08:00
KOMATA 2f380c9f2f feat: enhance app synchronization with concurrent processing and detailed result handling (#12041)
- Introduced concurrent processing for app synchronization tasks to improve performance.
- Added structures for managing app work items and results.
- Implemented detailed logging of synchronization progress and HTTP request outcomes.
- Enhanced error handling for icon downloads and app details updates.
2026-03-03 13:46:22 +08:00
CityFun 0aebb0d2db [Feature] Add App Store status check on the Agent page (#12039) 2026-03-03 12:02:42 +08:00
CityFun b7cf9aab2b feat(ai-agents): support ollama apiType selection (#12037) 2026-03-03 11:57:17 +08:00
Rowan Chen ea2f1875bc fix: disable favorite toggle during sort mode and fix drag rebound issue (#12026)
* Optimize sorting-related issues, disable the "favorite stars" function during sorting, and optimize sorting behavior caused by DOM conflicts.

* fix: prevent app icon from being squished in detail drawer
2026-03-03 11:06:53 +08:00
KOMATA 83524940ed refactor: remove unused pprof import from main.go (#12031) 2026-03-03 11:05:26 +08:00
MoeShin 8c33eb8409 feat: Operate website proxy config (#12005)
* feat: Operate website proxy config

* chore: Update api comment

* chore: update swagger

* chore: update x-log

* feat: split api
2026-03-03 11:05:00 +08:00
ssongliu 644655af86 feat: add extra_hosts support for container config (#12035)
Related: https://github.com/1Panel-dev/1Panel/issues/11861
2026-03-03 03:02:02 +00:00
CityFun 8082f4d2ca fix(ai-agents): mark skipped-verification accounts as unverified and normalize legacy status (#12034) 2026-03-03 11:00:53 +08:00
ssongliu 3c896bc5f8 fix: improve MFA interval compatibility i18n hints (#12033)
Related: https://github.com/1Panel-dev/1Panel/issues/11915
2026-03-03 03:00:02 +00:00
ssongliu 5c7ce6d8d4 fix: add PostgreSQL recreation and DB host env sync in app restore (#12029) 2026-03-03 02:58:01 +00:00
CityFun 222702f66b feat(ai-agents): Add ark coding plan for agent account (#12027) 2026-03-02 22:04:57 +08:00
ssongliu b3b3feb1b9 feat: improve container log viewer and download options (#12025)
#11898
2026-03-02 14:04:02 +00:00
ssongliu 133140040b fix: store loadingText as key and localize on render (#12022) 2026-03-02 10:32:01 +00:00
ssongliu d70f903432 fix: hide upage for intl and reload after edition switch (#12021) 2026-03-02 10:30:00 +00:00
CityFun f77f305316 feat(ai-agents): add time zone config for openclaw (#12020) 2026-03-02 10:28:01 +00:00
ssongliu 13b5908442 fix: handle sqlite wal/shm mismatch during restore (#12024) 2026-03-02 18:27:38 +08:00
CityFun d1335db986 fix(ai-agents): fixed issue with minimax-m2.5 is not work for bailian-coding-plan (#12023) 2026-03-02 18:27:25 +08:00
KOMATA 1e8836ad95 feat: add passkey prerequisites messages and update passkey settings UI (#12018)
* feat: add passkey prerequisites messages and update passkey settings UI

* refactor: enhance passkey settings UI by restructuring alert component and adding prerequisite checks
2026-03-02 16:05:55 +08:00
CityFun d155217ce3 feat(ai-agents): Add copaw type for agent (#12017) 2026-03-02 06:53:58 +00:00
ssongliu 9ddb996a44 fix: fix menu sort mismatch between load and migration (#12016) 2026-03-02 06:25:58 +00:00
ssongliu 226eccf5f1 fix: fix terminal extra line after settings change (#12015) 2026-03-02 06:24:00 +00:00
CityFun 4b2d6f0ef2 feat(ai-agents): add bailian-coding-plan account support (#12014) 2026-03-02 14:22:09 +08:00
蘭 f1c9ad45a7 feat: Improve file decompression processing (#12012) 2026-03-02 05:53:58 +00:00
KOMATA 93489c85fa feat: Enhance compose item interaction and styling (#12008)
- Updated the compose item to include a new class for active state, improving visual feedback.
- Refactored action buttons into a dedicated container for better organization and hover effects.
- Added CSS transitions for smoother interactions on hover and active states.
2026-03-02 13:43:34 +08:00
CityFun 8cead06d7b feat(ai-agents): add anthropic-messages support for custom account apiType (#12013) 2026-03-02 13:43:06 +08:00
ssongliu 625cd9f32f chore: update installation package path (#12009) 2026-03-02 04:05:59 +00:00
dependabot[bot] 9aab3b42af chore(deps): bump go.opentelemetry.io/otel/sdk in /agent (#12010)
Bumps [go.opentelemetry.io/otel/sdk](https://github.com/open-telemetry/opentelemetry-go) from 1.38.0 to 1.40.0.
- [Release notes](https://github.com/open-telemetry/opentelemetry-go/releases)
- [Changelog](https://github.com/open-telemetry/opentelemetry-go/blob/main/CHANGELOG.md)
- [Commits](https://github.com/open-telemetry/opentelemetry-go/compare/v1.38.0...v1.40.0)

---
updated-dependencies:
- dependency-name: go.opentelemetry.io/otel/sdk
  dependency-version: 1.40.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-02 12:04:30 +08:00
A_Words 6684cd57d7 fix: open compressed files in decompress drawer and improve archive detection (#11997) 2026-03-02 11:29:19 +08:00
A_Words 00d95d6e19 fix(mkdown-editor): avoid pre-sanitizing markdown source (#11998) (#12004) 2026-03-02 10:54:25 +08:00
ssongliu 36f03e5daf chore: refine partial i18n copy (#11992) 2026-02-28 15:01:54 +00:00
ssongliu 7c395a6bf8 fix: resolve GPU menu selected state style issue 2026-02-28 18:58:18 +08:00
zhengkunwang223 e9a7cac5fd fix(frontend): change table style 2026-02-28 18:58:06 +08:00
zhengkunwang223 9f00b431d4 fix(frontend): stabilize installed app card name truncation 2026-02-28 18:35:34 +08:00
maninhill 1705d44378 Update README.md 2026-02-28 17:54:03 +08:00
CityFun 537e2c730c feat: preserve Feishu account map on config updates (#11990) 2026-02-28 09:19:52 +00:00
ssongliu b4dc6b1ab0 chore: optimize runtime region description (#11989) 2026-02-28 09:17:53 +00:00
CityFun 1f22285cf4 chore: refine other language i18n wording (#11988) 2026-02-28 16:41:28 +08:00
A_Words 1d546236e4 fix: restore subsite nginx config on start to avoid stale stop page (#11978) 2026-02-28 15:25:18 +08:00
Kadxy 982e0bbd3e feat: add copy connection URL button to database connection info drawers (#11981)
Add a copy connection URL button to the MySQL, PostgreSQL, and Redis connection info drawers, allowing users to one-click copy the full connection URI for both container and remote connections.
2026-02-28 15:13:58 +08:00
CityFun 5a67a62733 chore: refine en and zh-Hant i18n wording (#11987) 2026-02-28 07:11:52 +00:00
ssongliu 961ebd30a1 fix: resolve overseas script repository sync failure (#11985) 2026-02-28 10:38:35 +08:00
ssongliu 9819546694 chore: optimize internationalization content (#11980) 2026-02-27 14:57:50 +00:00
ssongliu 5c8d391a73 fix: resolve partial application restore failure (#11979)
Refs #11716
2026-02-27 14:43:50 +00:00
CityFun 0e84b9a2a7 feat: fix error after deleting website deployed via deployment for "openclaw" (#11975) 2026-02-27 17:54:19 +08:00
王贺 1f58da501e ci: update build workflow (#11976) 2026-02-27 17:53:52 +08:00
ssongliu 0041184a3e fix: resolve version synchronization issue (#11971) 2026-02-27 07:17:51 +00:00
ssongliu 112468949d feat: Update sqlite connection settings (#11969) 2026-02-27 15:16:59 +08:00
KOMATA 7e03e94460 chore: remove stylelint configuration and ignore files, update lint-staged for stylelint removal (#11968) 2026-02-27 15:08:37 +08:00
ssongliu fd9b299b14 chore: update Edition key (#11962) 2026-02-26 10:05:46 +00:00
ssongliu 37932d7fb2 chore: Complete system security upgrade (#11961) 2026-02-26 10:01:47 +00:00
CityFun f0f2087f54 feat: add browser config for Agent (#11958) 2026-02-26 09:59:48 +00:00
CityFun f3de09e5d7 feat: Add discord channel for Agent (#11950) 2026-02-25 13:59:46 +00:00
maninhill 1a8c1613a4 Update README to eliminate redundant text
Removed duplicate description of 1Panel.
2026-02-25 16:56:40 +08:00
KOMATA 5209275f28 feat: Implement caching for settings retrieval and update logic (#11886)
* feat: Implement caching for settings retrieval and update logic

- Introduced a caching mechanism for settings using go-cache to improve performance.
- Updated the Create, Update, and UpdateOrCreate methods to cache values after database operations.
- Modified the Get and GetValueByKey methods to utilize the cache for faster access.
- Adjusted middleware to use the new GetValueByKey method for retrieving settings, enhancing code consistency.

* feat: Enhance setting cache management with dynamic TTL

- Introduced a function to determine cache TTL based on setting keys, allowing critical settings to have shorter cache durations.
- Updated cache logic in Create, Update, Get, and GetValueByKey methods to utilize the new TTL management, improving performance and consistency in settings retrieval.

* refactor: Simplify setting cache TTL management

- Removed the dynamic TTL function and standardized the cache TTL to a fixed duration for all settings.
- Updated cache logic in Create, Update, Get, and GetValueByKey methods to use the new fixed TTL, enhancing code clarity and maintainability.
- Added a new function to restart the core service after resetting settings, improving the reset command's functionality.

* refactor: Remove core restart functionality from reset commands

- Eliminated the restartCoreAfterReset function to simplify the reset command logic.
- Updated reset commands to return nil after setting changes, enhancing clarity and reducing unnecessary complexity.
2026-02-25 16:40:20 +08:00
ssongliu 924e59d948 fix: Fix login log recording abnormality in some scenarios (#11948) 2026-02-25 08:39:46 +00:00
CityFun 87a39cab3a feat: Add telegram channel for Agent (#11947) 2026-02-25 16:38:34 +08:00
CityFun f9a70757a1 feat: add rememberApiKey for AgentAccount (#11945) 2026-02-25 05:53:45 +00:00
ssongliu 741acee393 fix: Modify command invocation method (#11944) 2026-02-25 05:51:44 +00:00
dependabot[bot] c964a02088 chore(deps): bump filippo.io/edwards25519 from 1.1.0 to 1.1.1 in /agent (#11946)
Bumps [filippo.io/edwards25519](https://github.com/FiloSottile/edwards25519) from 1.1.0 to 1.1.1.
- [Commits](https://github.com/FiloSottile/edwards25519/compare/v1.1.0...v1.1.1)

---
updated-dependencies:
- dependency-name: filippo.io/edwards25519
  dependency-version: 1.1.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-25 11:58:33 +08:00
ssongliu ba71613ae6 fix: Fix docker compose parsing failure issue (#11942)
Refs #11934
2026-02-25 03:51:44 +00:00
ssongliu 5493b41381 fix: Modify monitoring interval prompt information (#11940)
Refs #11918
2026-02-25 02:57:47 +00:00
ssongliu c3f45d4787 feat: v2 supports international version (#11939) 2026-02-25 10:56:06 +08:00
rowanchen-com a8a92da483 Added the ability to sort installed applications. (#11936) 2026-02-25 10:35:29 +08:00
CityFun a77e61a861 feat: Agent Model Account support custom config (#11933)
* feat: Agent Model Account support custom config

* feat: Add agent gatewayControlUi config
2026-02-24 18:26:02 +08:00
kgbow e589728987 refactor(website-ssl): improve API flexibility for WebsiteSSLSearch validation (#11926) 2026-02-24 17:30:37 +08:00
maninhill cc3cdef5f3 Revise 1Panel description and add feature highlights
Updated the description of 1Panel to clarify its purpose and features.
2026-02-24 17:28:14 +08:00
A_Words 7101af0594 fix: Allow wildcard domains in domainOrIP validator for SSL form (#11923) 2026-02-24 17:22:30 +08:00
KOMATA df6cda7143 chore: change esbuild to native version (#11904)
* chore: change esbuild to native version

* chore: add @parcel/watcher and esbuild to trustedDependencies
2026-02-24 17:21:20 +08:00
KOMATA 62beb534d2 feat: enhance passkey function 2026-02-24 17:19:13 +08:00
adriandadri 9a70bf1c2d feat: Add support for Technitium DNS (#11896)
Co-authored-by: adri <adri@digitalunited.net>
2026-02-24 17:13:59 +08:00
KOMATA 6f4559c7cd style: Update Prettier configuration and adjust SCSS formatting (#11885)
- Added iconfont directory to .prettierignore
- Removed jsxBracketSameLine option from .prettierrc.js
- Fixed indentation in common.scss and reset.scss files
2026-02-24 17:03:20 +08:00
KOMATA 8b2bcdc814 feat: Enhance domain handling in website creation (#11884)
* feat: Enhance domain handling in website creation

* fix: Improve domain trimming and validation in website alias assignment
2026-02-24 16:58:30 +08:00
maninhill 0f6ddf26fd Refine README.md content for conciseness
Removed redundant phrases for clarity.
2026-02-24 14:11:31 +08:00
王贺 ebe472a9db feat(i18n): refine translations (#11931) 2026-02-24 13:42:10 +08:00
ssongliu c1a87baad7 feat: Terminal supports foreground and background colors (#11929) 2026-02-24 13:39:02 +08:00
Scars c7f185a184 feat: Terminal supports custom fonts (#11889) 2026-02-13 18:15:12 +08:00
蘭 4ca2ba5482 feat: File editing supports last opened file (#11893)
#11572
2026-02-13 10:01:14 +00:00
蘭 727b74895d ref: Adjust the column widths of the file system (#11887)
#11699
2026-02-13 09:59:13 +00:00
CityFun 6c3f56516f feat: Remove test code (#11883) 2026-02-12 10:20:08 +08:00
CityFun 4833cf2d3c fix: Resolve issue where Feishu configuration does not take effect (#11878) 2026-02-11 22:07:06 +08:00
maninhill b5ca015632 Revise Key Features section in README.md
Updated the README.md to enhance the Key Features section with improved formatting and clarity.
2026-02-11 19:07:00 +08:00
maninhill 1c82034111 Revise README for improved feature presentation
Updated the README to enhance feature descriptions and improve clarity.
2026-02-11 18:58:28 +08:00
CityFun e45d723f3d style: change manualModel to checkbox (#11874) 2026-02-11 07:35:07 +00:00
ssongliu 627e6852db chore: Modify FTP account deletion prompt (#11873) 2026-02-11 15:06:58 +08:00
CityFun 1725767789 feat: add token reset for agent (#11872) 2026-02-11 14:54:59 +08:00
ssongliu 3e1f2b4934 fix: Fix order by sorting rule not taking effect issue (#11871) 2026-02-11 06:39:07 +00:00
ssongliu 140d20a21d chore: Remove memo double-click event (#11870) 2026-02-11 14:08:14 +08:00
CityFun 6e933fc83c feat: change website ssl sort logic (#11869) 2026-02-11 14:02:54 +08:00
ssongliu 79ff565995 fix: Firewall remove status filtering (#11868) 2026-02-11 14:02:34 +08:00
CityFun 39691698c9 feat: change order function (#11865) 2026-02-11 04:17:06 +00:00
ssongliu 4bedddb557 chore: Remove some error prints (#11864) 2026-02-11 03:45:07 +00:00
ssongliu f9547752f5 fix: Fix memo caching issue (#11863) 2026-02-11 03:13:07 +00:00
CityFun 96b37cb024 fix: Resolve HTTPS certificate configuration failure for websites (#11862) 2026-02-11 02:39:06 +00:00
蘭 30d874bc91 style: Modify the style of alert settings (#11860) 2026-02-10 13:07:06 +00:00
ssongliu 32bc8d922b fix: Fix orderby dependency injection issue (#11858) 2026-02-10 10:35:06 +00:00
MonkeyCode-AI a95d9aa27a fix: Add CheckIllegal validation to prevent command injection in 8 API endpoints (#11857)
- SSH log query: validate req.Info before grep concatenation
- iptables rule search: validate chain param in ReadFilterRulesByChain/LoadDefaultStrategy
- Firewalld port forward: validate port/protocol/targetIP/targetPort params
- Pure-FTPd user management: validate username/path in UserAdd/UserDel/SetPath/SetStatus
- PostgreSQL remote backup/recover: validate password/address/user/dbname
- MySQL remote backup/recover: validate password/address/user/dbname/format
- Website Composer: validate user/mirror/command/extCommand
- Disk operations: validate device/mountPoint/filesystem in Partition/Mount/Unmount
2026-02-10 18:32:42 +08:00
7f4f5a0a47 fix: Prevent SQL injection via orderBy parameter in WithOrderBy and WithOrderRuleBy (#11856)
Co-authored-by: maosite <naocanmonster@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: monkeycode-ai <monkeycode-ai@chaitin.com>
2026-02-10 17:51:59 +08:00
CityFun 65a7193bc6 fix: resolve issue with minimax functionality (#11855) 2026-02-10 09:31:06 +00:00
ssongliu 64f79b4fdd perf: Optimize login expiration prompt information (#11854) 2026-02-10 09:17:06 +00:00
ssongliu 31d2af554e feat: Add carousel settings to overview page (#11853) 2026-02-10 08:45:06 +00:00
蘭 e0f2910710 fix: Fix all website/ssl alert error issues (#11852) 2026-02-10 16:42:19 +08:00
CityFun f89ab607b4 feat: update website traffic limit description text (#11850) 2026-02-10 07:39:05 +00:00
CityFun 61fa07b8f2 feat: Support up to three-level directory structure for website directory (#11847)
Refs https://github.com/1Panel-dev/1Panel/issues/11694
2026-02-10 06:55:05 +00:00
蘭 9653df185a ref: Adjust some alarm internationalization content (#11845) 2026-02-10 05:37:06 +00:00
ssongliu 8f7d3df76d fix: Fix system information refresh not taking effect (#11844) 2026-02-10 03:07:04 +00:00
CityFun 2aa89699c1 feat: change button name (#11840) 2026-02-09 13:13:03 +00:00
CityFun 46b8d3bd80 feat: add model switching functionality for AI agent (#11839) 2026-02-09 09:51:03 +00:00
蘭 8a7be481b0 ref: Adjust the docs address (#11838) 2026-02-09 08:57:02 +00:00
蘭 21bd94d3a3 fix: Fix the issue with editing alert settings (#11837) 2026-02-09 08:47:04 +00:00
CityFun d337190b59 fix: remove some code (#11836) 2026-02-09 15:38:06 +08:00
CityFun 7073e83d94 feat: change agent logic (#11835) 2026-02-09 15:33:15 +08:00
KOMATA 537ff49c0b f (#11834) 2026-02-09 15:33:05 +08:00
CityFun 4b1f643c70 feat: add Feishu configuration for OpenClaw (#11832) 2026-02-09 06:39:02 +00:00
ssongliu b8b873836d feat: Add agent to menu hidden items (#11831) 2026-02-09 14:25:23 +08:00
ssongliu 021334c2c8 fix: Fix disk and network card settings not taking effect issue (#11828)
Refs #11825
2026-02-09 06:13:02 +00:00
KOMATA 635d86a1a5 fix: update app details handling and sync logic in app_sync_task (#11830)
- Modified the logic to retain app details computed from remote sources, only falling back to existing details when necessary.
- Enhanced the synchronization process to ensure only remote resources are included in the synced app IDs for both addition and update scenarios.
2026-02-09 13:38:39 +08:00
ssongliu 607eae3ca8 chore: Modify model account prompt information (#11815) 2026-02-06 12:26:58 +00:00
ssongliu 66428c0a8e chore: Adjust menu names and positions (#11814) 2026-02-06 07:48:57 +00:00
ssongliu c0404a4ec5 feat: Optimize agent style and prompt information (#11813) 2026-02-06 07:46:56 +00:00
KOMATA 77ef3b7096 fix: replace DownloadFileWithProxy to stream method (#11811)
* refactor: replace DownloadFileWithProxy with DownloadFileWithProxyStream for improved file downloading logic

* refactor: remove commented-out DownloadFileWithProxy function to clean up code
2026-02-06 15:16:12 +08:00
sfwwslm 498052f4c9 style: Adjust the vertical alignment of the drop-down menu (#11812) 2026-02-06 15:13:02 +08:00
ssongliu 958c55eab8 chore: Rename dependency file names (#11809) 2026-02-06 10:51:18 +08:00
CityFun d3cddd175a feat: change style for agent (#11805) 2026-02-05 11:32:55 +00:00
ssongliu 6cd130677e feat: Add force image pull to container compose editing (#11804) 2026-02-05 11:30:57 +00:00
蘭 33b90e3979 feat: Webhook URL default hidden display (#11803) 2026-02-05 11:28:56 +00:00
CityFun e1e2b97e88 feat: Add an Agent Management Page (#11801) 2026-02-05 08:54:55 +00:00
ssongliu 8e4e1dc749 fix: Fix hostname cache refresh abnormality on overview page (#11802) 2026-02-05 08:26:55 +00:00
ssongliu 1eb5830238 fix: Fix service name loading failure in some scenarios (#11800)
Refs #11789
2026-02-05 08:24:56 +00:00
ssongliu 5db2ccbb22 perf: Optimize passkey prompt information (#11798) 2026-02-05 07:08:54 +00:00
蘭 47e04a14ad fix: Fix some issues with webhook alerts (#11797) 2026-02-05 06:40:54 +00:00
KOMATA a13c58eb18 fix: enhance app icon download logic to include file existence check (#11796)
* fix: enhance app icon download logic to include file existence check

- Updated the downloadAppIcon function to parse the icon field for the file name and ETag.
- Added a condition to check if the icon file exists before setting the If-None-Match header for the request.

* fix: improve app icon retrieval logic by adding filename check

- Updated GetAppIcon method to include filename in the response.
- Added a condition to ensure the ETag header is set only if both ETag and filename are present, enhancing the integrity of the caching mechanism.
2026-02-05 13:45:25 +08:00
KOMATA 15d77ceb46 fix: change sync logic to aggressive process (#11795)
* f

* f

* f
2026-02-05 13:44:59 +08:00
MoeShin af8fa6763f fix: Fix website https url (#11792) 2026-02-05 13:43:54 +08:00
ssongliu ed66d22bdb feat: Panel management adds status and redirection (#11794) 2026-02-05 03:42:54 +00:00
ssongliu 5209db3abc style: Adjust memo style (#11793) 2026-02-05 10:32:37 +08:00
MoeShin 35ea1a1d6c feat: Show container log timestamps (#11785) 2026-02-04 17:42:37 +08:00
蘭 b0a053b397 fix: Fix batch modification file/folder permission timeout issue (#11775)
#10737
2026-02-03 05:54:52 +00:00
王贺 a8b3390ce9 Revise installation instructions in README
Updated installation command and removed outdated references.
2026-02-02 19:24:05 +08:00
maninhill 4266e39d84 Revise README to highlight OpenClaw integration
Updated the description of 1Panel to emphasize its capabilities with OpenClaw and Ollama.
2026-02-02 19:21:07 +08:00
CityFun 5620b6e736 fix: resolve migration failure when reinstalling OpenResty (#11771) 2026-02-02 18:28:00 +08:00
ssongliu 289e4190ff perf: Optimize installed application password parameter display (#11767)
Refs #11636
2026-02-02 07:46:51 +00:00
ssongliu 9250412296 feat: Remove database transaction monitoring logs (#11765) 2026-02-02 06:48:51 +00:00
ssongliu 9c5370508e perf: Optimize application redirection style (#11764) 2026-02-02 06:42:52 +00:00
ssongliu a145bfdc5b fix: Improve application parameter modification logs (#11763)
Refs #11726
2026-02-02 06:40:51 +00:00
KOMATA db2b38eedb fix: update browser cache handling in website proxy service (#11762)
* fix: update browser cache handling in website proxy service

- Adjusted cache time condition to allow for negative values, introducing a no-cache option.
- Updated TypeScript interface to specify browser cache options as 'enable', 'disable', or 'noModify' for better clarity.

* refactor: clean up imports and enhance browser cache comments in website proxy service

- Removed unused import statements for better code clarity.
- Added detailed comments to clarify the behavior of the cache time settings in the OperateProxy function.
2026-02-02 11:14:30 +08:00
MoeShin 9303ee399f feat: Optimize HTTP to HTTPS redirection rules for non-443 port websites (#11761) 2026-02-02 11:07:15 +08:00
KOMATA d64cb3dc58 feat: add keepdeps.go to preserve mod clean in opensource version (#11752)
* feat: add keepdeps.go to preserve enterprise-only dependencies in OSS builds

* feat: migrate keepdeps.go to core/deps for OSS builds
2026-01-30 17:22:37 +08:00
KOMATA 62871acfbb fix: update cert reload logic and translation in Mux mode (#11751)
* fix: update SSL setting check to include 'Mux' option in GetSystemSSL function

* fix: update language files to clarify muxHelper descriptions for security implications

* feat: add keepdeps.go to preserve enterprise-only dependencies in OSS builds

* revert: revert cherrypick
2026-01-30 16:43:26 +08:00
ssongliu 7401906b03 fix: Fix compatibility issue with some OneDrive addition failures (#11748)
Refs #11741  #11685
2026-01-30 08:40:51 +00:00
蘭 5aa3c29684 feat: Support more alert method (#11742) 2026-01-30 08:38:50 +00:00
KOMATA 91745f592f feat: add auto passkey trial tracking to login form (#11749)
- Introduced functionality to track if the auto passkey login has been attempted using session storage.
- Implemented methods to initialize and mark the auto passkey trial status.
- Automatically trigger passkey login if conditions are met during settings retrieval.
2026-01-30 14:54:22 +08:00
KOMATA 564333ea03 feat: enhance app icon retrieval logic in home view (#11745)
- Updated app icon handling to use a dedicated function for improved clarity and maintainability.
- Introduced a new method, getAppIconSrc, to manage icon source retrieval based on app properties.
- Ensured proper handling of base64 and URL-based icons for better performance and consistency.
2026-01-30 14:53:58 +08:00
KOMATAandcopilot-swe-agent[bot] 7b543ea5fe feat: support Memo feature (#11744)
* Initial plan

* Add dashboard memo feature

Co-authored-by: HynoR <20227709+HynoR@users.noreply.github.com>

* Address code review feedback

Co-authored-by: HynoR <20227709+HynoR@users.noreply.github.com>

* fix: small fix

* feat: add tooltip for sensitive info and memo features in multiple languages

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-01-30 14:53:44 +08:00
KOMATA fc5e583481 feat: implement app icon management and caching mechanism (#11719)
* feat: implement app icon management and caching mechanism

* feat: enhance app synchronization and icon management

- Refactored app synchronization tasks to improve structure and clarity.
- Introduced shared context for managing app sync state and metadata.
- Updated icon handling to ensure proper content type and caching.
- Adjusted cache control settings for app icons to extend cache duration.
- Improved error handling and logging during app sync processes.

* refactor: streamline app icon retrieval by removing unused fileName return

- Removed the fileName return value from GetAppIcon function as it was not utilized.
- Enhanced the GetAppIcon method in BaseApi to improve clarity and maintainability.
- Ensured proper caching headers are set for app icons.
2026-01-27 14:02:38 +08:00
CityFun 1767b1a9c1 feat: revert website name validation changes (#11728)
https://github.com/1Panel-dev/1Panel/issues/11718
2026-01-27 05:44:47 +00:00
KOMATA 930c340e37 fix: ensure local connection is properly closed in CheckLocalConn method (#11730) 2026-01-27 13:37:14 +08:00
ssongliu 58531d3995 fix: Fix scheduled task execution abnormality issue (#11725)
Refs  #11707
2026-01-26 08:22:48 +00:00
ssongliu 4b7c1390fb fix: Fix container compose env loading abnormality issue (#11723)
Refs #11724
2026-01-26 08:20:47 +00:00
ssongliu 2337d6f13a fix: Fix clam scan status display abnormality issue (#11722)
Refs #11686
2026-01-26 08:18:47 +00:00
CityFun cb8ecd55ed feat: Change openresty upgrade logic (#11714) 2026-01-23 17:49:07 +08:00
CityFun 33ad31dba2 fix: fix website redirects not displaying when disabled (#11713) 2026-01-23 17:48:52 +08:00
CityFun 294c3f128b feat: add application upgrade support to overview page (#11711) 2026-01-22 16:02:48 +08:00
CityFun 0899f4e9db feat: add retry mechanism for SSL certificate application (#11710) 2026-01-22 08:02:22 +00:00
CityFun 788f4d8307 feat: optimize IPv6 support for websites (#11708) 2026-01-22 15:59:53 +08:00
CityFun 2451d6bed2 feat: add support for EAB verification in custom ACME accounts (#11704)
https://github.com/1Panel-dev/1Panel/issues/11705
2026-01-21 10:02:19 +00:00
CityFun c547de875a feat: disable redirect configuration when website is stopped (#11700) 2026-01-21 17:59:48 +08:00
CityFun ffead783aa feat: Add parent website display in website list (#11695) 2026-01-21 17:59:38 +08:00
KOMATA d742c7d333 feat: add support for passkey login method (#11601)
* feat: Add passkey authentication support with registration and login endpoints

* style: Refactor import statements and improve button formatting in login and settings views

* chore: Update dependencies in go.mod and go.sum, including adding go-webauthn and updating indirect dependencies

* refactor: Consolidate passkey session management and update related structures for improved clarity and functionality

* feat: Add passkey support in multiple languages with corresponding error messages and management options

* feat: Implement passkey reset command and enhance internationalization support for passkey-related messages

* refactor: Simplify login form structure by removing commented sections and unused passkey login function

* fix: Update passkey failure messages across multiple languages to include SSL certificate verification instructions

* chore: update go mod

* refactor: Update passkey handling by consolidating status checks and renaming related fields for clarity

* style: Format i18n file

* refactor: Enhance passkey configuration checks
2026-01-20 10:28:04 +08:00
KOMATA 1025cd3535 refactor: improve code logic and format in app remote sync task (#11691)
* feat: Implement AppStore synchronization task

- Introduced a new `syncAppStoreTask` function to handle the synchronization of applications from the AppStore.
- The function checks for updates, retrieves the application list, and manages the synchronization process, including downloading app icons and updating app details.
- Refactored the existing app synchronization logic from `SyncAppListFromRemote` into the new task for improved maintainability and clarity.
- Added logging for progress tracking during the synchronization process.

* feat: Refactor HTTP request handling to support custom client

- Introduced `HandleRequestWithClient` function to allow custom HTTP clients for requests.
- Moved the transport loading logic into the new function for better separation of concerns.
- Updated `HandleRequest` to utilize the new function, enhancing flexibility in request handling.

* refactor: Simplify HTTP request handling in app synchronization

- Replaced direct HTTP request handling with `HandleRequestWithClient` for downloading app icons and Docker Compose files.
- Removed unused variables and logging related to icon download updates for cleaner code.
- Improved error handling and logging for failed requests.
2026-01-19 17:15:19 +08:00
Scars de402ea724 feat: Support batch import for Docker images (#11640)
* feat: Support batch import for Docker images

* perf: Avoid duplicate Docker client creation in ImageLoad

* style: Remove unnecessary comments in FileList component
2026-01-19 10:05:17 +08:00
KOMATA 3af07a42d6 feat: support file remark ability in file management (#11626)
* feat: Add file remark ability in file management

* refactor: improve mark get logic

* feat: Implement remark filtering for file management buttons
2026-01-16 17:05:32 +08:00
KOMATA e5b7e99cce feat: retrieve listening processes in firewall port page (#11665)
* feat: Add endpoint to retrieve listening processes and update related services

* fix: Update listening process retrieval logic to include socket type checks

* refactor:  accept context for improved request handling
2026-01-16 16:54:15 +08:00
KOMATA 365936bf57 feat: Add pullImage option to ComposeCreate to swith pull operation (#11667)
* feat: Add pullImage option to ComposeCreate and update related logic

- Introduced a new optional field `pullImage` in the `ComposeCreate` struct to control image pulling behavior.
- Updated `CreateCompose` method to handle the `pullImage` flag when invoking the compose up command.
- Modified `UpWithTask` function to conditionally skip image pulling based on the `pullImages` parameter.
- Enhanced frontend form to include a checkbox for the `pullImage` option, with default value set to true.

* feat: Add label for environment variable input in Compose form
2026-01-16 16:40:55 +08:00
ssongliu 2b7562f546 feat: Add snapshot restore timeout duration (#11660) 2026-01-16 11:09:05 +08:00
ssongliu e7f4e312b9 fix: Fix container image cleanup uninitialized issue (#11655) 2026-01-15 10:09:59 +00:00
ssongliu 98d3870477 fix: Fix incomplete scanning of unassociated backups issue (#11654) 2026-01-15 10:08:00 +00:00
CityFun b1ceb2e53d feat: fix redirection issue caused by incorrect PHP PATH_INFO config (#11650)
Refs https://github.com/1Panel-dev/1Panel/issues/11651
2026-01-15 07:26:01 +00:00
ssongliu 075d2468ea feat: Add file copy command timeout duration (#11652)
Refs #11639
2026-01-15 07:23:59 +00:00
ssongliu 8b9e7aef4a fix: Fix container image cleanup count display abnormality issue (#11649) 2026-01-15 14:47:57 +08:00
ssongliu 15e499c1e9 perf: Optimize cache cleanup logic (#11648) 2026-01-15 06:45:59 +00:00
55ccb9fe61 fix: DNS Manual SSL certificate issues for wildcard domains (#11642)
This fix addresses three bugs in the DNS Manual SSL certificate flow:

1. **Order caching fails when Expires is zero**: ACME orders often have
   zero Expires initially. The condition `!Expires.IsZero()` caused valid
   cached orders to be deleted and recreated with different TXT values.
   Fixed by checking `Expires.IsZero() || Expires.After(now)`.

2. **Wildcard and base domain TXT records overwrite each other**: When
   requesting SSL for both `example.com` and `*.example.com`, both
   authorizations have identifier `example.com`, causing one TXT value
   to overwrite the other. Fixed by using `*.domain` as the map key.

3. **Only first TXT record checked**: When multiple TXT records exist,
   only the first was checked. Fixed by returning all TXT values and
   checking if expected value exists in any of them.

```release-note
Fix DNS Manual SSL certificate issues for wildcard domains
```

Co-authored-by: DeployThemAll <deploythemall@users.noreply.github.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 10:42:59 +08:00
蘭 a5816f1dc9 fix: Fix alert log issues (#11637) 2026-01-14 08:47:56 +00:00
ssongliu 95a613f59f fix: Fix GPU monitoring page refresh abnormality issue (#11635) 2026-01-14 06:57:57 +00:00
J.Knight 6777941dee feat: add support for Porkbun DNS provider (#11614) 2026-01-14 10:38:12 +08:00
ssongliu e55128b7fa perf: Optimize security portal prompt information (#11613) 2026-01-09 16:50:01 +08:00
CityFun 9ac11002f8 perf: Optimize app store icon display (#11612) 2026-01-09 08:41:52 +00:00
ssongliu 1cc7bfb4a9 fix: Fix cache cleanup checkbox abnormality issue (#11611) 2026-01-09 16:18:37 +08:00
CityFun e59534e54f feat: change appstore sync logic for v2.0.17 (#11610) 2026-01-09 07:17:52 +00:00
ssongliu 8c2368c1d2 fix: Fix snapshot cleanup abnormality issue (#11608) 2026-01-09 05:47:52 +00:00
CityFun 8e8f181cae fix: Fixed issue with website log display error in clean cache (#11607) 2026-01-09 04:01:51 +00:00
蘭 2fcab77565 fix: Fix more button overflow screen issues (#11605) 2026-01-09 11:17:26 +08:00
ssongliu d29f0c6c31 style: Modify container image pull style (#11604) 2026-01-09 11:14:15 +08:00
CityFun 79f1b1458b fix: Fix infinite log reading issue (#11603) 2026-01-09 11:14:07 +08:00
蘭 b279ee6b82 fix: Fix the issue of opening folder path errors (#11600) 2026-01-08 22:01:54 +08:00
蘭 c64a58d814 fix: Fix the issue of not sending SMS alerts (#11599) 2026-01-08 17:33:27 +08:00
ssongliu 57c60ab3f8 perf: MySQL backup parameter deduplication (#11597) 2026-01-08 15:27:00 +08:00
ssongliu e37d90d6e1 fix: Fix node overview cannot operate container terminal issue (#11593) 2026-01-08 06:17:49 +00:00
KOMATA 22055ed99a refactor: enhance Mux connection handling in server (#11590)
* refactor: enhance Mux connection handling in server

* fix: Increase maximum allowed length for RequestURI in Mux connection handling

* feat: Add support for HTTP/2 in Mux mode
2026-01-08 14:03:04 +08:00
KOMATA e263331b94 feat: Enhance entrance pattern validation in settings API to exclude specific segments and assets (#11592) 2026-01-08 14:01:43 +08:00
ssongliu eb3d613e3d feat: Set overview as default menu for multi-node management (#11588) 2026-01-07 22:06:13 +08:00
CityFun cc23444776 feat: change listNodes api timeout (#11586) 2026-01-07 09:29:48 +00:00
ssongliu 1931d3f262 perf: Optimize SSL selection style (#11585) 2026-01-07 16:08:56 +08:00
CityFun 79cbbbdf0a perf: Optimize display for long website names (#11581) 2026-01-07 07:09:47 +00:00
ssongliu 38cd785aa3 fix: Fix node overview operation abnormality issue (#11580) 2026-01-07 07:07:48 +00:00
ssongliu dabec3f87a fix: Fix cache cleanup abnormality issue (#11577) 2026-01-06 22:09:11 +08:00
ssongliu 6c94cb2bdf feat: MySQL backup supports custom parameters (#11575) 2026-01-06 16:41:55 +08:00
KOMATA f67bdf0c22 feat: Implement connection closure handling in status 444 (#11574)
* feat: Implement connection closure handling in security module

* refactor: Rename closeConn to CloseDirectly for clarity in security module
2026-01-06 16:41:20 +08:00
蘭 6c51b31270 fix: Fix more button overflow screen issues (#11573) 2026-01-06 07:39:44 +00:00
ssongliu ef6e342737 fix: Fix ICMP ping disable status update abnormality issue (#11571) 2026-01-06 04:01:43 +00:00
蘭 ac0ef98dde feat: Add panel name and IP address to email alert content (#11567) 2026-01-05 22:10:27 +08:00
CityFun 523775e56b fix: Fix log viewing errors when switching nodes (#11565) 2026-01-05 18:06:36 +08:00
CityFun b6e741a150 feat: change node select params rules (#11563) 2026-01-05 17:29:51 +08:00
CityFun 54357fbe2b feat: Add backend api timeout (#11562) 2026-01-05 08:41:42 +00:00
ssongliu 040dda5bec feat: Add node overview description information (#11561) 2026-01-05 08:25:42 +00:00
CityFun 77b4d58fb8 feat: Add required validation for pushNodes (#11560) 2026-01-05 08:23:42 +00:00
CityFun 0c9e551492 fix: Fix API to prevent frontend code startup errors (#11559) 2026-01-05 07:47:42 +00:00
KOMATA 2ba1e2e22e fix: Update regex patterns for log highlighting in custom component (#11556)
* fix: Update regex patterns for log highlighting in custom component

* feat: Add new regex pattern for HTTP status codes highlighting in custom log component

* refactor: Update HTTP status code regex patterns in custom log component for improved highlighting
2026-01-05 14:49:09 +08:00
ssongliu d8a08a6c0c fix: Fix iptables rule display abnormality issue (#11555) 2026-01-05 14:48:49 +08:00
ssongliu 2c92226f88 chore: Modify cache cleanup internationalization (#11551) 2026-01-04 18:32:46 +08:00
CityFun cd9cb9b9c9 feat: Update website name validation rules (#11550) 2026-01-04 10:29:43 +00:00
CityFun b2777055bc fix: Fix website log cleanup selection state (#11549) 2026-01-04 10:27:40 +00:00
ssongliu c8673b60bb fix: Fix the issue of abnormal cache cleanup display (#11547) 2026-01-04 09:31:43 +00:00
ssongliu 21975dff2d chore: Rollback S3 backup account SDK version (#11546)
Refs #11486

aws v2 存在部分兼容性问题 https://github.com/aws/aws-sdk-go-v2/issues/3007
2026-01-04 08:51:40 +00:00
CityFun 6ab14185e0 perf: Optimize supervisor log reading (#11545)
Refs https://github.com/1Panel-dev/1Panel/issues/11515
2026-01-04 08:49:40 +00:00
CityFun 1f70646952 feat: Add enable UDP option for TCP/UDP website configuration (#11543) 2026-01-04 15:23:56 +08:00
ssongliu c90ed9e873 feat: Cache database default connections (#11540)
Refs  #10819
2026-01-04 06:39:40 +00:00
CityFun e4ee07d36b feat: Support batch installation of applications (#11539) 2026-01-04 05:43:40 +00:00
ssongliu 8fb3e78554 chore: Fix some internationalization content (#11528)
Refs #11517
2025-12-31 14:35:38 +00:00
CityFun 01ab004876 fix: update MdEditor component to prevent potential XSS attacks (#11527) 2025-12-31 14:33:38 +00:00
ssongliu 6205eac51c feat: Support enabling proxy when adding nodes (#11525) 2025-12-31 22:31:43 +08:00
王贺 d9a5418029 chore: update copyright (#11524) 2025-12-31 09:03:56 +08:00
CityFun 74a8e61f3e feat: change website http to https logic (#11522)
Refs https://github.com/1Panel-dev/1Panel/issues/11521
2025-12-30 06:33:36 +00:00
CityFun e18472f1a3 feat: optimize disk mount logic (#11519) 2025-12-30 06:31:35 +00:00
蘭 e3dea80cb7 fix: Fix the issue of garbled Chinese characters in recipient names and email titles in email alerts (#11516)
#11264
2025-12-29 09:37:35 +00:00
ssongliu 50947f4ddc feat: ICMP ping disable compatibility with Debian 13 (#11514)
Refs  #11472
2025-12-29 09:35:36 +00:00
KOMATA d302bc07b3 feat: Add support for Mux SSL mode and update related settings (#11509)
* feat: Add support for Mux SSL mode and update related settings

- Introduced a new SSL mode "Mux" in the settings, allowing for HTTP to HTTPS redirection.
- Updated the `SSL` field in the `SettingUpdate` struct to include "Mux" as a valid option.
- Modified the server logic to handle Mux connections, including certificate management and HTTP redirection.
- Updated frontend components to reflect the new SSL options and improved user guidance in multiple languages.

* fix: Update HTTPS related messages for improved clarity and security guidance in multiple languages
2025-12-29 15:41:15 +08:00
CityFun c6df20e565 feat: support sync 404 html page to websites (#11513)
Refs https://github.com/1Panel-dev/1Panel/issues/11489
2025-12-29 07:17:34 +00:00
CityFun 6f9a1321d1 feat: support custom DNS server for manual DNS mode when applying certificates (#11512) 2025-12-29 07:13:35 +00:00
ssongliu eb4eb31578 feat: Multi-machine management supports viewing node logs (#11510) 2025-12-29 07:11:34 +00:00
CityFun f8f5fcfca2 fix: Fixed Website configuration file automatically keeps adding HTTPS redirects (#11506)
#11490
2025-12-29 07:09:35 +00:00
l061x 4e7b654090 fix: Fix command argument formatting in terminal initlinux (#11484) 2025-12-26 22:03:24 +08:00
ssongliu e8059d3676 fix: Fix refresh issue during compose creation (#11482)
Refs #11463
2025-12-26 13:55:03 +00:00
ssongliu ffadc6721b fix: Fix issue where compose cannot be created when empty (#11481) 2025-12-26 09:13:04 +00:00
CityFun e2e774d215 feat: add website log clean option for clean cache (#11480) 2025-12-26 09:11:03 +00:00
ssongliu f23d3678fb fix: Fix backup account echo abnormality issue (#11479)
Refs #11478
2025-12-26 09:09:03 +00:00
ssongliu dfade50bd8 fix: Fix snapshot backup application image failure issue (#11477) 2025-12-26 16:49:03 +08:00
ssongliu d25b5f4f2a feat: Add quick operation features to node overview page (#11476) 2025-12-26 08:35:03 +00:00
CityFun 9a17bc74b0 feat: add 'nofail' option for disk mounting (#11475)
Refs https://github.com/1Panel-dev/1Panel/issues/11468
2025-12-26 08:33:03 +00:00
CityFun 015605526d fix: add missing intermediate certificates for IP certificate requests (#11470)
Refs https://github.com/1Panel-dev/1Panel/issues/11462
2025-12-26 08:31:03 +00:00
CityFun 099896dc96 feat: add app temporary download file cleanup option to cache cleaning (#11460) 2025-12-25 10:33:03 +00:00
CityFun aa1bc6fa8e feat: add website log cleanup option to cache cleaning (#11457) 2025-12-25 07:43:02 +00:00
ssongliu f27a429fc5 feat: Cache cleanup supports cleaning up unassociated backups (#11452) 2025-12-25 13:49:47 +08:00
611 changed files with 67277 additions and 13325 deletions
@@ -1,33 +1,43 @@
name: Create Release And Upload Cloudflare R2
name: Build And Publish (OSS + R2)
on:
push:
tags:
- 'v*'
jobs:
create-release:
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '22.19.0'
node-version: '24.11.1'
- name: Build Web
run: |
cd frontend && npm install && npm run build:pro
env:
NODE_OPTIONS: --max-old-space-size=8192
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: '1.24'
go-version: '1.25'
cache-dependency-path: |
core/go.sum
agent/go.sum
- name: Build Release
uses: goreleaser/goreleaser-action@v6
with:
distribution: goreleaser
version: '~> v2'
args: release --skip=publish --clean
- name: Upload Assets
uses: softprops/action-gh-release@v1
if: startsWith(github.ref, 'refs/tags/')
@@ -36,10 +46,24 @@ jobs:
files: |
dist/*.tar.gz
dist/checksums.txt
- name: Setup OSSUTIL
uses: yizhoumo/setup-ossutil@v2
with:
endpoint: ${{ secrets.OSS_ENDPOINT }}
access-key-id: ${{ secrets.OSS_ACCESS_KEY_ID }}
access-key-secret: ${{ secrets.OSS_ACCESS_KEY_SECRET }}
ossutil-version: '1.7.18'
- name: Upload Assets to OSS
run: |
ossutil cp -r dist/ oss://resource-fit2cloud-com/1panel/package/v2/stable/${{ github.ref_name }}/release/ --include "*.tar.gz" --include "checksums.txt" --only-current-dir --force
- name: Setup Rclone
uses: AnimMouse/setup-rclone@v1
with:
rclone_config: ${{ secrets.RCLONE_CONFIG }}
- name: Upload to Cloudflare R2
run: |
rclone copy dist/ cloudflare_r2:package/v2/stable/${{ github.ref_name }}/release/ --include "*.tar.gz" --include "checksums.txt" --progress
@@ -1,47 +0,0 @@
name: Create Release And Upload assets
on:
push:
tags:
- 'v*'
jobs:
create-release:
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '22.19.0'
- name: Build Web
run: |
cd frontend && npm install && npm run build:pro
env:
NODE_OPTIONS: --max-old-space-size=8192
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: '1.24'
- name: Build Release
uses: goreleaser/goreleaser-action@v6
with:
distribution: goreleaser
version: '~> v2'
args: release --skip=publish --clean
- name: Upload Assets
uses: softprops/action-gh-release@v1
if: startsWith(github.ref, 'refs/tags/')
with:
draft: true
files: |
dist/*.tar.gz
dist/checksums.txt
- name: Setup OSSUTIL
uses: yizhoumo/setup-ossutil@v2
with:
endpoint: ${{ secrets.OSS_ENDPOINT }}
access-key-id: ${{ secrets.OSS_ACCESS_KEY_ID }}
access-key-secret: ${{ secrets.OSS_ACCESS_KEY_SECRET }}
ossutil-version: '1.7.18'
- name: Upload Assets to OSS
run: ossutil cp -r dist/ oss://resource-fit2cloud-com/1panel/package/v2/stable/${{ github.ref_name }}/release/ --include "*.tar.gz" --include "checksums.txt" --only-current-dir --force
+15 -2
View File
@@ -44,12 +44,17 @@ frontend/src/xpack
agent/xpack
agent/router/entry_xpack.go
agent/server/init_xpack.go
agent/utils/xpack/xpack.go
agent/utils/xpack/xpack_xpack.go
core/xpack
core/router/entry_xpack.go
core/server/init_xpack.go
core/utils/xpack/xpack.go
core/utils/xpack/xpack_xpack.go
xpack
core/xpack-ee
core/router/entry_xpackee.go
core/server/init_xpackee.go
core/utils/xpack/xpack_xpackee.go
.history/
dist/
@@ -62,4 +67,12 @@ cmd/server/fileList.txt
1Panel.code-workspace
core/.golangci.yml
agent/.golangci.yml
agent/.golangci.yml
.opencode
openspec
CLAUDE.md
AGENTS.md
opencode.json
superpowers
.worktrees/
+69 -30
View File
@@ -1,17 +1,23 @@
<p align="center"><a href="https://1panel.pro"><img src="https://resource.1panel.pro/img/1panel-logo.png" alt="1Panel" width="300" /></a></p>
<p align="center"><b>Top-Rated Web-based Linux Server Management Tool</b><br>Best VPS control panel<br>新一代的 Linux 服务器运维管理面板</p>
<h3 align="center">The open-source VPS control panel with native AI agent support</h3>
<p align="center">
Trusted by <strong>2,000,000+</strong> self-hosters worldwide
</p>
<p align="center">
<a href="https://trendshift.io/repositories/2462" target="_blank"><img src="https://trendshift.io/api/badge/repositories/2462" alt="1Panel-dev%2F1Panel | Trendshift" style="width: 240px; height: auto;" /></a>
</p>
<p align="center">
<a href="https://www.gnu.org/licenses/gpl-3.0.html"><img src="https://shields.io/github/license/1Panel-dev/1Panel?color=%231890FF" alt="License: GPL v3"></a>
<a href="https://app.codacy.com/gh/1Panel-dev/1Panel?utm_source=github.com&utm_medium=referral&utm_content=1Panel-dev/1Panel&utm_campaign=Badge_Grade_Dashboard"><img src="https://app.codacy.com/project/badge/Grade/da67574fd82b473992781d1386b937ef" alt="Codacy"></a>
<a href="https://discord.gg/bUpUqWqdRr" target="_blank">
<img src="https://img.shields.io/discord/1318846410149335080?logo=discord&labelColor=%20%235462eb&logoColor=%20%23f5f5f5&color=%20%235462eb"
alt="chat on Discord"></a>
<a href="https://app.codacy.com/gh/1Panel-dev/1Panel"><img src="https://app.codacy.com/project/badge/Grade/da67574fd82b473992781d1386b937ef" alt="Codacy"></a>
<a href="https://discord.gg/bUpUqWqdRr"><img src="https://img.shields.io/discord/1318846410149335080?logo=discord&labelColor=%20%235462eb&logoColor=%20%23f5f5f5&color=%20%235462eb" alt="Discord"></a>
<a href="https://github.com/1Panel-dev/1Panel/releases"><img src="https://img.shields.io/github/v/release/1Panel-dev/1Panel" alt="GitHub release"></a>
<a href="https://github.com/1Panel-dev/1Panel"><img src="https://img.shields.io/github/stars/1Panel-dev/1Panel?color=%231890FF&style=flat-square" alt="Stars"></a><br>
<a href="https://github.com/1Panel-dev/1Panel"><img src="https://img.shields.io/github/stars/1Panel-dev/1Panel?color=%231890FF&style=flat-square" alt="Stars"></a>
</p>
<p align="center">
<a href="/README.md"><img alt="English" src="https://img.shields.io/badge/English-d9d9d9"></a>
<a href="/docs/README.zh-Hans.md"><img alt="中文(简体)" src="https://img.shields.io/badge/中文(简体)-d9d9d9"></a>
@@ -19,58 +25,91 @@
<a href="/docs/README.pt-br.md"><img alt="Português (Brasil)" src="https://img.shields.io/badge/Português (Brasil)-d9d9d9"></a>
<a href="/docs/README.ar.md"><img alt="العربية" src="https://img.shields.io/badge/العربية-d9d9d9"></a>
<a href="/docs/README.de.md"><img alt="Deutsch" src="https://img.shields.io/badge/Deutsch-d9d9d9"></a>
<a href="/docs/README.es.md"><img alt="Español" src="https://img.shields.io/badge/Español-d9d9d9"></a><br>
<a href="/docs/README.es.md"><img alt="Español" src="https://img.shields.io/badge/Español-d9d9d9"></a>
<a href="/docs/README.fr.md"><img alt="français" src="https://img.shields.io/badge/français-d9d9d9"></a>
<a href="/docs/README.ko.md"><img alt="한국어" src="https://img.shields.io/badge/한국어-d9d9d9"></a>
<a href="/docs/README.id.md"><img alt="Bahasa Indonesia" src="https://img.shields.io/badge/Bahasa Indonesia-d9d9d9"></a>
<a href="/docs/README.zh-Hant.md"><img alt="中文(繁體)" src="https://img.shields.io/badge/中文(繁體)-d9d9d9"></a>
<a href="/docs/README.tr.md"><img alt="Türkçe" src="https://img.shields.io/badge/Türkçe-d9d9d9"></a>
<a href="/docs/README.ru.md"><img alt="Русский" src="https://img.shields.io/badge/%D0%A0%D1%83%D1%81%D1%81%D0%BA%D0%B8%D0%B9-d9d9d9"></a>
<a href="/docs/README.ru.md"><img alt="Русский" src="https://img.shields.io/badge/Русский-d9d9d9"></a>
<a href="/docs/README.ms.md"><img alt="Bahasa Melayu" src="https://img.shields.io/badge/Bahasa Melayu-d9d9d9"></a>
</p>
------------------------------
---
1Panel is an open-source, modern web-based control panel for Linux server management.
## What is 1Panel?
- **Efficient Management**: Through a user-friendly web graphical interface, 1Panel enables users to effortlessly manage their Linux servers. Key features include host monitoring, file management, database administration, container management, LLMs management.
- **Rapid Website Deployment**: With deep integration of the popular open-source website building software WordPress, 1Panel streamlines the process of domain binding and SSL certificate configuration, all achievable with just one click.
- **Application Store**: 1Panel curates a wide range of high-quality open-source tools and applications, facilitating easy installation and updates for its users.
- **Security and Reliability**: By leveraging containerization and secure application deployment practices, 1Panel minimizes vulnerability exposure. It further enhances security through integrated firewall management and log auditing capabilities.
- **One-Click Backup & Restore**: Data protection is made simple with 1Panel's one-click backup and restore functionality, supporting various cloud storage solutions to ensure data integrity and availability.
1Panel is a modern, open-source VPS control panel — and the only one with **native AI agent support**. Run Ollama models, deploy OpenClaw agents, and manage your entire server stack from one clean web interface. No CLI memorization required.
👉 Watch the [2-minute introduction](https://www.youtube.com/watch?v=Jl_wqp-XA08)
## Why 1Panel?
| | 1Panel | cPanel / Plesk | aaPanel | Webmin |
|--|--------|----------------|---------|--------|
| Free & open source | ✅ | ❌ | Partial | ✅ |
| Native AI agent runtime | ✅ | ❌ | ❌ | ❌ |
| One-click app marketplace | ✅ 165+ apps | ❌ | ✅ | ❌ |
| Modern UI (post-2020) | ✅ | ❌ | Partial | ❌ |
| Docker / container management | ✅ | ❌ | ❌ | ❌ |
| Active development | ✅ | ✅ | ✅ | Slow |
## Key Features
- **AI Agent Runtime**: Deploy Ollama LLMs, spin up OpenClaw personal agents, and monitor GPU utilization — all from the dashboard. No separate AI stack to manage.
- **One-Click Website Deployment**: Launch production-ready websites with automatic domain binding, SSL provisioning, and Nginx config — zero manual setup.
- **App Marketplace**: 165+ trusted open-source apps (Nextcloud, Bitwarden, Umami, NocoBase, and more) installed and updated with a single click.
- **Docker & Container Management**: Create, start, stop, and inspect containers, images, networks, and volumes through a visual UI — no CLI juggling.
- **Security Out of the Box**: Firewall rules, fail2ban, container isolation, WAF, and audit logs — configured and running from day one.
- **Backup & Restore**: Schedule automated backups to AWS S3, Cloudflare R2, or local storage. Restore any snapshot in one click.
## Quick Start
Execute the script below and follow the prompts to install 1Panel:
> **Requirements:** Linux VPS (Debian / Ubuntu / CentOS / Rocky), 1 GB RAM, internet access.
> Takes ~60 seconds.
```bash
curl -sSL https://resource.1panel.pro/quick_start.sh -o quick_start.sh && bash quick_start.sh
bash -c "$(curl -sSL https://resource.1panel.pro/v2/quick_start.sh)"
```
Please refer to our [documentation](https://docs.1panel.pro/quick_start/) for more details.
中国用户请使用这个 [安装脚本](https://1panel.cn/docs/installation/online_installation/),其应用数量比国际版本更丰富。
After installation, open `http://<your-server-ip>:<port>/<security-path>` in your browser.
Run `1pctl user-info` via SSH if you need to retrieve your access credentials.
## Screenshot
![UI Display](https://resource.1panel.pro/img/1panel.png)
![1Panel UI](https://resource.1panel.pro/img/overview_en_v2.png)
## Pro Edition
1Panel OSS is free forever. Pro adds features built for teams and production workloads:
| Feature | OSS | Pro |
|---------|:---:|:---:|
| One-click app installs | ✅ | ✅ |
| AI agents (OpenClaw) | 1 agent | Unlimited |
| WAF & advanced security | Basic | ✅ |
| Website tamper protection | ❌ | ✅ |
| Website uptime monitoring | ❌ | ✅ |
| Multi-node management | ❌ | ✅ |
| Custom logo & theme | ❌ | ✅ |
| Priority support | ❌ | ✅ |
**From $80/year.** [Compare plans & start 30-day free trial →](https://1panel.pro/pricing)
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=1Panel-dev/1Panel&type=Date)](https://star-history.com/#1Panel-dev/1Panel&Date)
## Pro Edition
## Community & Support
Compared to the OSS Edition, 1Panel Pro Edition provides users with a wealth of enhanced features and technical support services. Enhanced features include WAF enhancement, website tamper protection, website monitoring, GPU monitoring, custom logo and theme color, etc. [Click to view the detailed introduction of the Pro Edition](https://1panel.pro/pricing).
- **Discord** — [Join the community](https://discord.gg/bUpUqWqdRr) for help, feature requests, and show-and-tell
- **Docs** — [1panel.pro/docs](https://1panel.pro/docs)
- **Issues** — [GitHub Issues](https://github.com/1Panel-dev/1Panel/issues) for bug reports
## Security Information
## Security
If you discover any security issues, please refer to [SECURITY.md](/SECURITY.md).
Found a vulnerability? Please read [SECURITY.md](/SECURITY.md) before disclosing.
## License
Licensed under The GNU General Public License version 3 (GPLv3) (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at
<https://www.gnu.org/licenses/gpl-3.0.html>
Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
Licensed under the [GNU General Public License v3.0](https://www.gnu.org/licenses/gpl-3.0.html).
File diff suppressed because it is too large Load Diff
+42 -9
View File
@@ -2,12 +2,12 @@ package v2
import (
"net/http"
"time"
"github.com/1Panel-dev/1Panel/agent/app/api/v2/helper"
"github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/app/dto/request"
"github.com/1Panel-dev/1Panel/agent/i18n"
"github.com/1Panel-dev/1Panel/agent/utils/appicon"
"github.com/gin-gonic/gin"
)
@@ -164,7 +164,7 @@ func (b *BaseApi) InstallApp(c *gin.Context) {
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
install, err := appService.Install(req)
install, err := appService.Install(req, true)
if err != nil {
helper.InternalServer(c, err)
return
@@ -203,20 +203,53 @@ func (b *BaseApi) GetAppListUpdate(c *gin.Context) {
// @Success 200 {file} file "app icon"
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /apps/icon/:appId [get]
// @Router /apps/icon/:key [get]
func (b *BaseApi) GetAppIcon(c *gin.Context) {
appID, err := helper.GetIntParamByKey(c, "appID")
appKey, err := helper.GetStrParamByKey(c, "key")
if err != nil {
helper.BadRequest(c, err)
return
}
iconBytes, err := appService.GetAppIcon(appID)
iconBytes, filename, etag, err := appService.GetAppIcon(appKey)
if err != nil {
helper.InternalServer(c, err)
return
}
c.Header("Content-Type", "image/png")
c.Header("Cache-Control", "public, max-age=31536000, immutable")
c.Header("Last-Modified", time.Now().UTC().Format(http.TimeFormat))
c.Data(http.StatusOK, "image/png", iconBytes)
if len(iconBytes) == 0 {
c.Status(http.StatusNoContent)
return
}
c.Header("Cache-Control", "public, max-age=2592000")
if etag != "" && filename != "" {
c.Header("ETag", etag)
if c.GetHeader("If-None-Match") == etag {
c.Status(http.StatusNotModified)
return
}
}
c.Data(http.StatusOK, appicon.ContentTypePNG, iconBytes)
}
// @Tags App
// @Summary Search app detail by appkey and version
// @Accept json
// @Param appId path integer true "app key"
// @Param version path string true "app version"
// @Success 200 {object} response.AppDetailSimpleDTO
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /apps/detail/node/:appKey/:version [get]
func (b *BaseApi) GetAppDetailForNode(c *gin.Context) {
appKey := c.Param("appKey")
version := c.Param("version")
appDetailDTO, err := appService.GetAppDetailByKey(appKey, version)
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, appDetailDTO)
}
+14 -2
View File
@@ -109,7 +109,7 @@ func (b *BaseApi) LoadPort(c *gin.Context) {
// @Success 200 {object} response.DatabaseConn
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /apps/installed/conninfo [POST]
// @Router /apps/installed/conninfo [post]
func (b *BaseApi) LoadConnInfo(c *gin.Context) {
var req dto.OperationWithNameAndType
if err := helper.CheckBindAndValidate(&req, c); err != nil {
@@ -294,7 +294,7 @@ func (b *BaseApi) GetParams(c *gin.Context) {
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /apps/installed/params/update [post]
// @x-panel-log {"bodyKeys":["installId"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"应用参数修改 [installId]","formatEN":"Application param update [installId]"}
// @x-panel-log {"bodyKeys":["installId"],"paramKeys":[],"BeforeFunctions":[{"input_column":"id","input_value":"installId","isList":false,"db":"app_installs","output_column":"name","output_value":"name"}],"formatZH":"应用参数修改 [name]","formatEN":"Application param update [name]"}
func (b *BaseApi) UpdateInstalled(c *gin.Context) {
var req request.AppInstalledUpdate
if err := helper.CheckBindAndValidate(&req, c); err != nil {
@@ -349,3 +349,15 @@ func (b *BaseApi) GetAppInstallInfo(c *gin.Context) {
}
helper.SuccessWithData(c, info)
}
func (b *BaseApi) UpdateAppInstallSort(c *gin.Context) {
var req request.AppInstallSort
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if err := appInstallService.UpdateSort(req); err != nil {
helper.InternalServer(c, err)
return
}
helper.Success(c)
}
+30
View File
@@ -430,6 +430,16 @@ func (b *BaseApi) Backup(c *gin.Context) {
helper.InternalServer(c, err)
return
}
case "container":
if err := backupService.ContainerBackup(req); err != nil {
helper.InternalServer(c, err)
return
}
case "compose":
if err := backupService.ComposeBackup(req); err != nil {
helper.InternalServer(c, err)
return
}
}
helper.Success(c)
}
@@ -485,6 +495,16 @@ func (b *BaseApi) Recover(c *gin.Context) {
helper.InternalServer(c, err)
return
}
case "container":
if err := backupService.ContainerRecover(req); err != nil {
helper.InternalServer(c, err)
return
}
case "compose":
if err := backupService.ComposeRecover(req); err != nil {
helper.InternalServer(c, err)
return
}
}
helper.Success(c)
}
@@ -525,6 +545,16 @@ func (b *BaseApi) RecoverByUpload(c *gin.Context) {
helper.InternalServer(c, err)
return
}
case "container":
if err := backupService.ContainerRecover(req); err != nil {
helper.InternalServer(c, err)
return
}
case "compose":
if err := backupService.ComposeRecover(req); err != nil {
helper.InternalServer(c, err)
return
}
}
helper.Success(c)
}
+183 -22
View File
@@ -1,6 +1,9 @@
package v2
import (
"net/http"
"net/url"
"path"
"strconv"
"github.com/1Panel-dev/1Panel/agent/app/api/v2/helper"
@@ -53,6 +56,162 @@ func (b *BaseApi) LoadContainerUsers(c *gin.Context) {
helper.SuccessWithData(c, containerService.LoadUsers(req))
}
// @Tags Container
// @Summary List container files
// @Accept json
// @Param request body dto.ContainerFileReq true "request"
// @Success 200 {array} dto.ContainerFileInfo
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /containers/files/search [post]
func (b *BaseApi) ListContainerFiles(c *gin.Context) {
var req dto.ContainerFileReq
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
files, err := containerService.ListContainerFiles(req)
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, files)
}
// @Tags Container
// @Summary Upload container file
// @Accept multipart/form-data
// @Param containerID formData string true "containerID"
// @Param path formData string true "path"
// @Param file formData file true "file"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /containers/files/upload [post]
// @x-panel-log {"bodyKeys":["containerID","path"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"容器 [containerID] 上传文件到 [path]","formatEN":"Upload file to [path] in container [containerID]"}
func (b *BaseApi) UploadContainerFile(c *gin.Context) {
form, err := c.MultipartForm()
if err != nil {
helper.BadRequest(c, err)
return
}
containerIDs := form.Value["containerID"]
paths := form.Value["path"]
uploadFiles := form.File["file"]
if len(containerIDs) == 0 || len(paths) == 0 || len(uploadFiles) == 0 {
helper.BadRequest(c, errors.New("invalid container file upload params"))
return
}
req := dto.ContainerFileReq{
ContainerID: containerIDs[0],
Path: paths[0],
}
for _, uploadFile := range uploadFiles {
file, err := uploadFile.Open()
if err != nil {
helper.InternalServer(c, err)
return
}
err = containerService.UploadContainerFile(req, path.Base(uploadFile.Filename), uploadFile.Size, file)
_ = file.Close()
if err != nil {
helper.InternalServer(c, err)
return
}
}
helper.Success(c)
}
// @Tags Container
// @Summary Get container file content
// @Accept json
// @Param request body dto.ContainerFileReq true "request"
// @Success 200 {object} dto.ContainerFileContent
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /containers/files/content [post]
func (b *BaseApi) GetContainerFileContent(c *gin.Context) {
var req dto.ContainerFileReq
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
content, err := containerService.GetContainerFileContent(req)
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, content)
}
// @Tags Container
// @Summary Get container file size
// @Accept json
// @Param request body dto.ContainerFileReq true "request"
// @Success 200 {int} size
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /containers/files/size [post]
func (b *BaseApi) GetContainerFileSize(c *gin.Context) {
var req dto.ContainerFileReq
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
size, err := containerService.GetContainerFileSize(req)
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, size)
}
// @Tags Container
// @Summary Delete container file
// @Accept json
// @Param request body dto.ContainerFileBatchDeleteReq true "request"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /containers/files/del [post]
// @x-panel-log {"bodyKeys":["containerID","paths"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"删除容器 [containerID] 文件 [paths]","formatEN":"Delete files [paths] in container [containerID]"}
func (b *BaseApi) DeleteContainerFile(c *gin.Context) {
var req dto.ContainerFileBatchDeleteReq
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if err := containerService.DeleteContainerFile(req); err != nil {
helper.InternalServer(c, err)
return
}
helper.Success(c)
}
// @Tags Container
// @Summary Download container file
// @Accept json
// @Param request body dto.ContainerFileReq true "request"
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /containers/files/download [post]
// @x-panel-log {"bodyKeys":["containerID","path"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"下载容器 [containerID] 文件 [path]","formatEN":"Download file [path] from container [containerID]"}
func (b *BaseApi) DownloadContainerFile(c *gin.Context) {
var req dto.ContainerFileReq
if err := c.ShouldBindJSON(&req); err != nil {
helper.BadRequest(c, err)
return
}
if req.ContainerID == "" || req.Path == "" {
helper.BadRequest(c, errors.New("invalid container file download params"))
return
}
reader, fileName, contentType, err := containerService.DownloadContainerFile(req)
if err != nil {
helper.InternalServer(c, err)
return
}
defer reader.Close()
c.Header("Content-Disposition", "attachment; filename*=utf-8''"+url.PathEscape(fileName))
c.DataFromReader(http.StatusOK, -1, contentType, reader, nil)
}
// @Tags Container
// @Summary List containers
// @Accept json
@@ -306,27 +465,6 @@ func (b *BaseApi) ContainerCreate(c *gin.Context) {
helper.Success(c)
}
// @Tags Container
// @Summary Create container by command
// @Accept json
// @Param request body dto.ContainerCreateByCommand true "request"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /containers/command [post]
func (b *BaseApi) ContainerCreateByCommand(c *gin.Context) {
var req dto.ContainerCreateByCommand
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if err := containerService.ContainerCreateByCommand(req); err != nil {
helper.InternalServer(c, err)
return
}
helper.Success(c)
}
// @Tags Container
// @Summary Upgrade container
// @Accept json
@@ -529,7 +667,7 @@ func (b *BaseApi) DownloadContainerLogs(c *gin.Context) {
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
err := containerService.DownloadContainerLogs(req.ContainerType, req.Container, req.Since, strconv.Itoa(int(req.Tail)), c)
err := containerService.DownloadContainerLogs(req.ContainerType, req.Container, req.Since, strconv.Itoa(int(req.Tail)), req.Timestamp, c)
if err != nil {
helper.InternalServer(c, err)
}
@@ -731,12 +869,33 @@ func (b *BaseApi) ComposeUpdate(c *gin.Context) {
helper.Success(c)
}
// @Tags Container Compose
// @Summary Load compose environment variables
// @Accept json
// @Param request body dto.FilePath true "request"
// @Success 200 {array} string
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /containers/compose/env [post]
func (b *BaseApi) LoadComposeEnv(c *gin.Context) {
var req dto.FilePath
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
data, err := containerService.LoadComposeEnv(req.Path)
if err != nil {
helper.InternalServer(c, err)
}
helper.SuccessWithData(c, data)
}
// @Tags Container
// @Summary Container logs
// @Param container query string false "容器名称"
// @Param since query string false "时间筛选"
// @Param follow query string false "是否追踪"
// @Param tail query string false "显示行号"
// @Param timestamp query string false "是否显示时间"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
@@ -750,6 +909,7 @@ func (b *BaseApi) ContainerStreamLogs(c *gin.Context) {
since := c.Query("since")
follow := c.Query("follow") == "true"
tail := c.Query("tail")
timestamp := c.Query("timestamp") == "true"
container := c.Query("container")
compose := c.Query("compose")
@@ -759,6 +919,7 @@ func (b *BaseApi) ContainerStreamLogs(c *gin.Context) {
Since: since,
Follow: follow,
Tail: tail,
Timestamp: timestamp,
Type: "container",
}
if compose != "" {
+2 -2
View File
@@ -12,7 +12,7 @@ import (
// @Tags Container Docker
// @Summary Load docker status
// @Produce json
// @Success 200 {string} dto.DockerStatus
// @Success 200 {object} dto.DockerStatus
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /containers/docker/status [get]
@@ -24,7 +24,7 @@ func (b *BaseApi) LoadDockerStatus(c *gin.Context) {
// @Tags Container Docker
// @Summary Load docker daemon.json
// @Produce json
// @Success 200 {object} string
// @Success 200 {string} string
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /containers/daemonjson/file [get]
+9 -6
View File
@@ -20,6 +20,7 @@ var (
aiToolService = service.NewIAIToolService()
mcpServerService = service.NewIMcpServerService()
tensorrtLLMService = service.NewITensorRTLLMService()
agentService = service.NewIAgentService()
containerService = service.NewIContainerService()
composeTemplateService = service.NewIComposeTemplateService()
@@ -35,12 +36,13 @@ var (
cronjobService = service.NewICronjobService()
fileService = service.NewIFileService()
sshService = service.NewISSHService()
firewallService = service.NewIFirewallService()
iptablesService = service.NewIIptablesService()
monitorService = service.NewIMonitorService()
systemService = service.NewISystemService()
fileService = service.NewIFileService()
fileShareService = service.NewIFileShareService()
sshService = service.NewISSHService()
firewallService = service.NewIFirewallService()
iptablesService = service.NewIIptablesService()
monitorService = service.NewIMonitorService()
systemService = service.NewISystemService()
deviceService = service.NewIDeviceService()
fail2banService = service.NewIFail2BanService()
@@ -69,6 +71,7 @@ var (
recycleBinService = service.NewIRecycleBinService()
favoriteService = service.NewIFavoriteService()
hostService = service.NewIHostService()
websiteCAService = service.NewIWebsiteCAService()
taskService = service.NewITaskService()
+327
View File
@@ -25,6 +25,7 @@ import (
websocket2 "github.com/1Panel-dev/1Panel/agent/utils/websocket"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
qrcode "github.com/skip2/go-qrcode"
)
// @Tags File
@@ -48,6 +49,31 @@ func (b *BaseApi) ListFiles(c *gin.Context) {
helper.SuccessWithData(c, fileList)
}
// @Tags File
// @Summary File search: content grep + optional AI summary
// @Description When file-management AI is enabled, returns mode=ai with summary and hits. When disabled, returns mode=grep with hits only. Scans file contents only. Supports match options, extension/size/time filters, and scan limits.
// @Accept json
// @Param request body request.FileAISearch true "request"
// @Success 200 {object} response.FileAISearchResult
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /files/ai-search [post]
func (b *BaseApi) FileAISearch(c *gin.Context) {
var req request.FileAISearch
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if strings.TrimSpace(req.ResponseLanguage) == "" {
req.ResponseLanguage = strings.TrimSpace(c.GetHeader("Accept-Language"))
}
res, err := fileService.AISearch(req)
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, res)
}
// @Tags File
// @Summary Page file
// @Accept json
@@ -515,6 +541,29 @@ func (b *BaseApi) WgetFile(c *gin.Context) {
})
}
// @Tags File
// @Summary Stop wget file download
// @Accept json
// @Param request body request.FileProcessReq true "request"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /files/wget/stop [post]
// @x-panel-log {"bodyKeys":["key"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"停止下载任务 [key]","formatEN":"Stop wget task [key]"}
func (b *BaseApi) StopWget(c *gin.Context) {
var req request.FileProcessReq
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if strings.TrimSpace(req.Key) == "" {
helper.BadRequest(c, errors.New("key is required"))
return
}
files.CancelDownload(req.Key)
helper.Success(c)
}
// @Tags File
// @Summary Move file
// @Accept json
@@ -978,3 +1027,281 @@ func (b *BaseApi) ConvertLog(c *gin.Context) {
Total: total,
})
}
// @Tags File
// @Summary Batch get file remarks
// @Accept json
// @Param request body request.FileRemarkBatch true "request"
// @Success 200 {object} response.FileRemarksRes
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /files/remarks [post]
func (b *BaseApi) BatchGetFileRemarks(c *gin.Context) {
var req request.FileRemarkBatch
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
remarks := fileService.BatchGetRemarks(req)
helper.SuccessWithData(c, response.FileRemarksRes{Remarks: remarks})
}
// @Tags File
// @Summary Set file remark
// @Accept json
// @Param request body request.FileRemarkUpdate true "request"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /files/remark [post]
func (b *BaseApi) SetFileRemark(c *gin.Context) {
var req request.FileRemarkUpdate
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if err := fileService.SetRemark(req); err != nil {
helper.InternalServer(c, err)
return
}
helper.Success(c)
}
// @Tags File
// @Summary List file shares
// @Accept json
// @Param request body dto.PageInfo true "request"
// @Success 200 {object} dto.PageResult
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /files/share/search [post]
func (b *BaseApi) SearchFileShare(c *gin.Context) {
var req dto.PageInfo
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
total, list, err := fileShareService.Page(req)
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, dto.PageResult{
Total: total,
Items: list,
})
}
// @Tags File
// @Summary Get file share detail by path
// @Accept json
// @Param request body dto.FilePath true "request"
// @Success 200 {object} response.FileShareInfo
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /files/share/detail [post]
func (b *BaseApi) GetFileShareDetail(c *gin.Context) {
var req dto.FilePath
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
info, err := fileShareService.GetByPath(req.Path)
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, info)
}
// @Tags File
// @Summary Get file share detail by code (no login)
// @Param code query string true "share code"
// @Success 200 {object} response.FileSharePublicInfo
// @Router /files/share/info [get]
func (b *BaseApi) GetPublicFileShareInfo(c *gin.Context) {
code := strings.TrimSpace(c.Query("code"))
if code == "" {
helper.BadRequest(c, errors.New("code is required"))
return
}
info, err := fileShareService.GetPublicByCode(code)
if err != nil {
if be, ok := err.(buserr.BusinessError); ok {
helper.ErrorWithDetail(c, http.StatusBadRequest, be.Msg, be)
return
}
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, info)
}
func buildSharePublicURL(c *gin.Context, code, operateNode string) string {
scheme := strings.TrimSpace(c.GetHeader("X-Forwarded-Proto"))
if scheme == "" {
if c.Request.TLS != nil {
scheme = "https"
} else {
scheme = "http"
}
}
host := strings.TrimSpace(c.GetHeader("X-Forwarded-Host"))
if host == "" {
host = c.Request.Host
}
shareURL := url.URL{
Scheme: scheme,
Host: host,
Path: "/s/" + url.PathEscape(code),
}
query := shareURL.Query()
if strings.TrimSpace(operateNode) != "" {
query.Set("operateNode", operateNode)
}
shareURL.RawQuery = query.Encode()
return shareURL.String()
}
// @Tags File
// @Summary Get file share QR code image
// @Produce png
// @Param code query string true "share code"
// @Param operateNode query string false "operate node"
// @Success 200 {file} file
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /files/share/qrcode [get]
func (b *BaseApi) GetFileShareQRCode(c *gin.Context) {
code := strings.TrimSpace(c.Query("code"))
if code == "" {
helper.BadRequest(c, errors.New("code is required"))
return
}
if _, err := fileShareService.GetByCode(code); err != nil {
if be, ok := err.(buserr.BusinessError); ok {
helper.ErrorWithDetail(c, http.StatusBadRequest, be.Msg, be)
return
}
helper.InternalServer(c, err)
return
}
png, err := qrcode.Encode(buildSharePublicURL(c, code, c.Query("operateNode")), qrcode.Medium, 256)
if err != nil {
helper.InternalServer(c, err)
return
}
c.Header("Cache-Control", "private, max-age=300")
c.Data(http.StatusOK, "image/png", png)
}
// @Tags File
// @Summary Create temporary file share link
// @Accept json
// @Param request body request.FileShareCreate true "request"
// @Success 200 {object} response.FileShareInfo
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /files/share/create [post]
// @x-panel-log {"bodyKeys":["path","expireMinutes"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"创建文件分享 [path]","formatEN":"Create file share [path]"}
func (b *BaseApi) CreateFileShare(c *gin.Context) {
var req request.FileShareCreate
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
res, err := fileShareService.Create(req)
if err != nil {
if be, ok := err.(buserr.BusinessError); ok {
helper.ErrorWithDetail(c, http.StatusInternalServerError, be.Msg, be.Err)
return
}
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, res)
}
// @Tags File
// @Summary Delete file share by path
// @Accept json
// @Param request body dto.FilePath true "request"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /files/share/del [post]
// @x-panel-log {"bodyKeys":["path"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"关闭文件分享 [path]","formatEN":"Close file share [path]"}
func (b *BaseApi) DeleteFileShare(c *gin.Context) {
var req dto.FilePath
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if err := fileShareService.DeleteByPath(req.Path); err != nil {
if be, ok := err.(buserr.BusinessError); ok {
helper.ErrorWithDetail(c, http.StatusInternalServerError, be.Msg, be.Err)
return
}
helper.InternalServer(c, err)
return
}
helper.Success(c)
}
// @Tags File
// @Summary Check file share code (no login)
// @Param code query string true "share code"
// @Param password query string false "optional password"
// @Success 200 {object} dto.Response
// @Router /files/share/check [get]
func (b *BaseApi) CheckFileShare(c *gin.Context) {
code := strings.TrimSpace(c.Query("code"))
password := c.Query("password")
if code == "" {
helper.BadRequest(c, errors.New("code is required"))
return
}
if err := fileShareService.Check(code, password); err != nil {
if be, ok := err.(buserr.BusinessError); ok {
helper.ErrorWithDetail(c, http.StatusBadRequest, be.Msg, be)
return
}
helper.InternalServer(c, err)
return
}
helper.Success(c)
}
// @Tags File
// @Summary Download file by share code (no login)
// @Produce octet-stream
// @Param code query string true "share code"
// @Param password query string false "optional password"
// @Success 200 {file} file
// @Router /files/share/download [get]
func (b *BaseApi) DownloadFileShare(c *gin.Context) {
code := strings.TrimSpace(c.Query("code"))
password := c.Query("password")
if code == "" {
helper.BadRequest(c, errors.New("code is required"))
return
}
filePath, displayName, err := fileShareService.PrepareDownload(code, password)
if err != nil {
if be, ok := err.(buserr.BusinessError); ok {
helper.ErrorWithDetail(c, http.StatusBadRequest, be.Msg, be)
return
}
helper.InternalServer(c, err)
return
}
file, err := os.Open(filePath)
if err != nil {
helper.InternalServer(c, err)
return
}
defer file.Close()
info, err := file.Stat()
if err != nil {
helper.InternalServer(c, err)
return
}
c.Header("Content-Length", strconv.FormatInt(info.Size(), 10))
c.Header("Content-Disposition", "attachment; filename*=utf-8''"+url.PathEscape(displayName))
http.ServeContent(c.Writer, c.Request, displayName, info.ModTime(), file)
}
+167
View File
@@ -0,0 +1,167 @@
package v2
import (
"github.com/1Panel-dev/1Panel/agent/app/api/v2/helper"
"github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/utils/encrypt"
"github.com/gin-gonic/gin"
)
func (b *BaseApi) CreateHost(c *gin.Context) {
var req dto.HostOperate
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
host, err := hostService.Create(req)
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, host)
}
func (b *BaseApi) TestByInfo(c *gin.Context) {
var req dto.HostConnTest
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
helper.SuccessWithData(c, hostService.TestByInfo(req))
}
func (b *BaseApi) TestByID(c *gin.Context) {
var req dto.OperateByID
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
helper.SuccessWithData(c, hostService.TestLocalConn(req.ID))
}
func (b *BaseApi) HostTree(c *gin.Context) {
var req dto.SearchForTree
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
data, err := hostService.SearchForTree(req)
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, data)
}
func (b *BaseApi) SearchHost(c *gin.Context) {
var req dto.SearchPageWithGroup
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
total, list, err := hostService.SearchWithPage(req)
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, dto.PageResult{Items: list, Total: total})
}
func (b *BaseApi) DeleteHost(c *gin.Context) {
var req dto.OperateByIDs
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if err := hostService.Delete(req.IDs); err != nil {
helper.InternalServer(c, err)
return
}
helper.Success(c)
}
func (b *BaseApi) UpdateHost(c *gin.Context) {
var req dto.HostOperate
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
var err error
if len(req.Password) != 0 && req.AuthMode == "password" {
req.Password, err = hostService.EncryptHost(req.Password)
if err != nil {
helper.BadRequest(c, err)
return
}
req.PrivateKey = ""
req.PassPhrase = ""
}
if len(req.PrivateKey) != 0 && req.AuthMode == "key" {
req.PrivateKey, err = hostService.EncryptHost(req.PrivateKey)
if err != nil {
helper.BadRequest(c, err)
return
}
if len(req.PassPhrase) != 0 {
req.PassPhrase, err = encrypt.StringEncrypt(req.PassPhrase)
if err != nil {
helper.BadRequest(c, err)
return
}
}
req.Password = ""
}
upMap := map[string]interface{}{
"name": req.Name,
"group_id": req.GroupID,
"addr": req.Addr,
"port": req.Port,
"user": req.User,
"auth_mode": req.AuthMode,
"remember_password": req.RememberPassword,
"description": req.Description,
}
if req.AuthMode == "password" {
upMap["password"] = req.Password
upMap["private_key"] = ""
upMap["pass_phrase"] = ""
} else {
upMap["password"] = ""
upMap["private_key"] = req.PrivateKey
upMap["pass_phrase"] = req.PassPhrase
}
hostItem, err := hostService.Update(req.ID, upMap)
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, hostItem)
}
func (b *BaseApi) UpdateHostGroup(c *gin.Context) {
var req dto.ChangeGroup
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if _, err := hostService.Update(req.ID, map[string]interface{}{"group_id": req.GroupID}); err != nil {
helper.InternalServer(c, err)
return
}
helper.Success(c)
}
func (b *BaseApi) GetHostByID(c *gin.Context) {
var req dto.OperateByID
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
info, err := hostService.GetHostByID(req.ID)
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, info)
}
+15
View File
@@ -57,3 +57,18 @@ func (b *BaseApi) GetProcessInfoByPID(c *gin.Context) {
}
helper.SuccessWithData(c, data)
}
// @Tags Process
// @Summary Get Listening Process
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /process/listening [post]
func (b *BaseApi) GetListeningProcess(c *gin.Context) {
procs, err := processService.GetListeningProcess(c)
if err != nil {
helper.BadRequest(c, err)
return
}
helper.SuccessWithData(c, procs)
}
+47 -1
View File
@@ -27,6 +27,15 @@ func (b *BaseApi) GetSettingInfo(c *gin.Context) {
helper.SuccessWithData(c, setting)
}
func (b *BaseApi) GetTerminalAISettingInfo(c *gin.Context) {
setting, err := settingService.GetTerminalAIInfo()
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, setting)
}
// @Tags System Setting
// @Summary Load system available status
// @Success 200
@@ -59,6 +68,40 @@ func (b *BaseApi) UpdateSetting(c *gin.Context) {
helper.Success(c)
}
func (b *BaseApi) UpdateTerminalAISetting(c *gin.Context) {
var req dto.TerminalAIInfo
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if err := settingService.UpdateTerminalAI(req); err != nil {
helper.InternalServer(c, err)
return
}
helper.Success(c)
}
func (b *BaseApi) GetFileManageAISettingInfo(c *gin.Context) {
setting, err := settingService.GetFileManageAIInfo()
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, setting)
}
func (b *BaseApi) UpdateFileManageAISetting(c *gin.Context) {
var req dto.FileManageAIInfo
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if err := settingService.UpdateFileManageAI(req); err != nil {
helper.InternalServer(c, err)
return
}
helper.Success(c)
}
// @Tags System Setting
// @Summary Load local backup dir
// @Success 200 {string} path
@@ -80,7 +123,10 @@ func (b *BaseApi) LoadLocalConn(c *gin.Context) {
}
func (b *BaseApi) CheckLocalConn(c *gin.Context) {
_, err := loadLocalConn()
client, err := loadLocalConn()
if err == nil && client != nil {
client.Close()
}
helper.SuccessWithData(c, err == nil)
}
+2 -2
View File
@@ -249,14 +249,14 @@ func (b *BaseApi) LoadSSHFile(c *gin.Context) {
// @Tags SSH
// @Summary Update host SSH setting by file
// @Accept json
// @Param request body dto.SSHConf true "request"
// @Param request body dto.SSHConfUpdate true "request"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /hosts/ssh/file/update [post]
// @x-panel-log {"bodyKeys":["key"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"修改 SSH 配置文件 [key]","formatEN":"update SSH conf [key]"}
func (b *BaseApi) UpdateSSHByFile(c *gin.Context) {
var req dto.SettingUpdate
var req dto.SSHConfUpdate
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
+1 -1
View File
@@ -32,7 +32,7 @@ func (b *BaseApi) PageTasks(c *gin.Context) {
// @Tags TaskLog
// @Summary Get the number of executing tasks
// @Success 200 {object} int64
// @Success 200 {integer} int64
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /logs/tasks/executing/count [get]
+29 -3
View File
@@ -9,8 +9,10 @@ import (
"time"
"github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/app/service"
"github.com/1Panel-dev/1Panel/agent/global"
"github.com/1Panel-dev/1Panel/agent/utils/cmd"
"github.com/1Panel-dev/1Panel/agent/utils/ssh"
"github.com/1Panel-dev/1Panel/agent/utils/terminal"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
@@ -40,9 +42,33 @@ func (b *BaseApi) WsSSH(c *gin.Context) {
return
}
client, err := loadLocalConn()
if wshandleError(wsConn, errors.WithMessage(err, "failed to set up the connection. Please check the host information")) {
return
hostID, _ := strconv.Atoi(c.DefaultQuery("id", "0"))
var client *ssh.SSHClient
if hostID > 0 {
host, err := service.GetHostInfo(uint(hostID))
if wshandleError(wsConn, errors.WithMessage(err, "load host info by id failed")) {
return
}
connInfo := ssh.ConnInfo{
Addr: host.Addr,
Port: int(host.Port),
User: host.User,
AuthMode: host.AuthMode,
Password: host.Password,
PrivateKey: []byte(host.PrivateKey),
}
if len(host.PassPhrase) != 0 {
connInfo.PassPhrase = []byte(host.PassPhrase)
}
client, err = ssh.NewClient(connInfo)
if wshandleError(wsConn, errors.WithMessage(err, "failed to set up the connection. Please check the host information")) {
return
}
} else {
client, err = loadLocalConn()
if wshandleError(wsConn, errors.WithMessage(err, "failed to set up the connection. Please check the host information")) {
return
}
}
defer client.Close()
command := c.DefaultQuery("command", "")
+46 -2
View File
@@ -506,7 +506,7 @@ func (b *BaseApi) GetProxyConfig(c *gin.Context) {
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /websites/proxies/update [post]
// @x-panel-log {"bodyKeys":["id"],"paramKeys":[],"BeforeFunctions":[{"input_column":"id","input_value":"id","isList":false,"db":"websites","output_column":"primary_domain","output_value":"domain"}],"formatZH":"修改网站 [domain] 反向代理配置 ","formatEN":"Update domain [domain] proxy config"}
// @x-panel-log {"bodyKeys":["id","name","operate"],"paramKeys":[],"BeforeFunctions":[{"input_column":"id","input_value":"id","isList":false,"db":"websites","output_column":"primary_domain","output_value":"domain"}],"formatZH":"修改 [operate] 网站 [domain] 反向代理配置 [name] ","formatEN":"Update [operate] domain [domain] proxy config [name]"}
func (b *BaseApi) UpdateProxyConfig(c *gin.Context) {
var req request.WebsiteProxyConfig
if err := helper.CheckBindAndValidate(&req, c); err != nil {
@@ -520,6 +520,50 @@ func (b *BaseApi) UpdateProxyConfig(c *gin.Context) {
helper.Success(c)
}
// @Tags Website
// @Summary Delete proxy config
// @Accept json
// @Param request body request.WebsiteProxyDel true "request"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /websites/proxies/delete [post]
// @x-panel-log {"bodyKeys":["id","name"],"paramKeys":[],"BeforeFunctions":[{"input_column":"id","input_value":"id","isList":false,"db":"websites","output_column":"primary_domain","output_value":"domain"}],"formatZH":"删除网站 [domain] 反向代理配置 [name] ","formatEN":"Delete domain [domain] proxy config [name]"}
func (b *BaseApi) DeleteProxyConfig(c *gin.Context) {
var req request.WebsiteProxyDel
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
err := websiteService.DeleteProxy(req)
if err != nil {
helper.InternalServer(c, err)
return
}
helper.Success(c)
}
// @Tags Website
// @Summary Update proxy config status
// @Accept json
// @Param request body request.WebsiteProxyStatusUpdate true "request"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /websites/proxies/status [post]
// @x-panel-log {"bodyKeys":["id","name","status"],"paramKeys":[],"BeforeFunctions":[{"input_column":"id","input_value":"id","isList":false,"db":"websites","output_column":"primary_domain","output_value":"domain"}],"formatZH":"修改网站 [domain] 反向代理配置 [name] 状态 [status] ","formatEN":"Update domain [domain] proxy config [name] status [status]"}
func (b *BaseApi) UpdateProxyConfigStatus(c *gin.Context) {
var req request.WebsiteProxyStatusUpdate
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
err := websiteService.UpdateProxyStatus(req)
if err != nil {
helper.InternalServer(c, err)
return
}
helper.Success(c)
}
// @Tags Website
// @Summary Update proxy file
// @Accept json
@@ -528,7 +572,7 @@ func (b *BaseApi) UpdateProxyConfig(c *gin.Context) {
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /websites/proxies/file [post]
// @x-panel-log {"bodyKeys":["websiteID"],"paramKeys":[],"BeforeFunctions":[{"input_column":"id","input_value":"websiteID","isList":false,"db":"websites","output_column":"primary_domain","output_value":"domain"}],"formatZH":"更新反向代理文件 [domain]","formatEN":"Nginx conf proxy file update [domain]"}
// @x-panel-log {"bodyKeys":["websiteID","name"],"paramKeys":[],"BeforeFunctions":[{"input_column":"id","input_value":"websiteID","isList":false,"db":"websites","output_column":"primary_domain","output_value":"domain"}],"formatZH":"修改网站 [domain] 反向代理配置文件 [name] ","formatEN":"Update domain [domain] proxy config file [name]"}
func (b *BaseApi) UpdateProxyConfigFile(c *gin.Context) {
var req request.NginxProxyUpdate
if err := helper.CheckBindAndValidate(&req, c); err != nil {
+29 -18
View File
@@ -5,7 +5,6 @@ import (
"mime/multipart"
"net/http"
"net/url"
"reflect"
"strconv"
"github.com/1Panel-dev/1Panel/agent/app/model"
@@ -26,27 +25,39 @@ import (
// @Router /websites/ssl/search [post]
func (b *BaseApi) PageWebsiteSSL(c *gin.Context) {
var req request.WebsiteSSLSearch
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
total, accounts, err := websiteSSLService.Page(req)
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithDataGzipped(c, dto.PageResult{
Total: total,
Items: accounts,
})
}
// @Tags Website SSL
// @Summary List website ssl
// @Accept json
// @Param request body request.WebsiteSSLListReq true "request"
// @Success 200 {array} response.WebsiteSSLDTO
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /websites/ssl/list [post]
func (b *BaseApi) ListWebsiteSSL(c *gin.Context) {
var req request.WebsiteSSLListReq
if err := helper.CheckBind(&req, c); err != nil {
return
}
if !reflect.DeepEqual(req.PageInfo, dto.PageInfo{}) {
total, accounts, err := websiteSSLService.Page(req)
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithDataGzipped(c, dto.PageResult{
Total: total,
Items: accounts,
})
} else {
list, err := websiteSSLService.Search(req)
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithDataGzipped(c, list)
list, err := websiteSSLService.Search(req)
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithDataGzipped(c, list)
}
// @Tags Website SSL
+592
View File
@@ -0,0 +1,592 @@
package dto
import "time"
type AgentCreateReq struct {
Name string `json:"name" validate:"required"`
Remark string `json:"remark"`
AppVersion string `json:"appVersion" validate:"required"`
WebUIPort int `json:"webUIPort" validate:"required"`
BridgePort int `json:"bridgePort"`
AllowedOrigins []string `json:"allowedOrigins"`
AgentType string `json:"agentType" validate:"required,oneof=openclaw copaw"`
Model string `json:"model"`
AccountID uint `json:"accountId"`
Token string `json:"token"`
TaskID string `json:"taskID"`
Advanced bool `json:"advanced"`
ContainerName string `json:"containerName"`
AllowPort bool `json:"allowPort"`
SpecifyIP string `json:"specifyIP"`
RestartPolicy string `json:"restartPolicy"`
CpuQuota float64 `json:"cpuQuota"`
MemoryLimit float64 `json:"memoryLimit"`
MemoryUnit string `json:"memoryUnit"`
PullImage bool `json:"pullImage"`
EditCompose bool `json:"editCompose"`
DockerCompose string `json:"dockerCompose"`
}
type AgentItem struct {
ID uint `json:"id"`
Name string `json:"name"`
Remark string `json:"remark"`
AgentType string `json:"agentType"`
Provider string `json:"provider"`
ProviderName string `json:"providerName"`
Model string `json:"model"`
APIType string `json:"apiType"`
MaxTokens int `json:"maxTokens"`
ContextWindow int `json:"contextWindow"`
BaseURL string `json:"baseUrl"`
APIKey string `json:"apiKey"`
Token string `json:"token"`
Status string `json:"status"`
Message string `json:"message"`
AppInstallID uint `json:"appInstallId"`
WebsiteID uint `json:"websiteId"`
WebsitePrimaryDomain string `json:"websitePrimaryDomain"`
WebsiteProtocol string `json:"websiteProtocol"`
AccountID uint `json:"accountId"`
AppVersion string `json:"appVersion"`
Container string `json:"containerName"`
WebUIPort int `json:"webUIPort"`
BridgePort int `json:"bridgePort"`
Path string `json:"path"`
ConfigPath string `json:"configPath"`
Upgradable bool `json:"upgradable"`
CreatedAt time.Time `json:"createdAt"`
}
type AgentDeleteReq struct {
ID uint `json:"id" validate:"required"`
TaskID string `json:"taskID"`
ForceDelete bool `json:"forceDelete"`
}
type AgentTokenResetReq struct {
ID uint `json:"id" validate:"required"`
}
type AgentRemarkUpdateReq struct {
ID uint `json:"id" validate:"required"`
Remark string `json:"remark"`
}
type AgentWebsiteBindReq struct {
AgentID uint `json:"agentId" validate:"required"`
WebsiteID uint `json:"websiteId" validate:"required"`
}
type AgentModelConfigUpdateReq struct {
AgentID uint `json:"agentId" validate:"required"`
AccountID uint `json:"accountId" validate:"required"`
Model string `json:"model" validate:"required"`
Fallbacks []string `json:"fallbacks"`
}
type AgentModelConfig struct {
AccountID uint `json:"accountId"`
Model string `json:"model"`
Fallbacks []string `json:"fallbacks"`
}
type AgentOverviewReq struct {
AgentID uint `json:"agentId" validate:"required"`
}
type AgentIDReq struct {
AgentID uint `json:"agentId" validate:"required"`
}
type AgentOverview struct {
Snapshot AgentOverviewSnapshot `json:"snapshot"`
}
type AgentRoleBinding struct {
Channel string `json:"channel" validate:"required"`
AccountID string `json:"accountId"`
}
type AgentRoleCreateReq struct {
AgentID uint `json:"agentId" validate:"required"`
Name string `json:"name" validate:"required"`
Model string `json:"model"`
Bindings []AgentRoleBinding `json:"bindings"`
}
type AgentRoleCreateResp struct {
Output string `json:"output"`
}
type AgentRoleDeleteReq struct {
AgentID uint `json:"agentId" validate:"required"`
ID string `json:"id" validate:"required"`
}
type AgentRoleBindReq struct {
AgentID uint `json:"agentId" validate:"required"`
ID string `json:"id" validate:"required"`
Channel string `json:"channel" validate:"required"`
AccountID string `json:"accountId"`
}
type AgentConfiguredAgentsReq struct {
AgentID uint `json:"agentId" validate:"required"`
}
type AgentRoleChannelsReq struct {
AgentID uint `json:"agentId" validate:"required"`
}
type AgentRoleChannelItem struct {
Name string `json:"name"`
Bound bool `json:"bound"`
AccountIDs []string `json:"accountIds"`
}
type AgentRoleMarkdownFilesReq struct {
AgentID uint `json:"agentId" validate:"required"`
Workspace string `json:"workspace" validate:"required"`
}
type AgentConfiguredAgentItem struct {
ID string `json:"id"`
Name string `json:"name"`
Workspace string `json:"workspace"`
Model string `json:"model"`
AgentDir string `json:"agentDir"`
Bindings []AgentRoleBinding `json:"bindings"`
}
type AgentRoleMarkdownFileItem struct {
Name string `json:"name"`
Content string `json:"content"`
}
type AgentRoleMarkdownFileUpdateItem struct {
Name string `json:"name" validate:"required,oneof=AGENTS.md SOUL.md USER.md IDENTITY.md TOOLS.md HEARTBEAT.md BOOT.md BOOTSTRAP.md"`
Content string `json:"content"`
}
type AgentRoleMarkdownFilesUpdateReq struct {
AgentID uint `json:"agentId" validate:"required"`
Workspace string `json:"workspace" validate:"required"`
Restart bool `json:"restart"`
Files []AgentRoleMarkdownFileUpdateItem `json:"files" validate:"required"`
}
type AgentOverviewSnapshot struct {
ContainerStatus string `json:"containerStatus"`
AppVersion string `json:"appVersion"`
DefaultModel string `json:"defaultModel"`
ChannelCount int `json:"channelCount"`
SkillCount int `json:"skillCount"`
JobCount int `json:"jobCount"`
SessionCount int `json:"sessionCount"`
}
type AgentAccountModel struct {
RecordID uint `json:"recordId"`
ID string `json:"id"`
Name string `json:"name"`
ContextWindow int `json:"contextWindow"`
MaxTokens int `json:"maxTokens"`
Reasoning bool `json:"reasoning"`
Input []string `json:"input"`
}
type AgentAccountModelReq struct {
AccountID uint `json:"accountId" validate:"required"`
}
type AgentAccountModelCreateReq struct {
AccountID uint `json:"accountId" validate:"required"`
Model AgentAccountModel `json:"model" validate:"required"`
}
type AgentAccountModelUpdateReq struct {
AccountID uint `json:"accountId" validate:"required"`
Model AgentAccountModel `json:"model" validate:"required"`
}
type AgentAccountModelDeleteReq struct {
AccountID uint `json:"accountId" validate:"required"`
RecordID uint `json:"recordId" validate:"required"`
}
type AgentAccountCreateReq struct {
Provider string `json:"provider" validate:"required"`
Name string `json:"name" validate:"required"`
APIKey string `json:"apiKey" validate:"required"`
RememberAPIKey bool `json:"rememberApiKey"`
BaseURL string `json:"baseURL"`
Models []AgentAccountModel `json:"models"`
APIType string `json:"apiType" validate:"required"`
Remark string `json:"remark"`
}
type AgentAccountUpdateReq struct {
ID uint `json:"id" validate:"required"`
Name string `json:"name" validate:"required"`
APIKey string `json:"apiKey" validate:"required"`
RememberAPIKey bool `json:"rememberApiKey"`
BaseURL string `json:"baseURL"`
APIType string `json:"apiType" validate:"required"`
Remark string `json:"remark"`
SyncAgents bool `json:"syncAgents"`
}
type AgentAccountVerifyReq struct {
Provider string `json:"provider" validate:"required"`
APIKey string `json:"apiKey" validate:"required"`
BaseURL string `json:"baseURL"`
}
type AgentAccountDeleteReq struct {
ID uint `json:"id" validate:"required"`
}
type AgentAccountSearch struct {
PageInfo
Provider string `json:"provider"`
Name string `json:"name"`
}
type AgentAccountInfo struct {
ID uint `json:"id"`
Provider string `json:"provider"`
ProviderName string `json:"providerName"`
Name string `json:"name"`
APIKey string `json:"apiKey"`
RememberAPIKey bool `json:"rememberApiKey"`
BaseURL string `json:"baseUrl"`
Models []AgentAccountModel `json:"models"`
APIType string `json:"apiType"`
Verified bool `json:"verified"`
Remark string `json:"remark"`
CreatedAt time.Time `json:"createdAt"`
}
type ProviderModelInfo struct {
ID string `json:"id"`
Name string `json:"name"`
ContextWindow int `json:"contextWindow"`
MaxTokens int `json:"maxTokens"`
Reasoning bool `json:"reasoning"`
Input []string `json:"input"`
}
type ProviderInfo struct {
Sort uint `json:"-"`
Provider string `json:"provider"`
DisplayName string `json:"displayName"`
BaseURL string `json:"baseUrl"`
Models []ProviderModelInfo `json:"models"`
}
type AgentFeishuConfigReq struct {
AgentID uint `json:"agentId" validate:"required"`
}
type AgentFeishuConfigUpdateReq struct {
AgentID uint `json:"agentId" validate:"required"`
Enabled bool `json:"enabled"`
ThreadSession bool `json:"threadSession"`
ReplyMode string `json:"replyMode" validate:"required"`
Streaming bool `json:"streaming"`
RequireMention string `json:"requireMention" validate:"required,oneof=true false open"`
GroupPolicy string `json:"groupPolicy" validate:"required,oneof=open allowlist disabled"`
GroupAllowFrom []string `json:"groupAllowFrom"`
Bots []AgentFeishuBot `json:"bots" validate:"required,min=1"`
}
type AgentFeishuPairingApproveReq struct {
AgentID uint `json:"agentId" validate:"required"`
PairingCode string `json:"pairingCode" validate:"required"`
}
type AgentFeishuConfig struct {
Enabled bool `json:"enabled"`
ThreadSession bool `json:"threadSession"`
ReplyMode string `json:"replyMode"`
Streaming bool `json:"streaming"`
RequireMention string `json:"requireMention"`
GroupPolicy string `json:"groupPolicy"`
GroupAllowFrom []string `json:"groupAllowFrom"`
Bots []AgentFeishuBot `json:"bots"`
Installed bool `json:"installed"`
}
type AgentTelegramConfigReq struct {
AgentID uint `json:"agentId" validate:"required"`
}
type AgentTelegramConfigUpdateReq struct {
AgentID uint `json:"agentId" validate:"required"`
Enabled bool `json:"enabled"`
DmPolicy string `json:"dmPolicy" validate:"required,oneof=pairing open allowlist disabled"`
AllowFrom []string `json:"allowFrom"`
GroupPolicy string `json:"groupPolicy" validate:"required,oneof=open allowlist disabled"`
GroupAllowFrom []string `json:"groupAllowFrom"`
Proxy string `json:"proxy"`
Streaming string `json:"streaming" validate:"required,oneof=off partial block progress"`
DefaultAccount string `json:"defaultAccount" validate:"required"`
Bots []AgentTelegramBot `json:"bots" validate:"required,min=1"`
}
type AgentTelegramConfig struct {
Enabled bool `json:"enabled"`
DmPolicy string `json:"dmPolicy"`
AllowFrom []string `json:"allowFrom"`
GroupPolicy string `json:"groupPolicy"`
GroupAllowFrom []string `json:"groupAllowFrom"`
Proxy string `json:"proxy"`
Streaming string `json:"streaming"`
DefaultAccount string `json:"defaultAccount"`
Bots []AgentTelegramBot `json:"bots"`
}
type AgentChannelPairingApproveReq struct {
AgentID uint `json:"agentId" validate:"required"`
Type string `json:"type" validate:"required,oneof=feishu telegram discord wecom"`
PairingCode string `json:"pairingCode" validate:"required"`
AccountID string `json:"accountId"`
}
type AgentWecomConfigUpdateReq struct {
AgentID uint `json:"agentId" validate:"required"`
Enabled bool `json:"enabled"`
DmPolicy string `json:"dmPolicy" validate:"required,oneof=pairing open allowlist disabled"`
AllowFrom []string `json:"allowFrom"`
GroupPolicy string `json:"groupPolicy" validate:"required,oneof=open allowlist disabled"`
GroupAllowFrom []string `json:"groupAllowFrom"`
BotID string `json:"botId" validate:"required"`
Secret string `json:"secret" validate:"required"`
}
type AgentWecomConfig struct {
Enabled bool `json:"enabled"`
DmPolicy string `json:"dmPolicy"`
AllowFrom []string `json:"allowFrom"`
GroupPolicy string `json:"groupPolicy"`
GroupAllowFrom []string `json:"groupAllowFrom"`
BotID string `json:"botId"`
Secret string `json:"secret"`
Installed bool `json:"installed"`
}
type AgentDingTalkConfigUpdateReq struct {
AgentID uint `json:"agentId" validate:"required"`
Enabled bool `json:"enabled"`
DmPolicy string `json:"dmPolicy" validate:"required,oneof=allowlist open disabled"`
AllowFrom []string `json:"allowFrom"`
GroupPolicy string `json:"groupPolicy" validate:"required,oneof=open allowlist disabled"`
GroupAllowFrom []string `json:"groupAllowFrom"`
SeparateSessionByConversation bool `json:"separateSessionByConversation"`
GroupSessionScope string `json:"groupSessionScope" validate:"required,oneof=group group_sender"`
SharedMemoryAcrossConversations bool `json:"sharedMemoryAcrossConversations"`
AsyncMode bool `json:"asyncMode"`
AckText string `json:"ackText"`
Bots []AgentDingTalkBot `json:"bots" validate:"required,min=1"`
}
type AgentDingTalkConfig struct {
Enabled bool `json:"enabled"`
DmPolicy string `json:"dmPolicy"`
AllowFrom []string `json:"allowFrom"`
GroupPolicy string `json:"groupPolicy"`
GroupAllowFrom []string `json:"groupAllowFrom"`
SeparateSessionByConversation bool `json:"separateSessionByConversation"`
GroupSessionScope string `json:"groupSessionScope"`
SharedMemoryAcrossConversations bool `json:"sharedMemoryAcrossConversations"`
AsyncMode bool `json:"asyncMode"`
AckText string `json:"ackText"`
Bots []AgentDingTalkBot `json:"bots"`
Installed bool `json:"installed"`
}
type AgentWeixinLoginReq struct {
AgentID uint `json:"agentId" validate:"required"`
TaskID string `json:"taskID" validate:"required"`
}
type AgentQQBotConfigUpdateReq struct {
AgentID uint `json:"agentId" validate:"required"`
Enabled bool `json:"enabled"`
Bots []AgentQQBotBot `json:"bots" validate:"required,min=1"`
}
type AgentQQBotConfig struct {
Enabled bool `json:"enabled"`
Bots []AgentQQBotBot `json:"bots"`
Installed bool `json:"installed"`
}
type AgentPluginInstallReq struct {
AgentID uint `json:"agentId" validate:"required"`
Type string `json:"type" validate:"required,oneof=feishu qqbot wecom dingtalk weixin"`
TaskID string `json:"taskID" validate:"required"`
}
type AgentPluginUpgradeReq struct {
AgentID uint `json:"agentId" validate:"required"`
Type string `json:"type" validate:"required,oneof=feishu qqbot wecom dingtalk weixin"`
TaskID string `json:"taskID" validate:"required"`
}
type AgentPluginUninstallReq struct {
AgentID uint `json:"agentId" validate:"required"`
Type string `json:"type" validate:"required,oneof=feishu qqbot wecom dingtalk weixin"`
TaskID string `json:"taskID" validate:"required"`
}
type AgentPluginCheckReq struct {
AgentID uint `json:"agentId" validate:"required"`
Type string `json:"type" validate:"required,oneof=feishu qqbot wecom dingtalk weixin"`
CheckLatest bool `json:"checkLatest"`
}
type AgentPluginStatus struct {
Installed bool `json:"installed"`
CurrentVersion string `json:"currentVersion"`
LatestVersion string `json:"latestVersion"`
Upgradable bool `json:"upgradable"`
}
type AgentDiscordConfigUpdateReq struct {
AgentID uint `json:"agentId" validate:"required"`
Enabled bool `json:"enabled"`
DmPolicy string `json:"dmPolicy" validate:"required"`
GroupPolicy string `json:"groupPolicy" validate:"required,oneof=open allowlist disabled"`
Proxy string `json:"proxy"`
DefaultAccount string `json:"defaultAccount" validate:"required"`
Bots []AgentDiscordBot `json:"bots" validate:"required,min=1"`
}
type AgentDiscordConfig struct {
Enabled bool `json:"enabled"`
DmPolicy string `json:"dmPolicy"`
GroupPolicy string `json:"groupPolicy"`
Proxy string `json:"proxy"`
DefaultAccount string `json:"defaultAccount"`
Bots []AgentDiscordBot `json:"bots"`
}
type AgentChannelBotBase struct {
AccountID string `json:"accountId"`
Name string `json:"name"`
Enabled bool `json:"enabled"`
IsDefault bool `json:"isDefault"`
}
func (b AgentChannelBotBase) IsEnabled() bool {
return b.Enabled
}
type AgentFeishuBot struct {
AgentChannelBotBase
AppID string `json:"appId"`
AppSecret string `json:"appSecret"`
DmPolicy string `json:"dmPolicy"`
AllowFrom []string `json:"allowFrom"`
}
type AgentTelegramBot struct {
AgentChannelBotBase
BotToken string `json:"botToken"`
DmPolicy string `json:"dmPolicy"`
GroupPolicy string `json:"groupPolicy"`
Streaming string `json:"streaming"`
}
type AgentDiscordBot struct {
AgentChannelBotBase
Token string `json:"token"`
}
type AgentQQBotBot struct {
AgentChannelBotBase
AppID string `json:"appId"`
ClientSecret string `json:"clientSecret"`
AllowFrom []string `json:"allowFrom"`
SystemPrompt string `json:"systemPrompt"`
}
type AgentDingTalkBot struct {
AgentChannelBotBase
ClientID string `json:"clientId"`
ClientSecret string `json:"clientSecret"`
}
type AgentSecurityConfigUpdateReq struct {
AgentID uint `json:"agentId" validate:"required"`
AllowedOrigins []string `json:"allowedOrigins"`
}
type AgentSecurityConfig struct {
AllowedOrigins []string `json:"allowedOrigins"`
}
type AgentOtherConfigUpdateReq struct {
AgentID uint `json:"agentId" validate:"required"`
UserTimezone string `json:"userTimezone" validate:"required"`
BrowserEnabled bool `json:"browserEnabled"`
NPMRegistry string `json:"npmRegistry" validate:"required"`
}
type AgentOtherConfig struct {
UserTimezone string `json:"userTimezone"`
BrowserEnabled bool `json:"browserEnabled"`
NPMRegistry string `json:"npmRegistry"`
}
type AgentConfigFileReq struct {
AgentID uint `json:"agentId" validate:"required"`
}
type AgentConfigFileUpdateReq struct {
AgentID uint `json:"agentId" validate:"required"`
Content string `json:"content" validate:"required"`
}
type AgentConfigFile struct {
Content string `json:"content"`
}
type AgentSkillSearchReq struct {
AgentID uint `json:"agentId" validate:"required"`
Source string `json:"source" validate:"required,oneof=clawhub-global clawhub-cn skillhub"`
Keyword string `json:"keyword" validate:"required"`
}
type AgentSkillItem struct {
Name string `json:"name"`
Description string `json:"description"`
Source string `json:"source"`
Bundled bool `json:"bundled"`
Disabled bool `json:"disabled"`
}
type AgentSkillSearchItem struct {
Slug string `json:"slug"`
Name string `json:"name"`
Description string `json:"description"`
Summary string `json:"summary"`
Version string `json:"version"`
Source string `json:"source"`
Score string `json:"score"`
}
type AgentSkillUpdateReq struct {
AgentID uint `json:"agentId" validate:"required"`
Name string `json:"name" validate:"required"`
Enabled bool `json:"enabled"`
}
type AgentSkillInstallReq struct {
AgentID uint `json:"agentId" validate:"required"`
Source string `json:"source" validate:"required,oneof=clawhub-global clawhub-cn skillhub"`
Slug string `json:"slug" validate:"required"`
TaskID string `json:"taskID" validate:"required"`
}
+10
View File
@@ -330,3 +330,13 @@ type AlertEmailConfig struct {
Encryption string `json:"encryption"` // "ssl" / "tls" / "none"
Recipient string `json:"recipient"`
}
type AgentInfo struct {
NodeName string `json:"nodeName"`
NodeAddr string `json:"nodeAddr"`
}
type AlertWebhookConfig struct {
DisplayName string `json:"displayName"`
Url string `json:"url"`
}
+20 -19
View File
@@ -84,25 +84,26 @@ type ExtraProperties struct {
}
type AppProperty struct {
Name string `json:"name"`
Type string `json:"type"`
Tags []string `json:"tags"`
ShortDescZh string `json:"shortDescZh" yaml:"shortDescZh"`
ShortDescEn string `json:"shortDescEn" yaml:"shortDescEn"`
Description Locale `json:"description"`
Key string `json:"key"`
Required []string `json:"Required"`
CrossVersionUpdate bool `json:"crossVersionUpdate" yaml:"crossVersionUpdate"`
Limit int `json:"limit" yaml:"limit"`
Recommend int `json:"recommend" yaml:"recommend"`
Website string `json:"website"`
Github string `json:"github"`
Document string `json:"document"`
Architectures []string `json:"architectures"`
MemoryRequired int `json:"memoryRequired" yaml:"memoryRequired"`
GpuSupport bool `json:"gpuSupport" yaml:"gpuSupport"`
Version float64 `json:"version"`
Deprecated float64 `json:"deprecated"`
Name string `json:"name"`
Type string `json:"type"`
Tags []string `json:"tags"`
ShortDescZh string `json:"shortDescZh" yaml:"shortDescZh"`
ShortDescEn string `json:"shortDescEn" yaml:"shortDescEn"`
Description Locale `json:"description"`
Key string `json:"key"`
Required []string `json:"Required"`
CrossVersionUpdate bool `json:"crossVersionUpdate" yaml:"crossVersionUpdate"`
Limit int `json:"limit" yaml:"limit"`
Recommend int `json:"recommend" yaml:"recommend"`
Website string `json:"website"`
Github string `json:"github"`
Document string `json:"document"`
Architectures []string `json:"architectures"`
MemoryRequired int `json:"memoryRequired" yaml:"memoryRequired"`
GpuSupport bool `json:"gpuSupport" yaml:"gpuSupport"`
Version float64 `json:"version"`
Deprecated float64 `json:"deprecated"`
BatchInstallSupport bool `json:"batchInstallSupport"`
}
type AppConfigVersion struct {
+9 -7
View File
@@ -65,18 +65,20 @@ type UploadForRecover struct {
}
type CommonBackup struct {
Type string `json:"type" validate:"required,oneof=app mysql mariadb redis website postgresql mysql-cluster postgresql-cluster redis-cluster"`
Name string `json:"name"`
DetailName string `json:"detailName"`
Secret string `json:"secret"`
TaskID string `json:"taskID"`
FileName string `json:"fileName"`
Type string `json:"type" validate:"required,oneof=app mysql mariadb redis website postgresql mysql-cluster postgresql-cluster redis-cluster container compose"`
Name string `json:"name"`
DetailName string `json:"detailName"`
Secret string `json:"secret"`
StopBefore bool `json:"stopBefore"`
TaskID string `json:"taskID"`
FileName string `json:"fileName"`
Args []string `json:"args"`
Description string `json:"description"`
}
type CommonRecover struct {
DownloadAccountID uint `json:"downloadAccountID" validate:"required"`
Type string `json:"type" validate:"required,oneof=app mysql mariadb redis website postgresql mysql-cluster postgresql-cluster redis-cluster"`
Type string `json:"type" validate:"required,oneof=app mysql mariadb redis website postgresql mysql-cluster postgresql-cluster redis-cluster container compose"`
Name string `json:"name"`
DetailName string `json:"detailName"`
File string `json:"file"`
+6
View File
@@ -11,6 +11,12 @@ type SearchPageWithType struct {
Type string `json:"type"`
}
type SearchPageWithGroup struct {
PageInfo
GroupID uint `json:"groupID"`
Info string `json:"info"`
}
type PageInfo struct {
Page int `json:"page" validate:"required,number"`
PageSize int `json:"pageSize" validate:"required,number"`
+57 -17
View File
@@ -48,6 +48,34 @@ type ContainerOptions struct {
State string `json:"state"`
}
type ContainerFileReq struct {
ContainerID string `json:"containerID" validate:"required"`
Path string `json:"path" validate:"required"`
}
type ContainerFileBatchDeleteReq struct {
ContainerID string `json:"containerID" validate:"required"`
Paths []string `json:"paths" validate:"required,min=1,dive,required"`
}
type ContainerFileInfo struct {
Name string `json:"name"`
Path string `json:"path"`
IsDir bool `json:"isDir"`
IsLink bool `json:"isLink"`
LinkTo string `json:"linkTo"`
Size int64 `json:"size"`
Mode string `json:"mode"`
ModTime string `json:"modTime"`
}
type ContainerFileContent struct {
Content string `json:"content"`
Size int64 `json:"size"`
Truncated bool `json:"truncated"`
IsBinary bool `json:"isBinary"`
}
type ContainerStatus struct {
Created int `json:"created"`
Running int `json:"running"`
@@ -95,10 +123,16 @@ type ContainerOperate struct {
Privileged bool `json:"privileged"`
AutoRemove bool `json:"autoRemove"`
Volumes []VolumeHelper `json:"volumes"`
ExtraHosts []ExtraHost `json:"extraHosts"`
Labels []string `json:"labels"`
Env []string `json:"env"`
RestartPolicy string `json:"restartPolicy"`
}
type ExtraHost struct {
Hostname string `json:"hostname"`
IP string `json:"ip"`
}
type ContainerNetwork struct {
Network string `json:"network"`
Ipv4 string `json:"ipv4"`
@@ -253,16 +287,17 @@ type BatchDelete struct {
}
type ComposeInfo struct {
Name string `json:"name"`
CreatedAt string `json:"createdAt"`
CreatedBy string `json:"createdBy"`
ContainerCount int `json:"containerCount"`
RunningCount int `json:"runningCount"`
ConfigFile string `json:"configFile"`
Workdir string `json:"workdir"`
Path string `json:"path"`
Containers []ComposeContainer `json:"containers"`
Env string `json:"env"`
Name string `json:"name"`
CreatedAt string `json:"createdAt"`
CreatedBy string `json:"createdBy"`
ContainerCount int `json:"containerCount"`
RunningCount int `json:"runningCount"`
ConfigFile string `json:"configFile"`
Workdir string `json:"workdir"`
ComposeFileExists bool `json:"composeFileExists"`
Path string `json:"path"`
Containers []ComposeContainer `json:"containers"`
Env string `json:"env"`
}
type ComposeContainer struct {
ContainerID string `json:"containerID"`
@@ -272,13 +307,14 @@ type ComposeContainer struct {
Ports []string `json:"ports"`
}
type ComposeCreate struct {
TaskID string `json:"taskID"`
Name string `json:"name"`
From string `json:"from" validate:"required,oneof=edit path template"`
File string `json:"file"`
Path string `json:"path"`
Template uint `json:"template"`
Env string `json:"env"`
TaskID string `json:"taskID"`
Name string `json:"name"`
From string `json:"from" validate:"required,oneof=edit path template"`
File string `json:"file"`
Path string `json:"path"`
Template uint `json:"template"`
Env string `json:"env"`
ForcePull bool `json:"forcePull"`
}
type ComposeOperation struct {
Name string `json:"name" validate:"required"`
@@ -288,11 +324,13 @@ type ComposeOperation struct {
Force bool `json:"force"`
}
type ComposeUpdate struct {
TaskID string `json:"taskID"`
Name string `json:"name" validate:"required"`
Path string `json:"path" validate:"required"`
DetailPath string `json:"detailPath"`
Content string `json:"content" validate:"required"`
Env string `json:"env"`
ForcePull bool `json:"forcePull"`
}
type ComposeLogClean struct {
Name string `json:"name" validate:"required"`
@@ -304,6 +342,7 @@ type ContainerLog struct {
Container string `json:"container" validate:"required"`
Since string `json:"since"`
Tail uint `json:"tail"`
Timestamp bool `json:"timestamp"`
ContainerType string `json:"containerType"`
}
@@ -313,5 +352,6 @@ type StreamLog struct {
Since string
Follow bool
Tail string
Timestamp bool
Type string
}
+3
View File
@@ -49,6 +49,7 @@ type CronjobOperate struct {
Timeout uint `json:"timeout" validate:"number,min=1"`
IgnoreErr bool `json:"ignoreErr"`
Secret string `json:"secret"`
Args string `json:"args"`
AlertCount uint `json:"alertCount"`
AlertTitle string `json:"alertTitle"`
@@ -123,6 +124,7 @@ type CronjobInfo struct {
LastRecordTime string `json:"lastRecordTime"`
Status string `json:"status"`
Secret string `json:"secret"`
Args string `json:"args"`
AlertCount uint `json:"alertCount"`
}
@@ -162,6 +164,7 @@ type CronjobTrans struct {
IgnoreErr bool `json:"ignoreErr"`
SnapshotRule SnapshotTransHelper `json:"snapshotRule"`
Secret string `json:"secret"`
Args string `json:"args"`
SourceAccounts []string `json:"sourceAccounts"`
DownloadAccount string `json:"downloadAccount"`
+1
View File
@@ -4,6 +4,7 @@ import "time"
type DashboardBase struct {
WebsiteNumber int `json:"websiteNumber"`
AgentNumber int `json:"agentNumber"`
DatabaseNumber int `json:"databaseNumber"`
CronjobNumber int `json:"cronjobNumber"`
AppInstalledNumber int `json:"appInstalledNumber"`
+1 -1
View File
@@ -19,7 +19,7 @@ type RuleSearch struct {
}
type FirewallOperation struct {
Operation string `json:"operation" validate:"required,oneof=start stop restart disablePing enablePing"`
Operation string `json:"operation" validate:"required,oneof=start stop restart disableBanPing enableBanPing"`
WithDockerRestart bool `json:"withDockerRestart"`
}
@@ -1,8 +1,6 @@
package dto
import (
"time"
)
import "time"
type HostOperate struct {
ID uint `json:"id"`
@@ -34,11 +32,6 @@ type SearchForTree struct {
Info string `json:"info"`
}
type ChangeHostGroup struct {
ID uint `json:"id" validate:"required"`
GroupID uint `json:"groupID" validate:"required"`
}
type HostInfo struct {
ID uint `json:"id"`
CreatedAt time.Time `json:"createdAt"`
+2 -2
View File
@@ -21,8 +21,8 @@ type ImageInfo struct {
}
type ImageLoad struct {
TaskID string `json:"taskID"`
Path string `json:"path" validate:"required"`
TaskID string `json:"taskID"`
Paths []string `json:"paths" validate:"required,dive,required"`
}
type ImageBuild struct {
+26 -7
View File
@@ -22,7 +22,16 @@ type AppInstallCreate struct {
Name string `json:"name" validate:"required"`
Services map[string]string `json:"services"`
TaskID string `json:"taskID"`
AppContainerConfig
NodePushConfig
}
type NodePushConfig struct {
Nodes []string `json:"nodes"`
PushNode bool `json:"pushNode"`
AppKey string `json:"appKey"`
Version string `json:"version"`
}
type AppContainerConfig struct {
@@ -45,13 +54,14 @@ type AppContainerConfig struct {
type AppInstalledSearch struct {
dto.PageInfo
Type string `json:"type"`
Name string `json:"name"`
Tags []string `json:"tags"`
Update bool `json:"update"`
Unused bool `json:"unused"`
All bool `json:"all"`
Sync bool `json:"sync"`
Type string `json:"type"`
Name string `json:"name"`
Tags []string `json:"tags"`
Update bool `json:"update"`
Unused bool `json:"unused"`
All bool `json:"all"`
Sync bool `json:"sync"`
CheckUpdate bool `json:"checkUpdate"`
}
type AppInstalledInfo struct {
@@ -128,3 +138,12 @@ type AppUpdateVersion struct {
AppInstallID uint `json:"appInstallID" validate:"required"`
UpdateVersion string `json:"updateVersion"`
}
type AppInstallSortItem struct {
InstallID uint `json:"installID"`
SortOrder int `json:"sortOrder"`
}
type AppInstallSort struct {
Items []AppInstallSortItem `json:"items" validate:"required"`
}
+1
View File
@@ -13,6 +13,7 @@ type DiskMountRequest struct {
MountPoint string `json:"mountPoint" validate:"required"`
Filesystem string `json:"filesystem" validate:"required,oneof=ext4 xfs"`
AutoMount bool `json:"autoMount"`
NoFail bool `json:"noFail"`
}
type DiskUnmountRequest struct {
+38
View File
@@ -9,6 +9,29 @@ type FileOption struct {
files.FileOption
}
type FileAISearch struct {
Path string `json:"path" validate:"required"`
Query string `json:"query" validate:"required"`
ResponseLanguage string `json:"responseLanguage,omitempty"`
ContainSub *bool `json:"containSub,omitempty"`
MaxItems int `json:"maxItems" validate:"omitempty,min=1,max=2000"`
MatchCase bool `json:"matchCase"`
WholeWord bool `json:"wholeWord"`
UseRegex bool `json:"useRegex"`
Extensions []string `json:"extensions,omitempty"`
MinSize int64 `json:"minSize"`
MaxSize int64 `json:"maxSize"`
ModifiedAfter string `json:"modifiedAfter,omitempty"`
ModifiedBefore string `json:"modifiedBefore,omitempty"`
MaxScanFiles int `json:"maxScanFiles"`
MaxFileBytes int64 `json:"maxFileBytes"`
MaxHitsPerFile int `json:"maxHitsPerFile"`
MaxTotalHits int `json:"maxTotalHits"`
ContentHitsPromptMaxBytes int `json:"contentHitsPromptMaxBytes"`
LlmMaxOutputTokens int `json:"llmMaxOutputTokens"`
}
type FileContentReq struct {
Path string `json:"path" validate:"required"`
IsDetail bool `json:"isDetail"`
@@ -163,3 +186,18 @@ type FileConvertRequest struct {
DeleteSource bool `json:"deleteSource"`
TaskID string `json:"taskID"`
}
type FileRemarkBatch struct {
Paths []string `json:"paths" validate:"required"`
}
type FileRemarkUpdate struct {
Path string `json:"path" validate:"required"`
Remark string `json:"remark"`
}
type FileShareCreate struct {
Path string `json:"path" validate:"required"`
ExpireMinutes int `json:"expireMinutes" validate:"min=0,max=10080"`
Password *string `json:"password"`
}
+14
View File
@@ -45,6 +45,7 @@ type StreamConfig struct {
StreamPorts string `json:"streamPorts"`
Name string `json:"name"`
Algorithm string `json:"algorithm"`
UDP bool `json:"udp"`
Servers []dto.NginxUpstreamServer `json:"servers"`
}
@@ -268,9 +269,21 @@ type WebsiteProxyConfig struct {
Replaces map[string]string `json:"replaces"`
SNI bool `json:"sni"`
ProxySSLName string `json:"proxySSLName"`
SSLVerify bool `json:"sslVerify"`
CorsConfig
}
type WebsiteProxyDelete struct {
ID uint `json:"id" validate:"required"`
Name string `json:"name" validate:"required"`
}
type WebsiteProxyStatusUpdate struct {
ID uint `json:"id" validate:"required"`
Name string `json:"name" validate:"required"`
Status string `json:"status" validate:"required"`
}
type CorsConfig struct {
Cors bool `json:"cors"`
AllowOrigins string `json:"allowOrigins"`
@@ -310,6 +323,7 @@ type WebsiteHtmlReq struct {
type WebsiteHtmlUpdate struct {
Type string `json:"type" validate:"required"`
Content string `json:"content" validate:"required"`
Sync bool `json:"sync"`
}
type WebsiteLBCreate struct {
+7 -3
View File
@@ -6,8 +6,12 @@ type WebsiteSSLSearch struct {
dto.PageInfo
AcmeAccountID string `json:"acmeAccountID"`
Domain string `json:"domain"`
OrderBy string `json:"orderBy" validate:"required,oneof=expire_date"`
Order string `json:"order" validate:"required,oneof=null ascending descending"`
OrderBy string `json:"orderBy" validate:"omitempty,oneof=created_at expire_date"`
Order string `json:"order" validate:"omitempty,oneof=null ascending descending"`
}
type WebsiteSSLListReq struct {
AcmeAccountID string `json:"acmeAccountID"`
}
type WebsiteSSLCreate struct {
@@ -47,7 +51,6 @@ type WebsiteSSLApply struct {
ID uint `json:"ID" validate:"required"`
SkipDNSCheck bool `json:"skipDNSCheck"`
Nameservers []string `json:"nameservers"`
DisableLog bool `json:"disableLog"`
}
type WebsiteSSLObtain struct {
@@ -63,6 +66,7 @@ type WebsiteAcmeAccountCreate struct {
EabHmacKey string `json:"eabHmacKey"`
UseProxy bool `json:"useProxy"`
CaDirURL string `json:"caDirURL"`
UseEAB bool `json:"useEAB"`
}
type WebsiteAcmeAccountUpdate struct {
+17 -11
View File
@@ -29,17 +29,18 @@ type AppDTO struct {
}
type AppItem struct {
Name string `json:"name"`
Key string `json:"key"`
ID uint `json:"id"`
Description string `json:"description"`
Status string `json:"status"`
Installed bool `json:"installed"`
Limit int `json:"limit"`
Tags []string `json:"tags"`
GpuSupport bool `json:"gpuSupport"`
Recommend int `json:"recommend"`
Type string `json:"type"`
Name string `json:"name"`
Key string `json:"key"`
ID uint `json:"id"`
Description string `json:"description"`
Status string `json:"status"`
Installed bool `json:"installed"`
Limit int `json:"limit"`
Tags []string `json:"tags"`
GpuSupport bool `json:"gpuSupport"`
Recommend int `json:"recommend"`
Type string `json:"type"`
BatchInstallSupport bool `json:"batchInstallSupport"`
}
type TagDTO struct {
@@ -75,6 +76,10 @@ type AppDetailDTO struct {
GpuSupport bool `json:"gpuSupport"`
}
type AppDetailSimpleDTO struct {
ID uint `json:"id"`
}
type IgnoredApp struct {
Icon string `json:"icon"`
Name string `json:"name"`
@@ -121,6 +126,7 @@ type AppInstallDTO struct {
WebUI string `json:"webUI"`
CreatedAt time.Time `json:"createdAt"`
Favorite bool `json:"favorite"`
SortOrder int `json:"sortOrder"`
App AppDetail `json:"app"`
Container string `json:"container"`
IsEdit bool `json:"isEdit"`
+42
View File
@@ -81,3 +81,45 @@ type FileConvertLog struct {
Status string `json:"status"`
Message string `json:"message"`
}
type FileRemarksRes struct {
Remarks map[string]string `json:"remarks"`
}
type FileShareInfo struct {
Code string `json:"code"`
Path string `json:"path"`
FileName string `json:"fileName"`
ExpiresAt int64 `json:"expiresAt"`
Permanent bool `json:"permanent"`
HasPassword bool `json:"hasPassword"`
Password string `json:"password,omitempty"`
}
type FileSharePublicInfo struct {
FileName string `json:"fileName"`
ExpiresAt int64 `json:"expiresAt"`
Permanent bool `json:"permanent"`
HasPassword bool `json:"hasPassword"`
}
type FileAIContentHit struct {
Path string `json:"path"`
Line int `json:"line"`
Text string `json:"text"`
}
type FileAISearchResult struct {
Mode string `json:"mode"`
Summary string `json:"summary"`
Hits []FileAIContentHit `json:"hits"`
ContentScannedFiles int `json:"contentScannedFiles"`
ContentHitsTruncated bool `json:"contentHitsTruncated"`
Truncated bool `json:"truncated"`
PreFiltered bool `json:"preFiltered"`
ItemCount int `json:"itemCount"`
PromptTokens int `json:"promptTokens"`
CompletionTokens int `json:"completionTokens"`
TotalTokens int `json:"totalTokens"`
Duration string `json:"duration"`
}
+2
View File
@@ -18,6 +18,7 @@ type WebsiteDTO struct {
SiteDir string `json:"siteDir"`
OpenBaseDir bool `json:"openBaseDir"`
Algorithm string `json:"algorithm"`
UDP bool `json:"udp"`
Servers []dto.NginxUpstreamServer `json:"servers"`
}
@@ -39,6 +40,7 @@ type WebsiteRes struct {
SSLStatus string `json:"sslStatus"`
AppInstallID uint `json:"appInstallId"`
ChildSites []string `json:"childSites"`
ParentSite string `json:"parentSite"`
RuntimeType string `json:"runtimeType"`
Favorite bool `json:"favorite"`
IPV6 bool `json:"IPV6"`
+15
View File
@@ -37,6 +37,7 @@ type SyncTime struct {
type CleanData struct {
SystemClean []CleanTree `json:"systemClean"`
BackupClean []CleanTree `json:"backupClean"`
UploadClean []CleanTree `json:"uploadClean"`
DownloadClean []CleanTree `json:"downloadClean"`
SystemLogClean []CleanTree `json:"systemLogClean"`
@@ -54,6 +55,7 @@ type CleanTree struct {
Size uint64 `json:"size"`
IsCheck bool `json:"isCheck"`
IsRecommend bool `json:"isRecommend"`
CanDelete bool `json:"canDelete"`
}
type Clean struct {
@@ -86,6 +88,19 @@ type SystemProxy struct {
Password string `json:"password"`
}
type TerminalAIInfo struct {
AIStatus string `json:"aiStatus"`
AIAccountID string `json:"aiAccountId"`
AIPrefix string `json:"aiPrefix" validate:"required,oneof=@ai #ai /ai"`
AIRiskCommands string `json:"aiRiskCommands"`
AIRiskCommandsDefault string `json:"aiRiskCommandsDefault"`
}
type FileManageAIInfo struct {
AIStatus string `json:"aiStatus"`
AIAccountID string `json:"aiAccountId"`
}
type CommonDescription struct {
ID string `json:"id" validate:"required"`
Type string `json:"type" validate:"required"`
+6 -1
View File
@@ -4,7 +4,6 @@ import "time"
type SSHUpdate struct {
Key string `json:"key" validate:"required"`
OldValue string `json:"oldValue"`
NewValue string `json:"newValue"`
}
@@ -50,6 +49,12 @@ type GenerateLoad struct {
type SSHConf struct {
File string `json:"file"`
}
type SSHConfUpdate struct {
Key string `json:"key" validate:"required"`
Path string `json:"path"`
Value string `json:"value"`
}
type SearchSSHLog struct {
PageInfo
Info string `json:"info"`
+22
View File
@@ -0,0 +1,22 @@
package model
type Agent struct {
BaseModel
Name string `json:"name" gorm:"not null;unique"`
Remark string `json:"remark"`
AgentType string `json:"agentType" gorm:"default:openclaw"`
Provider string `json:"provider"`
Model string `json:"model"`
APIType string `json:"apiType"`
MaxTokens int `json:"maxTokens"`
ContextWindow int `json:"contextWindow"`
BaseURL string `json:"baseUrl"`
APIKey string `json:"apiKey"`
Token string `json:"token"`
Status string `json:"status"`
Message string `json:"message"`
AppInstallID uint `json:"appInstallId"`
WebsiteID uint `json:"websiteId"`
AccountID uint `json:"accountId"`
ConfigPath string `json:"configPath"`
}
+17
View File
@@ -0,0 +1,17 @@
package model
type AgentAccount struct {
BaseModel
Provider string `json:"provider"`
Name string `json:"name"`
APIKey string `json:"apiKey"`
BaseURL string `json:"baseUrl"`
APIType string `json:"apiType"`
RememberAPIKey bool `json:"rememberApiKey"`
Verified bool `json:"verified"`
Remark string `json:"remark"`
}
func (AgentAccount) TableName() string {
return "agent_provider_accounts"
}
+17
View File
@@ -0,0 +1,17 @@
package model
type AgentAccountModel struct {
BaseModel
AccountID uint `json:"accountId" gorm:"index"`
Model string `json:"model" gorm:"index"`
Name string `json:"name"`
ContextWindow int `json:"contextWindow"`
MaxTokens int `json:"maxTokens"`
Reasoning bool `json:"reasoning"`
Input string `json:"input" gorm:"type:text"`
SortOrder int `json:"sortOrder" gorm:"index"`
}
func (AgentAccountModel) TableName() string {
return "agent_account_models"
}
+1 -1
View File
@@ -11,7 +11,7 @@ type Alert struct {
Status string `gorm:"type:varchar(64);not null" json:"status"`
Method string `gorm:"type:varchar(64);not null" json:"method"`
SendCount uint `gorm:"type:integer" json:"sendCount"`
AdvancedParams string `gorm:"type:longText" json:"advancedParam"`
AdvancedParams string `gorm:"type:longText" json:"advancedParams"`
}
type AlertTask struct {
+1
View File
@@ -34,6 +34,7 @@ type App struct {
MemoryRequired int `json:"memoryRequired"`
GpuSupport bool `json:"gpuSupport"`
RequiredPanelVersion float64 `json:"requiredPanelVersion"`
BatchInstallSupport bool `json:"batchInstallSupport" yaml:"batchInstallSupport"`
Details []AppDetail `json:"-" gorm:"-:migration"`
TagsKey []string `json:"tags" yaml:"tags" gorm:"-"`
+1
View File
@@ -26,6 +26,7 @@ type AppInstall struct {
HttpsPort int `json:"httpsPort"`
WebUI string `json:"webUI"`
Favorite bool `json:"favorite"`
SortOrder int `json:"sortOrder" gorm:"default:0"`
App App `json:"app" gorm:"-:migration"`
}
+1
View File
@@ -37,6 +37,7 @@ type Cronjob struct {
Timeout uint `json:"timeout"`
IgnoreErr bool `json:"ignoreErr"`
RetainCopies uint64 `json:"retainCopies"`
Args string `json:"args"`
IsExecuting bool `json:"isExecuting"`
Status string `json:"status"`
+14
View File
@@ -0,0 +1,14 @@
package model
type FileShare struct {
BaseModel
Path string `gorm:"not null;uniqueIndex" json:"path"`
Token string `gorm:"not null;uniqueIndex" json:"token"`
FileName string `gorm:"not null" json:"fileName"`
ExpiresUnix int64 `json:"expiresUnix"`
PasswordEnc string `json:"-"`
PasswordSalt string `json:"passwordSalt"`
PasswordHash string `json:"passwordHash"`
MaxDownloads int `json:"maxDownloads"`
DownloadCount int `json:"downloadCount"`
}
+1
View File
@@ -11,6 +11,7 @@ type WebsiteAcmeAccount struct {
KeyType string `gorm:"not null;default:2048" json:"keyType"`
UseProxy bool `gorm:"default:false" json:"useProxy"`
CaDirURL string `json:"caDirURL"`
UseEAB bool `json:"useEAB"`
}
func (w WebsiteAcmeAccount) TableName() string {
+438
View File
@@ -0,0 +1,438 @@
package provider
import (
"strings"
)
type Model struct {
ID string
Name string
ContextWindow int
MaxTokens int
Reasoning bool
Input []string
}
type RuntimeDefault struct {
APIType string
ContextWindow int
MaxTokens int
Input []string
}
type Meta struct {
Key string
DisplayName string
Sort uint
DefaultBaseURL string
EnvKey string
Default RuntimeDefault
Models []Model
}
var catalog = map[string]Meta{
"custom": {
Key: "custom",
DisplayName: "Custom",
Sort: 10,
DefaultBaseURL: "",
EnvKey: "CUSTOM_API_KEY",
Default: RuntimeDefault{
APIType: "openai-completions",
ContextWindow: 128000,
MaxTokens: 8192,
Input: []string{"text"},
},
Models: []Model{},
},
"ollama": {
Key: "ollama",
DisplayName: "Ollama",
Sort: 15,
Default: RuntimeDefault{
APIType: "openai-responses",
ContextWindow: 160000,
MaxTokens: 8192,
Input: []string{"text"},
},
},
"vllm": {
Key: "vllm",
DisplayName: "vLLM",
Sort: 20,
DefaultBaseURL: "",
EnvKey: "VLLM_API_KEY",
Default: RuntimeDefault{
APIType: "openai-completions",
ContextWindow: 128000,
MaxTokens: 8192,
Input: []string{"text"},
},
Models: []Model{},
},
"deepseek": {
Key: "deepseek",
DisplayName: "DeepSeek",
Sort: 25,
DefaultBaseURL: "https://api.deepseek.com/v1",
EnvKey: "DEEPSEEK_API_KEY",
Default: RuntimeDefault{
APIType: "openai-completions",
ContextWindow: 131072,
MaxTokens: 8192,
Input: []string{"text"},
},
Models: []Model{
{ID: "deepseek/deepseek-chat", Name: "DeepSeek Chat"},
{ID: "deepseek/deepseek-reasoner", Name: "DeepSeek Reasoner", MaxTokens: 65536, ContextWindow: 131072, Reasoning: true},
},
},
"bailian-coding-plan": {
Key: "bailian-coding-plan",
DisplayName: "阿里云百炼 Coding Plan",
Sort: 30,
DefaultBaseURL: "https://coding.dashscope.aliyuncs.com/v1",
EnvKey: "QWEN_API_KEY",
Default: RuntimeDefault{
APIType: "openai-completions",
ContextWindow: 256000,
MaxTokens: 8192,
Input: []string{"text"},
},
Models: []Model{
{ID: "bailian-coding-plan/qwen3.5-plus", Name: "Qwen3.5-Plus", Reasoning: true},
{ID: "bailian-coding-plan/qwen3-max", Name: "Qwen3-Max", Reasoning: true},
{ID: "bailian-coding-plan/qwen3-coder-next", Name: "Qwen3-Coder-Next", Reasoning: true},
{ID: "bailian-coding-plan/qwen3-coder-plus", Name: "Qwen3-Coder-Plus", Reasoning: true},
{ID: "bailian-coding-plan/minimax-m2.5", Name: "MiniMax M2.5", Reasoning: true},
{ID: "bailian-coding-plan/glm-5", Name: "GLM-5", Reasoning: true},
{ID: "bailian-coding-plan/kimi-k2.5", Name: "Kimi-k2.5", Reasoning: true},
{ID: "bailian-coding-plan/glm-4.7", Name: "GLM-4.7", Reasoning: true},
},
},
"ark-coding-plan": {
Key: "ark-coding-plan",
DisplayName: "方舟 Coding Plan",
Sort: 35,
DefaultBaseURL: "https://ark.cn-beijing.volces.com/api/coding/v3",
EnvKey: "ARK_API_KEY",
Default: RuntimeDefault{
APIType: "openai-completions",
ContextWindow: 256000,
MaxTokens: 4096,
Input: []string{"text"},
},
Models: []Model{
{ID: "ark-coding-plan/ark-code-latest", Name: "Ark Coding Plan", ContextWindow: 256000, MaxTokens: 4096},
{ID: "ark-coding-plan/doubao-seed-code", Name: "Doubao Seed Code", ContextWindow: 256000, MaxTokens: 4096},
{ID: "ark-coding-plan/glm-4.7", Name: "GLM 4.7 Coding", ContextWindow: 200000, MaxTokens: 4096},
{ID: "ark-coding-plan/kimi-k2-thinking", Name: "Kimi K2 Thinking", ContextWindow: 256000, MaxTokens: 4096},
{ID: "ark-coding-plan/kimi-k2.5", Name: "Kimi K2.5 Coding", ContextWindow: 256000, MaxTokens: 4096},
{ID: "ark-coding-plan/doubao-seed-code-preview-251028", Name: "Doubao Seed Code Preview", ContextWindow: 256000, MaxTokens: 4096},
},
},
"zai": {
Key: "zai",
DisplayName: "Z.ai",
Sort: 40,
DefaultBaseURL: "https://open.bigmodel.cn/api/paas/v4",
EnvKey: "ZAI_API_KEY",
Default: RuntimeDefault{
APIType: "openai-completions",
ContextWindow: 204800,
MaxTokens: 131072,
Input: []string{"text"},
},
Models: []Model{
{ID: "zai/glm-5", Name: "GLM-5", Reasoning: true},
{ID: "zai/glm-4.7", Name: "GLM-4.7", Reasoning: true},
{ID: "zai/glm-4.7-flash", Name: "GLM-4.7-Flash", Reasoning: true},
{ID: "zai/glm-4.7-flashx", Name: "GLM-4.7-FlashX", Reasoning: true},
},
},
"minimax": {
Key: "minimax",
DisplayName: "MiniMax (CN)",
Sort: 45,
DefaultBaseURL: "https://api.minimaxi.com/anthropic",
EnvKey: "MINIMAX_API_KEY",
Default: RuntimeDefault{
APIType: "anthropic-messages",
ContextWindow: 200000,
MaxTokens: 8192,
Input: []string{"text"},
},
Models: []Model{
{ID: "minimax/MiniMax-M2.7", Name: "MiniMax M2.7"},
{ID: "minimax/MiniMax-M2.7-highspeed", Name: "MiniMax M2.7 highspeed"},
{ID: "minimax/MiniMax-M2.5", Name: "MiniMax M2.5", Reasoning: true},
{ID: "minimax/MiniMax-M2.5-highspeed", Name: "MiniMax M2.5 highspeed"},
},
},
"xiaomi": {
Key: "xiaomi",
DisplayName: "Xiaomi",
Sort: 46,
DefaultBaseURL: "https://api.xiaomimimo.com/v1",
EnvKey: "XIAOMI_API_KEY",
Default: RuntimeDefault{
APIType: "openai-completions",
ContextWindow: 262144,
MaxTokens: 8192,
Input: []string{"text"},
},
Models: []Model{
{ID: "xiaomi/mimo-v2-flash", Name: "Xiaomi MiMo V2 Flash", ContextWindow: 262144, MaxTokens: 8192, Input: []string{"text"}},
{ID: "xiaomi/mimo-v2-pro", Name: "Xiaomi MiMo V2 Pro", ContextWindow: 1048576, MaxTokens: 32000, Reasoning: true, Input: []string{"text"}},
{ID: "xiaomi/mimo-v2-omni", Name: "Xiaomi MiMo V2 Omni", ContextWindow: 262144, MaxTokens: 32000, Reasoning: true, Input: []string{"text", "image"}},
},
},
"kimi": {
Key: "kimi",
DisplayName: "Kimi (CN)",
Sort: 50,
DefaultBaseURL: "https://api.moonshot.cn/v1",
EnvKey: "KIMI_API_KEY",
Default: RuntimeDefault{
APIType: "openai-completions",
ContextWindow: 256000,
MaxTokens: 8192,
Input: []string{"text", "image"},
},
Models: []Model{
{ID: "kimi/kimi-k2.5", Name: "Kimi K2.5", Reasoning: true},
{ID: "kimi/kimi-k2-0905-preview", Name: "Kimi K2 0905 Preview"},
{ID: "kimi/kimi-k2-thinking", Name: "Kimi K2 Thinking", Reasoning: true},
},
},
"kimi-coding": {
Key: "kimi-coding",
DisplayName: "Kimi Coding",
Sort: 51,
DefaultBaseURL: "https://api.kimi.com/coding/",
EnvKey: "KIMI_API_KEY",
Default: RuntimeDefault{
APIType: "anthropic-messages",
ContextWindow: 262144,
MaxTokens: 32768,
Input: []string{"text", "image"},
},
Models: []Model{
{ID: "kimi-coding/kimi-code", Name: "Kimi Code", ContextWindow: 262144, MaxTokens: 32768, Reasoning: true, Input: []string{"text", "image"}},
{ID: "kimi-coding/k2p5", Name: "Kimi K2.5", ContextWindow: 262144, MaxTokens: 32768, Reasoning: true, Input: []string{"text", "image"}},
},
},
"openai": {
Key: "openai",
DisplayName: "OpenAI",
Sort: 55,
DefaultBaseURL: "https://api.openai.com/v1",
EnvKey: "OPENAI_API_KEY",
Default: RuntimeDefault{
APIType: "openai-responses",
ContextWindow: 272000,
MaxTokens: 128000,
Input: []string{"text", "image"},
},
Models: []Model{
{ID: "openai/gpt-5.4", Name: "gpt-5.4", ContextWindow: 272000, MaxTokens: 128000, Reasoning: true, Input: []string{"text", "image"}},
{ID: "openai/gpt-5.4-pro", Name: "gpt-5.4-pro", ContextWindow: 1050000, MaxTokens: 128000, Reasoning: true, Input: []string{"text", "image"}},
{ID: "openai/gpt-5.4-mini", Name: "gpt-5.4-mini", ContextWindow: 400000, MaxTokens: 128000, Reasoning: true, Input: []string{"text", "image"}},
{ID: "openai/gpt-5.4-nano", Name: "gpt-5.4-nano", ContextWindow: 400000, MaxTokens: 128000, Reasoning: true, Input: []string{"text", "image"}},
},
},
"openrouter": {
Key: "openrouter",
DisplayName: "OpenRouter",
Sort: 56,
DefaultBaseURL: "https://openrouter.ai/api/v1",
EnvKey: "OPENROUTER_API_KEY",
Default: RuntimeDefault{
APIType: "openai-completions",
ContextWindow: 128000,
MaxTokens: 8192,
Input: []string{"text"},
},
Models: []Model{
{ID: "openrouter/free", Name: "openrouter/free"},
{ID: "openrouter/auto", Name: "openrouter/auto"},
},
},
"anthropic": {
Key: "anthropic",
DisplayName: "Anthropic",
Sort: 60,
DefaultBaseURL: "https://api.anthropic.com",
EnvKey: "ANTHROPIC_API_KEY",
Default: RuntimeDefault{
APIType: "anthropic-messages",
ContextWindow: 256000,
MaxTokens: 8192,
Input: []string{"text", "image"},
},
Models: []Model{
{ID: "anthropic/claude-sonnet-4-6", Name: "Claude Sonnet 4.6", Reasoning: true},
{ID: "anthropic/claude-opus-4-6", Name: "Claude Opus 4.6", Reasoning: true},
{ID: "anthropic/claude-opus-4-5", Name: "Claude Opus 4.5"},
{ID: "anthropic/claude-sonnet-4-5", Name: "Claude Sonnet 4.5"},
{ID: "anthropic/claude-haiku-4-5", Name: "Claude Haiku 4.5"},
},
},
"gemini": {
Key: "gemini",
DisplayName: "Gemini",
Sort: 65,
DefaultBaseURL: "https://generativelanguage.googleapis.com",
EnvKey: "GEMINI_API_KEY",
Default: RuntimeDefault{
APIType: "openai-completions",
ContextWindow: 256000,
MaxTokens: 8192,
Input: []string{"text", "image"},
},
Models: []Model{
{ID: "google/gemini-3-flash-preview", Name: "Gemini 3 Flash Preview", Reasoning: true},
{ID: "google/gemini-flash-latest", Name: "Gemini Flash Latest"},
{ID: "google/gemini-3-pro-preview", Name: "Gemini 3 Pro Preview", Reasoning: true},
},
},
"moonshot": {
Key: "moonshot",
DisplayName: "Moonshot (Global)",
Sort: 70,
DefaultBaseURL: "https://api.moonshot.ai/v1",
EnvKey: "MOONSHOT_API_KEY",
Default: RuntimeDefault{
APIType: "openai-completions",
ContextWindow: 256000,
MaxTokens: 8192,
Input: []string{"text"},
},
Models: []Model{
{ID: "moonshot/kimi-k2.5", Name: "Kimi K2.5", Reasoning: true},
{ID: "moonshot/kimi-k2-0905-preview", Name: "Kimi K2 0905 Preview"},
{ID: "moonshot/kimi-k2-thinking", Name: "Kimi K2 Thinking", Reasoning: true},
},
},
}
func Get(key string) (Meta, bool) {
meta, ok := catalog[key]
if !ok {
return Meta{}, false
}
return cloneMeta(meta), true
}
func All() map[string]Meta {
result := make(map[string]Meta, len(catalog))
for key, meta := range catalog {
result[key] = cloneMeta(meta)
}
return result
}
func DefaultBaseURL(key string) (string, bool) {
meta, ok := catalog[key]
if !ok || strings.TrimSpace(meta.DefaultBaseURL) == "" {
return "", false
}
return meta.DefaultBaseURL, true
}
func EnvKey(key string) string {
meta, ok := catalog[key]
if !ok {
return ""
}
return meta.EnvKey
}
func DisplayName(key string) string {
meta, ok := catalog[key]
if !ok {
return key
}
if strings.TrimSpace(meta.DisplayName) == "" {
return key
}
return meta.DisplayName
}
func FindModel(key, modelID string) (Model, bool) {
meta, ok := Get(key)
if !ok {
return Model{}, false
}
for _, item := range meta.Models {
if item.ID == modelID {
return item, true
}
}
return Model{}, false
}
func cloneMeta(meta Meta) Meta {
clone := meta
if len(meta.Default.Input) > 0 {
clone.Default.Input = make([]string, len(meta.Default.Input))
copy(clone.Default.Input, meta.Default.Input)
}
if len(meta.Models) > 0 {
clone.Models = make([]Model, len(meta.Models))
for i, item := range meta.Models {
clone.Models[i] = normalizeModel(meta, item)
}
}
return clone
}
func normalizeModel(meta Meta, model Model) Model {
clone := model
clone.ID = strings.TrimSpace(clone.ID)
clone.Name = strings.TrimSpace(clone.Name)
if clone.Name == "" {
clone.Name = clone.ID
}
if clone.MaxTokens <= 0 {
clone.MaxTokens = meta.Default.MaxTokens
}
if clone.ContextWindow <= 0 {
clone.ContextWindow = meta.Default.ContextWindow
}
if len(clone.Input) == 0 && len(meta.Default.Input) > 0 {
clone.Input = make([]string, len(meta.Default.Input))
copy(clone.Input, meta.Default.Input)
}
return clone
}
func ResolveRuntimeParams(provider, apiType string, maxTokens, contextWindow int) (string, int, int) {
defaultAPIType := "openai-completions"
defaultMaxTokens := 8192
defaultContextWindow := 256000
if meta, ok := Get(provider); ok {
if meta.Default.APIType != "" {
defaultAPIType = meta.Default.APIType
}
if meta.Default.MaxTokens > 0 {
defaultMaxTokens = meta.Default.MaxTokens
}
if meta.Default.ContextWindow > 0 {
defaultContextWindow = meta.Default.ContextWindow
}
}
resolvedAPI := apiType
if strings.TrimSpace(apiType) == "" {
resolvedAPI = defaultAPIType
}
resolvedMaxTokens := defaultMaxTokens
resolvedContextWindow := defaultContextWindow
if maxTokens > 0 {
resolvedMaxTokens = maxTokens
}
if contextWindow > 0 {
resolvedContextWindow = contextWindow
}
return resolvedAPI, resolvedMaxTokens, resolvedContextWindow
}
+176
View File
@@ -0,0 +1,176 @@
package provider
import (
"fmt"
"strings"
)
type OpenClawPatch struct {
PrimaryModel string
Models map[string]interface{}
}
type OpenClawProviderPatch struct {
PrimaryModel string
ProviderKey string
ModelID string
APIKey string
BaseURL string
APIType string
AuthHeader bool
}
func BuildOpenClawPatch(provider, modelName, apiType string, reasoning bool, maxTokens, contextWindow int, baseURL, apiKey string) (*OpenClawPatch, error) {
providerPatch, err := BuildOpenClawProviderPatch(provider, modelName, apiType, baseURL, apiKey)
if err != nil {
return nil, err
}
_, maxTokens, contextWindow = ResolveRuntimeParams(provider, apiType, maxTokens, contextWindow)
return newOpenClawPatch(
providerPatch,
resolveOpenClawModelName(provider, providerPatch.ModelID),
resolveOpenClawModelInput(provider, providerPatch.ModelID),
reasoning,
contextWindow,
maxTokens,
), nil
}
func BuildOpenClawProviderPatch(provider, modelName, apiType, baseURL, apiKey string) (*OpenClawProviderPatch, error) {
modelName = strings.TrimSpace(modelName)
if modelName == "" {
return nil, fmt.Errorf("model is required")
}
resolvedAPIType, _, _ := ResolveRuntimeParams(provider, apiType, 0, 0)
modelID := resolveOpenClawModelID(provider, modelName)
switch provider {
case "deepseek":
return newOpenClawProviderPatch(modelName, "deepseek", modelID, strings.TrimSpace(apiKey), baseURL, "openai-completions", false), nil
case "gemini":
return newOpenClawProviderPatch("google/"+modelID, "google", modelID, strings.TrimSpace(apiKey), baseURL, resolvedAPIType, false), nil
case "moonshot", "kimi":
return buildMoonshotProviderPatch(provider, modelName, modelID, baseURL, apiKey), nil
case "bailian-coding-plan":
return newOpenClawProviderPatch("bailian-coding-plan/"+modelID, "bailian-coding-plan", modelID, strings.TrimSpace(apiKey), baseURL, "openai-completions", false), nil
case "ark-coding-plan":
return newOpenClawProviderPatch("ark-coding-plan/"+modelID, "ark-coding-plan", modelID, strings.TrimSpace(apiKey), baseURL, "openai-completions", false), nil
case "minimax":
return newOpenClawProviderPatch("minimax/"+modelID, "minimax", modelID, strings.TrimSpace(apiKey), baseURL, "anthropic-messages", true), nil
case "xiaomi":
return newOpenClawProviderPatch("xiaomi/"+modelID, "xiaomi", modelID, strings.TrimSpace(apiKey), baseURL, "openai-completions", false), nil
case "custom", "vllm":
return newOpenClawProviderPatch(provider+"/"+modelID, provider, modelID, strings.TrimSpace(apiKey), strings.TrimSpace(baseURL), resolvedAPIType, false), nil
case "ollama":
return newOpenClawProviderPatch(modelName, "ollama", modelID, "ollama", strings.TrimSpace(baseURL), resolvedAPIType, false), nil
case "kimi-coding":
return newOpenClawProviderPatch(modelName, "kimi-coding", modelID, strings.TrimSpace(apiKey), baseURL, "anthropic-messages", false), nil
case "zai":
return newOpenClawProviderPatch("zai/"+modelID, "zai", modelID, strings.TrimSpace(apiKey), baseURL, "openai-completions", false), nil
default:
return newOpenClawProviderPatch(modelName, provider, modelID, strings.TrimSpace(apiKey), baseURL, resolvedAPIType, false), nil
}
}
func buildMoonshotProviderPatch(provider, modelName, modelID, baseURL, apiKey string) *OpenClawProviderPatch {
providerKey := provider
primaryModel := modelName
if provider == "kimi" {
providerKey = "moonshot"
primaryModel = "moonshot/" + modelID
}
return newOpenClawProviderPatch(primaryModel, providerKey, modelID, strings.TrimSpace(apiKey), baseURL, "openai-completions", false)
}
func newOpenClawProviderPatch(primaryModel, providerKey, modelID, apiKey, baseURL, apiType string, authHeader bool) *OpenClawProviderPatch {
return &OpenClawProviderPatch{
PrimaryModel: primaryModel,
ProviderKey: providerKey,
ModelID: modelID,
APIKey: apiKey,
BaseURL: baseURL,
APIType: apiType,
AuthHeader: authHeader,
}
}
func newOpenClawPatch(providerPatch *OpenClawProviderPatch, modelName string, input []string, reasoning bool, contextWindow, maxTokens int) *OpenClawPatch {
model := map[string]interface{}{
"id": providerPatch.ModelID,
"name": modelName,
"reasoning": reasoning,
"input": input,
"contextWindow": contextWindow,
"maxTokens": maxTokens,
"cost": map[string]interface{}{},
}
providerConfig := map[string]interface{}{
"apiKey": providerPatch.APIKey,
"baseUrl": providerPatch.BaseURL,
"api": providerPatch.APIType,
"models": []map[string]interface{}{model},
}
if providerPatch.AuthHeader {
providerConfig["authHeader"] = true
}
return &OpenClawPatch{
PrimaryModel: providerPatch.PrimaryModel,
Models: map[string]interface{}{
"mode": "merge",
"providers": map[string]interface{}{
providerPatch.ProviderKey: providerConfig,
},
},
}
}
func resolveOpenClawModelID(provider, modelName string) string {
if provider == "custom" || provider == "vllm" {
return normalizeCustomModel(modelName)
}
if parts := strings.SplitN(modelName, "/", 2); len(parts) == 2 {
return parts[1]
}
return modelName
}
func resolveOpenClawModelName(provider, modelID string) string {
if item, ok := resolveOpenClawCatalogModel(provider, modelID); ok {
return item.Name
}
if provider == "kimi-coding" {
return "Kimi for Coding"
}
return modelID
}
func resolveOpenClawModelInput(provider, modelID string) []string {
if item, ok := resolveOpenClawCatalogModel(provider, modelID); ok && len(item.Input) > 0 {
return item.Input
}
if provider == "kimi-coding" {
return []string{"text", "image"}
}
return []string{"text"}
}
func resolveOpenClawCatalogModel(provider, modelID string) (Model, bool) {
candidates := []string{provider + "/" + modelID}
if provider == "gemini" {
candidates = append(candidates, "google/"+modelID)
}
for _, candidate := range candidates {
if item, ok := FindModel(provider, candidate); ok {
return item, true
}
}
return Model{}, false
}
func normalizeCustomModel(modelName string) string {
trim := strings.TrimSpace(modelName)
trim = strings.TrimLeft(trim, "/")
if parts := strings.SplitN(trim, "/", 2); len(parts) == 2 && strings.EqualFold(parts[0], "custom") {
return strings.TrimLeft(strings.TrimSpace(parts[1]), "/")
}
return trim
}
+181
View File
@@ -0,0 +1,181 @@
package provider
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"github.com/1Panel-dev/1Panel/agent/buserr"
)
type VerifyRequest struct {
Method string
URL string
Headers map[string]string
Body []byte
}
const (
defaultVerifyTimeout = 30 * time.Second
)
func SkipVerification(key string) bool {
switch key {
case "custom", "vllm", "ollama", "kimi-coding":
return true
default:
return false
}
}
func VerifyAccount(provider, baseURL, apiKey string) error {
req := BuildVerifyRequest(provider, baseURL, apiKey)
var body *bytes.Buffer
if len(req.Body) > 0 {
body = bytes.NewBuffer(req.Body)
} else {
body = bytes.NewBuffer(nil)
}
httpReq, err := http.NewRequest(req.Method, req.URL, body)
if err != nil {
return err
}
for key, value := range req.Headers {
httpReq.Header.Set(key, value)
}
resp, err := (&http.Client{Timeout: verifyTimeout()}).Do(httpReq)
if err != nil {
return buserr.WithErr("ErrAgentAccountUnavailable", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return buserr.WithErr("ErrAgentAccountUnavailable", fmt.Errorf("verify failed: %s", resp.Status))
}
return nil
}
func verifyTimeout() time.Duration {
return defaultVerifyTimeout
}
func BuildVerifyRequest(provider, baseURL, apiKey string) VerifyRequest {
base := strings.TrimRight(strings.TrimSpace(baseURL), "/")
headers := map[string]string{}
request := VerifyRequest{Method: http.MethodGet, Headers: headers}
switch provider {
case "anthropic", "kimi-coding":
headers["x-api-key"] = apiKey
headers["anthropic-version"] = "2023-06-01"
if strings.Contains(base, "/v1") {
request.URL = base + "/models"
} else {
request.URL = base + "/v1/models"
}
case "gemini":
request.Method = http.MethodPost
if strings.Contains(base, "/v1beta") {
request.URL = base + "/models/gemini-3-flash-preview:generateContent"
} else {
request.URL = base + "/v1beta/models/gemini-3-flash-preview:generateContent"
}
headers["x-goog-api-key"] = apiKey
headers["Content-Type"] = "application/json"
request.Body = mustJSON(map[string]interface{}{
"contents": []map[string]interface{}{{
"parts": []map[string]string{{
"text": "Explain how AI works in a few words",
}},
}},
})
case "zai":
headers["Authorization"] = fmt.Sprintf("Bearer %s", apiKey)
request.URL = base + "/models"
case "bailian-coding-plan":
request.Method = http.MethodPost
if !strings.Contains(base, "/v1") {
base = base + "/v1"
}
request.URL = base + "/chat/completions"
headers["Authorization"] = fmt.Sprintf("Bearer %s", apiKey)
headers["Content-Type"] = "application/json"
request.Body = mustJSON(map[string]interface{}{
"model": "qwen3.5-plus",
"messages": []map[string]string{{"role": "user", "content": "test"}},
"max_tokens": 1,
})
case "ark-coding-plan":
request.Method = http.MethodPost
if !strings.Contains(base, "/api/coding/v3") {
base = "https://ark.cn-beijing.volces.com/api/coding/v3"
}
request.URL = base + "/chat/completions"
headers["Authorization"] = fmt.Sprintf("Bearer %s", apiKey)
headers["Content-Type"] = "application/json"
request.Body = mustJSON(map[string]interface{}{
"model": "ark-code-latest",
"messages": []map[string]string{{"role": "user", "content": "test"}},
"max_tokens": 1,
})
case "minimax":
request.Method = http.MethodPost
headers["x-api-key"] = apiKey
headers["anthropic-version"] = "2023-06-01"
headers["Content-Type"] = "application/json"
if strings.Contains(base, "/v1") {
request.URL = base + "/messages"
} else {
request.URL = base + "/v1/messages"
}
request.Body = mustJSON(map[string]interface{}{
"model": "MiniMax-M2.5",
"max_tokens": 1,
"messages": []map[string]interface{}{{
"role": "user",
"content": []map[string]string{{
"type": "text",
"text": "test",
}},
}},
})
case "xiaomi":
request.Method = http.MethodPost
headers["Authorization"] = fmt.Sprintf("Bearer %s", apiKey)
headers["Content-Type"] = "application/json"
if !strings.Contains(base, "/v1") {
base = base + "/v1"
}
request.URL = base + "/chat/completions"
request.Body = mustJSON(map[string]interface{}{
"model": "mimo-v2-flash",
"max_tokens": 1,
"messages": []map[string]string{{"role": "user", "content": "test"}},
})
case "openrouter":
headers["Authorization"] = fmt.Sprintf("Bearer %s", apiKey)
if strings.Contains(base, "/v1") {
request.URL = base + "/key"
} else {
request.URL = base + "/v1/key"
}
default:
headers["Authorization"] = fmt.Sprintf("Bearer %s", apiKey)
if strings.Contains(base, "/v1") {
request.URL = base + "/models"
} else {
request.URL = base + "/v1/models"
}
}
return request
}
func mustJSON(value interface{}) []byte {
payload, err := json.Marshal(value)
if err != nil {
return []byte("{}")
}
return payload
}
+83
View File
@@ -0,0 +1,83 @@
package repo
import (
"context"
"github.com/1Panel-dev/1Panel/agent/app/model"
)
type AgentRepo struct{}
type IAgentRepo interface {
Page(page, size int, opts ...DBOption) (int64, []model.Agent, error)
GetFirst(opts ...DBOption) (*model.Agent, error)
Create(agent *model.Agent) error
Save(agent *model.Agent) error
DeleteByID(id uint) error
DeleteByAppInstallID(appInstallID uint) error
DeleteByAppInstallIDWithCtx(ctx context.Context, appInstallID uint) error
ClearWebsiteIDByWebsiteIDWithCtx(ctx context.Context, websiteID uint) error
List(opts ...DBOption) ([]model.Agent, error)
}
func NewIAgentRepo() IAgentRepo {
return &AgentRepo{}
}
func (a AgentRepo) Page(page, size int, opts ...DBOption) (int64, []model.Agent, error) {
var agents []model.Agent
db := getDb(opts...).Model(&model.Agent{})
count := int64(0)
db = db.Count(&count)
err := db.Limit(size).Offset(size * (page - 1)).Find(&agents).Error
return count, agents, err
}
func (a AgentRepo) GetFirst(opts ...DBOption) (*model.Agent, error) {
var agent model.Agent
if err := getDb(opts...).First(&agent).Error; err != nil {
return nil, err
}
return &agent, nil
}
func (a AgentRepo) Create(agent *model.Agent) error {
return getDb().Create(agent).Error
}
func (a AgentRepo) Save(agent *model.Agent) error {
return getDb().Save(agent).Error
}
func (a AgentRepo) DeleteByID(id uint) error {
return getDb().Delete(&model.Agent{}, id).Error
}
func (a AgentRepo) DeleteByAppInstallID(appInstallID uint) error {
if appInstallID == 0 {
return nil
}
return getDb().Where("app_install_id = ?", appInstallID).Delete(&model.Agent{}).Error
}
func (a AgentRepo) DeleteByAppInstallIDWithCtx(ctx context.Context, appInstallID uint) error {
if appInstallID == 0 {
return nil
}
return getTx(ctx).Where("app_install_id = ?", appInstallID).Delete(&model.Agent{}).Error
}
func (a AgentRepo) ClearWebsiteIDByWebsiteIDWithCtx(ctx context.Context, websiteID uint) error {
if websiteID == 0 {
return nil
}
return getTx(ctx).Model(&model.Agent{}).Where("website_id = ?", websiteID).Update("website_id", 0).Error
}
func (a AgentRepo) List(opts ...DBOption) ([]model.Agent, error) {
var agents []model.Agent
if err := getDb(opts...).Find(&agents).Error; err != nil {
return nil, err
}
return agents, nil
}
+55
View File
@@ -0,0 +1,55 @@
package repo
import "github.com/1Panel-dev/1Panel/agent/app/model"
type AgentAccountRepo struct{}
type IAgentAccountRepo interface {
Page(page, size int, opts ...DBOption) (int64, []model.AgentAccount, error)
GetFirst(opts ...DBOption) (*model.AgentAccount, error)
Create(account *model.AgentAccount) error
Save(account *model.AgentAccount) error
DeleteByID(id uint) error
List(opts ...DBOption) ([]model.AgentAccount, error)
}
func NewIAgentAccountRepo() IAgentAccountRepo {
return &AgentAccountRepo{}
}
func (a AgentAccountRepo) Page(page, size int, opts ...DBOption) (int64, []model.AgentAccount, error) {
var accounts []model.AgentAccount
db := getDb(opts...).Model(&model.AgentAccount{})
count := int64(0)
db = db.Count(&count)
err := db.Limit(size).Offset(size * (page - 1)).Find(&accounts).Error
return count, accounts, err
}
func (a AgentAccountRepo) GetFirst(opts ...DBOption) (*model.AgentAccount, error) {
var account model.AgentAccount
if err := getDb(opts...).First(&account).Error; err != nil {
return nil, err
}
return &account, nil
}
func (a AgentAccountRepo) Create(account *model.AgentAccount) error {
return getDb().Create(account).Error
}
func (a AgentAccountRepo) Save(account *model.AgentAccount) error {
return getDb().Save(account).Error
}
func (a AgentAccountRepo) DeleteByID(id uint) error {
return getDb().Delete(&model.AgentAccount{}, id).Error
}
func (a AgentAccountRepo) List(opts ...DBOption) ([]model.AgentAccount, error) {
var accounts []model.AgentAccount
if err := getDb(opts...).Find(&accounts).Error; err != nil {
return nil, err
}
return accounts, nil
}
+50
View File
@@ -0,0 +1,50 @@
package repo
import "github.com/1Panel-dev/1Panel/agent/app/model"
type AgentAccountModelRepo struct{}
type IAgentAccountModelRepo interface {
List(opts ...DBOption) ([]model.AgentAccountModel, error)
GetFirst(opts ...DBOption) (*model.AgentAccountModel, error)
Create(item *model.AgentAccountModel) error
Save(item *model.AgentAccountModel) error
DeleteByID(id uint) error
Delete(opts ...DBOption) error
}
func NewIAgentAccountModelRepo() IAgentAccountModelRepo {
return &AgentAccountModelRepo{}
}
func (a AgentAccountModelRepo) List(opts ...DBOption) ([]model.AgentAccountModel, error) {
var list []model.AgentAccountModel
if err := getDb(opts...).Find(&list).Error; err != nil {
return nil, err
}
return list, nil
}
func (a AgentAccountModelRepo) GetFirst(opts ...DBOption) (*model.AgentAccountModel, error) {
var item model.AgentAccountModel
if err := getDb(opts...).First(&item).Error; err != nil {
return nil, err
}
return &item, nil
}
func (a AgentAccountModelRepo) Create(item *model.AgentAccountModel) error {
return getDb().Create(item).Error
}
func (a AgentAccountModelRepo) Save(item *model.AgentAccountModel) error {
return getDb().Save(item).Error
}
func (a AgentAccountModelRepo) DeleteByID(id uint) error {
return getDb().Delete(&model.AgentAccountModel{}, id).Error
}
func (a AgentAccountModelRepo) Delete(opts ...DBOption) error {
return getDb(opts...).Delete(&model.AgentAccountModel{}).Error
}
+7
View File
@@ -20,6 +20,7 @@ type IAppRepo interface {
WithResource(resource string) DBOption
WithNotLocal() DBOption
WithByLikeName(name string) DBOption
WithKeyIn(keys []string) DBOption
WithArch(arch string) DBOption
WithPanelVersion(panelVersion string) DBOption
@@ -55,6 +56,12 @@ func (a AppRepo) WithKey(key string) DBOption {
}
}
func (a AppRepo) WithKeyIn(keys []string) DBOption {
return func(db *gorm.DB) *gorm.DB {
return db.Where("`key` in (?)", keys)
}
}
func (a AppRepo) WithType(typeStr string) DBOption {
return func(g *gorm.DB) *gorm.DB {
return g.Where("type = ?", typeStr)
+2 -1
View File
@@ -80,9 +80,10 @@ func (u *LauncherRepo) ListQuickJump(withAll bool) []model.QuickJump {
}
if !withAll && len(quicks) == 0 {
return []model.QuickJump{
{Name: "Agent", Title: "aiTools.agents.agent", Recommend: 1, IsShow: true, Router: "/ai/agents/agent"},
{Name: "Website", Title: "menu.website", Recommend: 10, IsShow: true, Router: "/websites"},
{Name: "Database", Title: "menu.database", Recommend: 30, IsShow: true, Router: "/databases"},
{Name: "Cronjob", Title: "menu.cronjob", Recommend: 50, IsShow: true, Router: "/cronjobs"},
{Name: "Cronjob", Title: "menu.cronjob", Recommend: 50, IsShow: false, Router: "/cronjobs"},
{Name: "AppInstalled", Title: "home.appInstalled", Recommend: 70, IsShow: true, Router: "/apps/installed"},
}
}
+72 -7
View File
@@ -7,6 +7,7 @@ import (
"github.com/1Panel-dev/1Panel/agent/constant"
"github.com/1Panel-dev/1Panel/agent/global"
"github.com/1Panel-dev/1Panel/agent/utils/re"
"gorm.io/gorm"
)
@@ -18,6 +19,12 @@ func WithByID(id uint) DBOption {
}
}
func WithByGroupID(id uint) DBOption {
return func(g *gorm.DB) *gorm.DB {
return g.Where("group_id = ?", id)
}
}
func WithByNOTID(id uint) DBOption {
return func(g *gorm.DB) *gorm.DB {
return g.Where("id != ?", id)
@@ -42,6 +49,12 @@ func WithByName(name string) DBOption {
}
}
func WithByAddr(addr string) DBOption {
return func(g *gorm.DB) *gorm.DB {
return g.Where("addr = ?", addr)
}
}
func WithByKey(key string) DBOption {
return func(g *gorm.DB) *gorm.DB {
return g.Where("key = ?", key)
@@ -72,6 +85,51 @@ func WithByDetailName(detailName string) DBOption {
}
}
func WithByProvider(provider string) DBOption {
return func(g *gorm.DB) *gorm.DB {
if len(provider) == 0 {
return g
}
return g.Where("provider = ?", provider)
}
}
func WithByModel(model string) DBOption {
return func(g *gorm.DB) *gorm.DB {
if len(model) == 0 {
return g
}
return g.Where("model = ?", model)
}
}
func WithByAccountID(accountID uint) DBOption {
return func(g *gorm.DB) *gorm.DB {
if accountID == 0 {
return g
}
return g.Where("account_id = ?", accountID)
}
}
func WithByWebsiteID(websiteID uint) DBOption {
return func(g *gorm.DB) *gorm.DB {
if websiteID == 0 {
return g
}
return g.Where("website_id = ?", websiteID)
}
}
func WithByAppInstallID(appInstallID uint) DBOption {
return func(g *gorm.DB) *gorm.DB {
if appInstallID == 0 {
return g
}
return g.Where("app_install_id = ?", appInstallID)
}
}
func WithByType(tp string) DBOption {
return func(g *gorm.DB) *gorm.DB {
return g.Where("`type` = ?", tp)
@@ -124,23 +182,30 @@ func WithByCreatedAt(startTime, endTime time.Time) DBOption {
}
}
func WithOrderBy(orderStr string) DBOption {
if orderStr == "createdAt" {
orderStr = "created_at"
}
return func(g *gorm.DB) *gorm.DB {
return g.Order(orderStr)
}
func WithOrderDesc(orderBy string) DBOption {
return WithOrderRuleBy(orderBy, constant.Desc)
}
func WithOrderAsc(orderBy string) DBOption {
return WithOrderRuleBy(orderBy, constant.Asc)
}
func WithOrderRuleBy(orderBy, order string) DBOption {
if orderBy == "createdAt" {
orderBy = "created_at"
}
if !re.GetRegex(re.OrderByValidationPattern).MatchString(orderBy) {
orderBy = "created_at"
}
switch order {
case constant.OrderDesc:
order = "desc"
case constant.OrderAsc:
order = "asc"
case constant.Desc:
order = "desc"
case constant.Asc:
order = "asc"
default:
orderBy = "created_at"
order = "desc"
+77
View File
@@ -0,0 +1,77 @@
package repo
import (
"github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/global"
"gorm.io/gorm"
)
type FileShareRepo struct{}
type IFileShareRepo interface {
Page(page, size int, opts ...DBOption) (int64, []model.FileShare, error)
Create(fileShare *model.FileShare) error
Save(fileShare *model.FileShare) error
Delete(opts ...DBOption) error
GetFirst(opts ...DBOption) (model.FileShare, error)
All() ([]model.FileShare, error)
WithByPath(path string) DBOption
WithByCode(code string) DBOption
}
func NewIFileShareRepo() IFileShareRepo {
return &FileShareRepo{}
}
func (r *FileShareRepo) WithByPath(path string) DBOption {
return func(db *gorm.DB) *gorm.DB {
return db.Where("path = ?", path)
}
}
func (r *FileShareRepo) WithByCode(code string) DBOption {
return func(db *gorm.DB) *gorm.DB {
return db.Where("token = ?", code)
}
}
func (r *FileShareRepo) Page(page, size int, opts ...DBOption) (int64, []model.FileShare, error) {
var (
items []model.FileShare
count int64
)
db := getDb(opts...).Model(&model.FileShare{})
db = db.Count(&count)
err := db.Order("path asc").Limit(size).Offset(size * (page - 1)).Find(&items).Error
return count, items, err
}
func (r *FileShareRepo) Create(fileShare *model.FileShare) error {
return global.DB.Create(fileShare).Error
}
func (r *FileShareRepo) Save(fileShare *model.FileShare) error {
return global.DB.Save(fileShare).Error
}
func (r *FileShareRepo) GetFirst(opts ...DBOption) (model.FileShare, error) {
var item model.FileShare
db := getDb(opts...).Model(&model.FileShare{})
if err := db.First(&item).Error; err != nil {
return item, err
}
return item, nil
}
func (r *FileShareRepo) Delete(opts ...DBOption) error {
db := getDb(opts...).Model(&model.FileShare{})
return db.Delete(&model.FileShare{}).Error
}
func (r *FileShareRepo) All() ([]model.FileShare, error) {
var items []model.FileShare
if err := getDb().Order("path asc").Find(&items).Error; err != nil {
return nil, err
}
return items, nil
}
+86
View File
@@ -10,6 +10,18 @@ import (
type HostRepo struct{}
type IHostRepo interface {
Get(opts ...DBOption) (model.Host, error)
GetList(opts ...DBOption) ([]model.Host, error)
Page(limit, offset int, opts ...DBOption) (int64, []model.Host, error)
Create(host *model.Host) error
Update(id uint, vars map[string]interface{}) error
UpdateGroup(group, newGroup uint) error
Delete(opts ...DBOption) error
WithByInfo(info string) DBOption
WithByPort(port uint) DBOption
WithByUser(user string) DBOption
GetFirewallRecord(opts ...DBOption) (model.Firewall, error)
ListFirewallRecord(opts ...DBOption) ([]model.Firewall, error)
SaveFirewallRecord(firewall *model.Firewall) error
@@ -30,6 +42,80 @@ func NewIHostRepo() IHostRepo {
return &HostRepo{}
}
func (h *HostRepo) Get(opts ...DBOption) (model.Host, error) {
var host model.Host
db := global.DB
for _, opt := range opts {
db = opt(db)
}
err := db.First(&host).Error
return host, err
}
func (h *HostRepo) GetList(opts ...DBOption) ([]model.Host, error) {
var hosts []model.Host
db := global.DB.Model(&model.Host{})
for _, opt := range opts {
db = opt(db)
}
err := db.Find(&hosts).Error
return hosts, err
}
func (h *HostRepo) Page(page, size int, opts ...DBOption) (int64, []model.Host, error) {
var hosts []model.Host
db := global.DB.Model(&model.Host{})
for _, opt := range opts {
db = opt(db)
}
count := int64(0)
db = db.Count(&count)
err := db.Limit(size).Offset(size * (page - 1)).Find(&hosts).Error
return count, hosts, err
}
func (h *HostRepo) WithByInfo(info string) DBOption {
return func(g *gorm.DB) *gorm.DB {
if len(info) == 0 {
return g
}
infoStr := "%" + info + "%"
return g.Where("name LIKE ? OR addr LIKE ?", infoStr, infoStr)
}
}
func (h *HostRepo) WithByPort(port uint) DBOption {
return func(g *gorm.DB) *gorm.DB {
return g.Where("port = ?", port)
}
}
func (h *HostRepo) WithByUser(user string) DBOption {
return func(g *gorm.DB) *gorm.DB {
return g.Where("user = ?", user)
}
}
func (h *HostRepo) Create(host *model.Host) error {
return global.DB.Create(host).Error
}
func (h *HostRepo) Update(id uint, vars map[string]interface{}) error {
return global.DB.Model(&model.Host{}).Where("id = ?", id).Updates(vars).Error
}
func (h *HostRepo) UpdateGroup(group, newGroup uint) error {
return global.DB.Model(&model.Host{}).Where("group_id = ?", group).Updates(map[string]interface{}{"group_id": newGroup}).Error
}
func (h *HostRepo) Delete(opts ...DBOption) error {
db := global.DB
for _, opt := range opts {
db = opt(db)
}
return db.Delete(&model.Host{}).Error
}
func (h *HostRepo) GetFirewallRecord(opts ...DBOption) (model.Firewall, error) {
var firewall model.Firewall
db := global.DB
+5 -20
View File
@@ -73,28 +73,13 @@ func (u *MonitorRepo) CreateMonitorBase(model model.MonitorBase) error {
return global.MonitorDB.Create(&model).Error
}
func (s *MonitorRepo) BatchCreateMonitorGPU(list []model.MonitorGPU) error {
for _, item := range list {
if err := global.GPUMonitorDB.Create(&item).Error; err != nil {
global.LOG.Errorf("create Monitor GPU record failed, err: %v", err)
}
}
return nil
return global.GPUMonitorDB.CreateInBatches(&list, len(list)).Error
}
func (u *MonitorRepo) BatchCreateMonitorIO(list []model.MonitorIO) error {
for _, item := range list {
if err := global.MonitorDB.Create(&item).Error; err != nil {
global.LOG.Errorf("create Monitor IO record failed, err: %v", err)
}
}
return nil
func (u *MonitorRepo) BatchCreateMonitorIO(ioList []model.MonitorIO) error {
return global.MonitorDB.CreateInBatches(ioList, len(ioList)).Error
}
func (u *MonitorRepo) BatchCreateMonitorNet(list []model.MonitorNetwork) error {
for _, item := range list {
if err := global.MonitorDB.Create(&item).Error; err != nil {
global.LOG.Errorf("create Monitor Network record failed, err: %v", err)
}
}
return nil
func (u *MonitorRepo) BatchCreateMonitorNet(ioList []model.MonitorNetwork) error {
return global.MonitorDB.CreateInBatches(ioList, len(ioList)).Error
}
func (u *MonitorRepo) DelMonitorBase(timeForDelete time.Time) error {
return global.MonitorDB.Where("created_at < ?", timeForDelete).Delete(&model.MonitorBase{}).Error
+10
View File
@@ -10,6 +10,7 @@ import (
type IWebsiteRepo interface {
WithAppInstallId(appInstallId uint) DBOption
WithAppInstallIds(appInstallIds []uint) DBOption
WithDomain(domain string) DBOption
WithAlias(alias string) DBOption
WithWebsiteSSLID(sslId uint) DBOption
@@ -48,6 +49,15 @@ func (w *WebsiteRepo) WithAppInstallId(appInstallID uint) DBOption {
}
}
func (w *WebsiteRepo) WithAppInstallIds(appInstallIDs []uint) DBOption {
return func(db *gorm.DB) *gorm.DB {
if len(appInstallIDs) == 0 {
return db
}
return db.Where("app_install_id in (?)", appInstallIDs)
}
}
func (w *WebsiteRepo) WithRuntimeID(runtimeID uint) DBOption {
return func(db *gorm.DB) *gorm.DB {
return db.Where("runtime_id = ?", runtimeID)
+10
View File
@@ -13,6 +13,7 @@ type WebsiteDomainRepo struct {
type IWebsiteDomainRepo interface {
WithWebsiteId(websiteId uint) DBOption
WithWebsiteIds(websiteIds []uint) DBOption
WithPort(port int) DBOption
WithDomain(domain string) DBOption
WithDomainLike(domain string) DBOption
@@ -36,6 +37,15 @@ func (w WebsiteDomainRepo) WithWebsiteId(websiteId uint) DBOption {
}
}
func (w WebsiteDomainRepo) WithWebsiteIds(websiteIds []uint) DBOption {
return func(db *gorm.DB) *gorm.DB {
if len(websiteIds) == 0 {
return db
}
return db.Where("website_id in (?)", websiteIds)
}
}
func (w WebsiteDomainRepo) WithPort(port int) DBOption {
return func(db *gorm.DB) *gorm.DB {
return db.Where("port = ?", port)
File diff suppressed because it is too large Load Diff
+533
View File
@@ -0,0 +1,533 @@
package service
import (
"os"
"path"
"sort"
"strings"
"time"
"github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/app/dto/request"
"github.com/1Panel-dev/1Panel/agent/buserr"
"github.com/1Panel-dev/1Panel/agent/constant"
"github.com/1Panel-dev/1Panel/agent/global"
"github.com/1Panel-dev/1Panel/agent/utils/cmd"
)
var agentMarkdownFileNames = []string{"AGENTS.md", "SOUL.md", "USER.md", "IDENTITY.md", "TOOLS.md", "HEARTBEAT.md", "BOOT.md", "BOOTSTRAP.md"}
func (a AgentService) CreateRole(req dto.AgentRoleCreateReq) (*dto.AgentRoleCreateResp, error) {
_, install, err := a.loadAgentAndInstall(req.AgentID)
if err != nil {
return nil, err
}
args := []string{"exec", install.ContainerName, "openclaw", "agents", "add", req.Name}
workspace := "/home/node/.openclaw/workspace-agent_" + req.Name
agentDir := "/home/node/.openclaw/agents/" + req.Name
args = append(args, "--workspace", workspace)
if model := strings.TrimSpace(req.Model); model != "" {
args = append(args, "--model", model)
}
for _, binding := range req.Bindings {
channel := binding.Channel
if channel == "" {
continue
}
if accountID := binding.AccountID; accountID != "" {
channel = channel + ":" + accountID
}
args = append(args, "--bind", channel)
}
args = append(args, "--agent-dir", agentDir)
args = append(args, "--non-interactive", "--json")
mgr := cmd.NewCommandMgr(cmd.WithTimeout(5 * time.Minute))
output, err := mgr.RunWithStdout("docker", args...)
if err != nil {
return nil, err
}
return &dto.AgentRoleCreateResp{Output: strings.TrimSpace(output)}, nil
}
func (a AgentService) GetConfiguredAgents(req dto.AgentConfiguredAgentsReq) ([]dto.AgentConfiguredAgentItem, error) {
agent, _, conf, err := a.loadAgentConfig(req.AgentID)
if err != nil {
return nil, err
}
agents, ok := conf["agents"].(map[string]interface{})
if !ok {
return []dto.AgentConfiguredAgentItem{}, nil
}
rawList, ok := agents["list"].([]interface{})
if !ok {
return []dto.AgentConfiguredAgentItem{}, nil
}
result := make([]dto.AgentConfiguredAgentItem, 0, len(rawList))
baseDir := path.Join(global.Dir.AppInstallDir, agent.AgentType, agent.Name, "data")
for _, item := range rawList {
record, ok := item.(map[string]interface{})
if !ok {
continue
}
configured := extractConfiguredAgentItem(baseDir, record)
if strings.EqualFold(configured.ID, "main") {
continue
}
result = append(result, configured)
}
applyConfiguredAgentBindings(result, conf["bindings"])
return result, nil
}
func (a AgentService) GetRoleChannels(req dto.AgentRoleChannelsReq) ([]dto.AgentRoleChannelItem, error) {
_, _, conf, err := a.loadAgentConfig(req.AgentID)
if err != nil {
return nil, err
}
channels, ok := conf["channels"].(map[string]interface{})
if !ok || len(channels) == 0 {
return []dto.AgentRoleChannelItem{}, nil
}
boundBindings := loadBoundChannelBindings(conf["bindings"])
result := make([]dto.AgentRoleChannelItem, 0, len(channels))
for key := range channels {
if key == "" {
continue
}
accountIDs := extractRoleChannelAccountIDs(conf, key)
availableAccountIDs := filterAvailableChannelAccountIDs(boundBindings, key, accountIDs)
result = append(result, dto.AgentRoleChannelItem{
Name: key,
Bound: isRoleChannelFullyBound(boundBindings, key, accountIDs, availableAccountIDs),
AccountIDs: availableAccountIDs,
})
}
sort.Slice(result, func(i, j int) bool {
return result[i].Name < result[j].Name
})
return result, nil
}
func (a AgentService) DeleteRole(req dto.AgentRoleDeleteReq) error {
agent, install, conf, err := a.loadAgentConfig(req.AgentID)
if err != nil {
return err
}
baseDir := path.Join(global.Dir.AppInstallDir, agent.AgentType, agent.Name, "data")
if req.ID == "" {
return buserr.New("ErrRecordNotFound")
}
target, ok := findConfiguredAgentByID(baseDir, conf, req.ID)
if !ok {
return buserr.New("ErrRecordNotFound")
}
args := []string{"exec", install.ContainerName, "openclaw", "agents", "delete", req.ID, "--force"}
mgr := cmd.NewCommandMgr(cmd.WithTimeout(5 * time.Minute))
if _, err = mgr.RunWithStdout("docker", args...); err != nil {
return err
}
if target.Workspace != "" {
if err := os.RemoveAll(target.Workspace); err != nil {
return err
}
}
if target.AgentDir != "" {
if err := os.RemoveAll(target.AgentDir); err != nil {
return err
}
}
return nil
}
func (a AgentService) BindRole(req dto.AgentRoleBindReq) error {
return a.operateRoleBinding(req, "bind")
}
func (a AgentService) UnbindRole(req dto.AgentRoleBindReq) error {
return a.operateRoleBinding(req, "unbind")
}
func (a AgentService) operateRoleBinding(req dto.AgentRoleBindReq, action string) error {
agent, install, conf, err := a.loadAgentConfig(req.AgentID)
if err != nil {
return err
}
baseDir := path.Join(global.Dir.AppInstallDir, agent.AgentType, agent.Name, "data")
if req.ID == "" {
return buserr.New("ErrRecordNotFound")
}
if _, ok := findConfiguredAgentByID(baseDir, conf, req.ID); !ok {
return buserr.New("ErrRecordNotFound")
}
binding := formatRoleBinding(req.Channel, req.AccountID)
if binding == "" {
return buserr.New("ErrInvalidParams")
}
args := []string{
"exec",
install.ContainerName,
"openclaw",
"agents",
action,
"--agent",
req.ID,
"--bind",
binding,
}
args = append(args, "--json")
mgr := cmd.NewCommandMgr(cmd.WithTimeout(5 * time.Minute))
_, err = mgr.RunWithStdout("docker", args...)
return err
}
func (a AgentService) GetRoleMarkdownFiles(req dto.AgentRoleMarkdownFilesReq) ([]dto.AgentRoleMarkdownFileItem, error) {
agent, err := loadOpenclawAgentByID(req.AgentID)
if err != nil {
return nil, err
}
baseDir := path.Join(global.Dir.AppInstallDir, agent.AgentType, agent.Name, "data")
workspaceDir, err := resolveMarkdownWorkspaceDir(baseDir, req.Workspace)
if err != nil {
return nil, err
}
items := make([]dto.AgentRoleMarkdownFileItem, 0, len(agentMarkdownFileNames))
for _, name := range agentMarkdownFileNames {
item := dto.AgentRoleMarkdownFileItem{
Name: name,
}
content, readErr := os.ReadFile(path.Join(workspaceDir, name))
if readErr == nil {
item.Content = string(content)
} else if !os.IsNotExist(readErr) {
return nil, readErr
}
items = append(items, item)
}
return items, nil
}
func (a AgentService) UpdateRoleMarkdownFiles(req dto.AgentRoleMarkdownFilesUpdateReq) error {
agent, install, err := a.loadOpenclawAgentAndInstall(req.AgentID)
if err != nil {
return err
}
baseDir := path.Join(global.Dir.AppInstallDir, agent.AgentType, agent.Name, "data")
dirPath, err := resolveMarkdownWorkspaceDir(baseDir, req.Workspace)
if err != nil {
return err
}
if err = os.MkdirAll(dirPath, 0755); err != nil {
return err
}
for _, item := range req.Files {
file := path.Join(dirPath, item.Name)
if err = os.WriteFile(file, []byte(item.Content), 0644); err != nil {
return err
}
}
if req.Restart {
return NewIAppInstalledService().Operate(request.AppInstalledOperate{
InstallId: install.ID,
Operate: constant.Restart,
})
}
return nil
}
func extractConfiguredAgentItem(installDir string, record map[string]interface{}) dto.AgentConfiguredAgentItem {
item := dto.AgentConfiguredAgentItem{Bindings: []dto.AgentRoleBinding{}}
if id, ok := record["id"].(string); ok {
item.ID = id
} else if id, ok := record["agentId"].(string); ok {
item.ID = id
}
if name, ok := record["name"].(string); ok {
item.Name = name
}
if workspace, ok := record["workspace"].(string); ok {
item.Workspace = resolveRoleDir(installDir, workspace)
}
if model, ok := record["model"].(string); ok {
item.Model = model
}
if agentDir, ok := record["agentDir"].(string); ok {
item.AgentDir = agentDir
} else if agentDir, ok := record["agent_dir"].(string); ok {
item.AgentDir = agentDir
}
item.AgentDir = resolveRoleDir(installDir, item.AgentDir)
return item
}
func findConfiguredAgentByID(baseDir string, conf map[string]interface{}, id string) (dto.AgentConfiguredAgentItem, bool) {
agents, ok := conf["agents"].(map[string]interface{})
if !ok {
return dto.AgentConfiguredAgentItem{}, false
}
rawList, ok := agents["list"].([]interface{})
if !ok {
return dto.AgentConfiguredAgentItem{}, false
}
for _, item := range rawList {
record, ok := item.(map[string]interface{})
if !ok {
continue
}
configured := extractConfiguredAgentItem(baseDir, record)
if strings.EqualFold(configured.ID, id) {
return configured, true
}
}
return dto.AgentConfiguredAgentItem{}, false
}
func formatRoleBinding(channel, accountID string) string {
if channel == "" {
return ""
}
if accountID == "" {
return channel
}
return channel + ":" + accountID
}
func applyConfiguredAgentBindings(agents []dto.AgentConfiguredAgentItem, value interface{}) {
bindings, ok := value.([]interface{})
if !ok || len(agents) == 0 {
return
}
indexByID := make(map[string]int, len(agents))
indexByName := make(map[string]int, len(agents))
for i, agent := range agents {
if agent.ID != "" {
indexByID[agent.ID] = i
}
if agent.Name != "" {
indexByName[agent.Name] = i
}
}
for _, binding := range bindings {
record, ok := binding.(map[string]interface{})
if !ok {
continue
}
if bindingType, _ := record["type"].(string); !strings.EqualFold(bindingType, "route") {
continue
}
targetID, _ := record["agentId"].(string)
if targetID == "" {
continue
}
match, ok := record["match"].(map[string]interface{})
if !ok {
continue
}
channel, _ := match["channel"].(string)
if channel == "" {
continue
}
index, ok := indexByID[targetID]
if !ok {
index, ok = indexByName[targetID]
}
if !ok {
continue
}
accountID, _ := match["accountId"].(string)
if accountID == "" {
accountID, _ = record["accountId"].(string)
}
agents[index].Bindings = append(agents[index].Bindings, dto.AgentRoleBinding{
Channel: channel,
AccountID: accountID,
})
}
}
func loadBoundChannelBindings(value interface{}) map[string]map[string]struct{} {
result := make(map[string]map[string]struct{})
bindings, ok := value.([]interface{})
if !ok {
return result
}
for _, binding := range bindings {
record, ok := binding.(map[string]interface{})
if !ok {
continue
}
if bindingType, _ := record["type"].(string); !strings.EqualFold(bindingType, "route") {
continue
}
match, ok := record["match"].(map[string]interface{})
if !ok {
continue
}
channel, _ := match["channel"].(string)
if channel == "" {
continue
}
accountID, _ := match["accountId"].(string)
if accountID == "" {
accountID, _ = record["accountId"].(string)
}
if _, ok := result[channel]; !ok {
result[channel] = make(map[string]struct{})
}
result[channel][accountID] = struct{}{}
}
return result
}
func extractRoleChannelAccountIDs(conf map[string]interface{}, channel string) []string {
switch channel {
case "feishu":
config := extractFeishuConfig(conf)
accountIDs := make([]string, 0, len(config.Bots))
for _, item := range config.Bots {
if accountID := item.AccountID; accountID != "" {
accountIDs = append(accountIDs, accountID)
}
}
sort.Strings(accountIDs)
return accountIDs
case "telegram":
config := extractTelegramConfig(conf)
accountIDs := make([]string, 0, len(config.Bots))
for _, item := range config.Bots {
if accountID := item.AccountID; accountID != "" {
accountIDs = append(accountIDs, accountID)
}
}
sort.Strings(accountIDs)
return accountIDs
case "discord":
config := extractDiscordConfig(conf)
accountIDs := make([]string, 0, len(config.Bots))
for _, item := range config.Bots {
if accountID := item.AccountID; accountID != "" {
accountIDs = append(accountIDs, accountID)
}
}
sort.Strings(accountIDs)
return accountIDs
case "qqbot":
config := extractQQBotConfig(conf)
accountIDs := make([]string, 0, len(config.Bots))
for _, item := range config.Bots {
if accountID := item.AccountID; accountID != "" {
accountIDs = append(accountIDs, accountID)
}
}
sort.Strings(accountIDs)
return accountIDs
case "dingtalk-connector":
config := extractDingTalkConfig(conf)
accountIDs := make([]string, 0, len(config.Bots))
for _, item := range config.Bots {
if accountID := item.AccountID; accountID != "" {
accountIDs = append(accountIDs, accountID)
}
}
sort.Strings(accountIDs)
return accountIDs
case "wecom":
return []string{}
default:
return extractRawChannelAccountIDs(getChannelConfig(conf, channel))
}
}
func filterAvailableChannelAccountIDs(bindings map[string]map[string]struct{}, channel string, accountIDs []string) []string {
channelBindings, ok := bindings[channel]
if !ok || len(channelBindings) == 0 {
return append([]string(nil), accountIDs...)
}
if _, ok := channelBindings[""]; ok {
return []string{}
}
result := make([]string, 0, len(accountIDs))
for _, accountID := range accountIDs {
if _, ok := channelBindings[accountID]; ok {
continue
}
result = append(result, accountID)
}
return result
}
func isRoleChannelFullyBound(bindings map[string]map[string]struct{}, channel string, allAccountIDs, availableAccountIDs []string) bool {
channelBindings, ok := bindings[channel]
if !ok || len(channelBindings) == 0 {
return false
}
if _, ok := channelBindings[""]; ok {
return true
}
if len(allAccountIDs) == 0 {
return true
}
return len(availableAccountIDs) == 0
}
func extractRawChannelAccountIDs(channel map[string]interface{}) []string {
if len(channel) == 0 {
return []string{}
}
accounts, ok := channel["accounts"].(map[string]interface{})
if !ok || len(accounts) == 0 {
return []string{}
}
result := make([]string, 0, len(accounts))
for key := range accounts {
if key == "" {
continue
}
result = append(result, key)
}
sort.Strings(result)
return result
}
func resolveRoleDir(installDir, workspace string) string {
if workspace == "" {
return ""
}
if strings.HasPrefix(workspace, "/home/node/.openclaw/workspace/") {
return strings.ReplaceAll(workspace, "/home/node/.openclaw", installDir)
}
return strings.ReplaceAll(workspace, "/home/node/.openclaw", path.Join(installDir, "conf"))
}
func resolveMarkdownWorkspaceDir(installDir, workspace string) (string, error) {
if workspace == "" {
return "", buserr.New("ErrRecordNotFound")
}
confDir := path.Join(installDir, "conf")
allowedOpenclawPrefixes := []string{
"/home/node/.openclaw/workspace/",
"/home/node/.openclaw/workspace-agent_",
}
for _, prefix := range allowedOpenclawPrefixes {
if strings.HasPrefix(workspace, prefix) {
return resolveRoleDir(installDir, workspace), nil
}
}
cleanWorkspace := path.Clean(workspace)
cleanConfDir := path.Clean(confDir)
if cleanWorkspace == cleanConfDir || strings.HasPrefix(cleanWorkspace, cleanConfDir+"/") {
return cleanWorkspace, nil
}
return "", buserr.New("ErrRecordNotFound")
}
File diff suppressed because it is too large Load Diff
+239
View File
@@ -0,0 +1,239 @@
package service
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/constant"
)
const (
openclawCronJobsRelativePath = "data/conf/cron/jobs.json"
openclawSessionStoreRelativeGlob = "data/conf/agents/*/sessions/sessions.json"
openclawSessionFilesRelativeGlob = "data/conf/agents/*/sessions/*.jsonl"
)
func (a AgentService) GetOverview(req dto.AgentOverviewReq) (*dto.AgentOverview, error) {
agent, install, conf, err := a.loadOpenclawAgentConfig(req.AgentID)
if err != nil {
return nil, err
}
overview := &dto.AgentOverview{
Snapshot: dto.AgentOverviewSnapshot{
ContainerStatus: install.Status,
AppVersion: install.Version,
DefaultModel: extractOpenclawDefaultModel(conf),
ChannelCount: countOpenclawConfiguredChannels(conf),
},
}
if overview.Snapshot.DefaultModel == "" {
overview.Snapshot.DefaultModel = agent.Model
}
if install.Status != constant.StatusRunning {
return overview, nil
}
var wg sync.WaitGroup
wg.Add(3)
go func() {
defer wg.Done()
skillCount, err := loadOpenclawOverviewSkillStats(install.ContainerName)
if err == nil {
overview.Snapshot.SkillCount = skillCount
}
}()
go func() {
defer wg.Done()
sessionCount, err := loadOpenclawOverviewSessionCount(install.GetPath())
if err == nil {
overview.Snapshot.SessionCount = sessionCount
}
}()
go func() {
defer wg.Done()
jobCount, err := loadOpenclawOverviewJobCount(install.GetPath())
if err == nil {
overview.Snapshot.JobCount = jobCount
}
}()
wg.Wait()
return overview, nil
}
func extractOpenclawDefaultModel(conf map[string]interface{}) string {
agents, ok := conf["agents"].(map[string]interface{})
if !ok {
return ""
}
defaults, ok := agents["defaults"].(map[string]interface{})
if !ok {
return ""
}
model, ok := defaults["model"].(map[string]interface{})
if !ok {
return ""
}
primary, _ := model["primary"].(string)
return strings.TrimSpace(primary)
}
func countOpenclawConfiguredChannels(conf map[string]interface{}) int {
channels, ok := conf["channels"].(map[string]interface{})
if !ok {
return 0
}
count := 0
for _, value := range channels {
channel, ok := value.(map[string]interface{})
if !ok || len(channel) == 0 {
continue
}
count++
}
return count
}
func loadOpenclawOverviewSkillStats(containerName string) (int, error) {
output, err := runDockerExecWithStdout(5*time.Minute, containerName, "sh", "-c", "openclaw skills list --json 2>&1")
if err != nil {
return 0, err
}
if strings.TrimSpace(output) == "" {
return 0, nil
}
skills, err := parseOpenclawSkillsList(output)
if err != nil {
return 0, err
}
return len(skills), nil
}
func loadOpenclawOverviewSessionCount(installPath string) (int, error) {
sessionStorePaths, err := filepath.Glob(filepath.Join(installPath, openclawSessionStoreRelativeGlob))
if err != nil {
return 0, err
}
total := 0
for _, sessionStorePath := range sessionStorePaths {
count, err := loadOpenclawSessionCountFromFile(sessionStorePath)
if err != nil {
continue
}
total += count
}
if total > 0 {
return total, nil
}
sessionFiles, err := filepath.Glob(filepath.Join(installPath, openclawSessionFilesRelativeGlob))
if err != nil {
return 0, err
}
return len(sessionFiles), nil
}
func loadOpenclawSessionCountFromFile(sessionStorePath string) (int, error) {
content, err := os.ReadFile(sessionStorePath)
if err != nil {
return 0, err
}
return parseOpenclawSessionCount(string(content))
}
func loadOpenclawOverviewJobCount(installPath string) (int, error) {
content, err := os.ReadFile(filepath.Join(installPath, openclawCronJobsRelativePath))
if err != nil {
if os.IsNotExist(err) {
return 0, nil
}
return 0, err
}
return parseOpenclawCronCount(string(content))
}
func parseOpenclawSessionCount(output string) (int, error) {
if strings.TrimSpace(output) == "" {
return 0, nil
}
var payload interface{}
if err := json.Unmarshal([]byte(strings.TrimSpace(output)), &payload); err != nil {
return 0, err
}
return countOpenclawSessions(payload), nil
}
func parseOpenclawCronCount(output string) (int, error) {
if strings.TrimSpace(output) == "" {
return 0, nil
}
var payload interface{}
if err := json.Unmarshal([]byte(strings.TrimSpace(output)), &payload); err != nil {
return 0, err
}
switch value := payload.(type) {
case []interface{}:
return len(value), nil
case map[string]interface{}:
if total, ok := value["total"].(float64); ok {
return int(total), nil
}
if jobs, ok := value["jobs"].([]interface{}); ok {
return len(jobs), nil
}
return len(value), nil
default:
return 0, nil
}
}
func countOpenclawSessions(payload interface{}) int {
switch value := payload.(type) {
case []interface{}:
return len(value)
case map[string]interface{}:
if count, ok := value["count"].(float64); ok {
return int(count)
}
if sessions, ok := value["sessions"]; ok {
return countOpenclawSessions(sessions)
}
count := 0
for _, item := range value {
switch typed := item.(type) {
case map[string]interface{}:
if looksLikeOpenclawSession(typed) {
count++
}
case string:
if strings.HasSuffix(strings.TrimSpace(typed), ".jsonl") {
count++
}
}
}
return count
default:
return 0
}
}
func looksLikeOpenclawSession(item map[string]interface{}) bool {
if _, ok := item["sessionFile"]; ok {
return true
}
if _, ok := item["agentId"]; ok {
return true
}
if _, ok := item["lastActivityTs"]; ok {
return true
}
if _, ok := item["updatedAt"]; ok {
return true
}
return false
}
+286
View File
@@ -0,0 +1,286 @@
package service
import (
"encoding/json"
"fmt"
"regexp"
"strings"
"time"
"github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/app/task"
"github.com/1Panel-dev/1Panel/agent/global"
"github.com/1Panel-dev/1Panel/agent/utils/cmd"
)
const clawhubGlobalRegistry = "https://clawhub.com"
const clawhubChinaRegistry = "https://mirror-cn.clawhub.com"
type openclawSkillsList struct {
Skills []openclawSkillListItem `json:"skills"`
}
type openclawSkillListItem struct {
Name string `json:"name"`
Description string `json:"description"`
Source string `json:"source"`
Bundled bool `json:"bundled"`
Disabled bool `json:"disabled"`
}
type openclawSkillInfo struct {
SkillKey string `json:"skillKey"`
}
type skillhubSearchPayload struct {
Skills []dto.AgentSkillSearchItem `json:"skills"`
Results []dto.AgentSkillSearchItem `json:"results"`
}
var clawhubSearchLinePattern = regexp.MustCompile(`^(\S+)\s+(.+?)\s+\(([\d.]+)\)$`)
var ansiEscapePattern = regexp.MustCompile(`\x1b\[[0-9;?]*[ -/]*[@-~]`)
func (a AgentService) ListSkills(req dto.AgentIDReq) ([]dto.AgentSkillItem, error) {
_, install, err := a.loadOpenclawAgentAndInstall(req.AgentID)
if err != nil {
return nil, err
}
if err := ensureContainerRunning(install.ContainerName); err != nil {
return nil, err
}
output, err := runDockerExecWithStdout(5*time.Minute, install.ContainerName, "sh", "-c", "openclaw skills list --json 2>&1")
if err != nil {
return nil, err
}
if len(output) == 0 {
return nil, nil
}
return parseOpenclawSkillsList(output)
}
func (a AgentService) SearchSkills(req dto.AgentSkillSearchReq) ([]dto.AgentSkillSearchItem, error) {
_, install, err := a.loadOpenclawAgentAndInstall(req.AgentID)
if err != nil {
return nil, err
}
if err := ensureContainerRunning(install.ContainerName); err != nil {
return nil, err
}
output, err := loadOpenclawSkillSearchOutput(install.ContainerName, req.Source, req.Keyword)
if err != nil {
return nil, err
}
if len(output) == 0 {
return nil, nil
}
switch req.Source {
case "skillhub":
return parseSkillhubSearchResult(output)
default:
return parseClawhubSearchResult(output, req.Source), nil
}
}
func (a AgentService) UpdateSkill(req dto.AgentSkillUpdateReq) error {
agent, install, err := a.loadOpenclawAgentAndInstall(req.AgentID)
if err != nil {
return err
}
if err := ensureContainerRunning(install.ContainerName); err != nil {
return err
}
conf, err := readOpenclawConfig(agent.ConfigPath)
if err != nil {
return err
}
skillKey, err := getOpenclawSkillKey(install.ContainerName, req.Name)
if err != nil {
return err
}
setOpenclawSkillEnabled(conf, skillKey, req.Enabled)
return writeOpenclawConfigRaw(agent.ConfigPath, conf)
}
func (a AgentService) InstallSkill(req dto.AgentSkillInstallReq) error {
_, install, err := a.loadOpenclawAgentAndInstall(req.AgentID)
if err != nil {
return err
}
if err := ensureContainerRunning(install.ContainerName); err != nil {
return err
}
installTask, err := task.NewTaskWithOps(req.Slug, task.TaskInstall, task.TaskScopeAI, req.TaskID, req.AgentID)
if err != nil {
return err
}
installTask.AddSubTask("Install OpenClaw skill", func(t *task.Task) error {
mgr := cmd.NewCommandMgr(cmd.WithTask(*t), cmd.WithContext(t.TaskCtx), cmd.WithTimeout(20*time.Minute))
return mgr.Run("docker", "exec", install.ContainerName, "sh", "-c", buildOpenclawSkillInstallCommand(req.Source, req.Slug))
}, nil)
go func() {
if err := installTask.Execute(); err != nil {
global.LOG.Errorf("install openclaw skill failed: %v", err)
}
}()
return nil
}
func parseOpenclawSkillsList(output string) ([]dto.AgentSkillItem, error) {
payloadBytes, err := extractEmbeddedJSON(output)
if err != nil {
return nil, err
}
if len(payloadBytes) == 0 {
return nil, nil
}
var payload openclawSkillsList
if err := json.Unmarshal(payloadBytes, &payload); err != nil {
return nil, err
}
items := make([]dto.AgentSkillItem, 0, len(payload.Skills))
for _, item := range payload.Skills {
items = append(items, dto.AgentSkillItem{
Name: item.Name,
Description: item.Description,
Source: item.Source,
Bundled: item.Bundled,
Disabled: item.Disabled,
})
}
return items, nil
}
func loadOpenclawSkillSearchOutput(containerName, source, keyword string) (string, error) {
switch source {
case "skillhub":
return runDockerExecWithStdout(2*time.Minute, containerName, "skillhub", "search", keyword, "--json")
default:
return runDockerExecWithStdout(
2*time.Minute,
containerName,
"sh",
"-c",
fmt.Sprintf("CLAWHUB_REGISTRY=%q clawhub search %q", resolveClawhubRegistry(source), keyword),
)
}
}
func parseSkillhubSearchResult(output string) ([]dto.AgentSkillSearchItem, error) {
trimmed := strings.TrimSpace(output)
if trimmed == "" {
return nil, nil
}
var list []dto.AgentSkillSearchItem
if err := json.Unmarshal([]byte(trimmed), &list); err == nil {
for i := range list {
list[i].Source = "skillhub"
}
return list, nil
}
var payload skillhubSearchPayload
if err := json.Unmarshal([]byte(trimmed), &payload); err != nil {
return nil, err
}
items := payload.Skills
if len(items) == 0 {
items = payload.Results
}
for i := range items {
items[i].Source = "skillhub"
}
return items, nil
}
func parseClawhubSearchResult(output, source string) []dto.AgentSkillSearchItem {
lines := strings.Split(strings.TrimSpace(output), "\n")
items := make([]dto.AgentSkillSearchItem, 0, len(lines))
for _, line := range lines {
matches := clawhubSearchLinePattern.FindStringSubmatch(strings.TrimSpace(line))
if len(matches) != 4 {
continue
}
items = append(items, dto.AgentSkillSearchItem{
Slug: matches[1],
Name: matches[2],
Score: matches[3],
Source: source,
})
}
return items
}
func buildOpenclawSkillInstallCommand(source, slug string) string {
switch source {
case "clawhub-global", "clawhub-cn":
return fmt.Sprintf(
"mkdir -p %s && CLAWHUB_REGISTRY=%q clawhub --workdir /home/node/.openclaw --dir skills install %q",
openclawManagedSkillsDir,
resolveClawhubRegistry(source),
slug,
)
default:
return fmt.Sprintf(
"mkdir -p %s && skillhub --dir %s install %q",
openclawManagedSkillsDir,
openclawManagedSkillsDir,
slug,
)
}
}
func resolveClawhubRegistry(source string) string {
switch source {
case "clawhub-cn":
return clawhubChinaRegistry
default:
return clawhubGlobalRegistry
}
}
func getOpenclawSkillKey(containerName, name string) (string, error) {
output, err := runDockerExecWithStdout(2*time.Minute, containerName, "sh", "-c", fmt.Sprintf("openclaw skills info %q --json 2>&1", name))
if err != nil {
return "", err
}
return parseOpenclawSkillKey(name, output)
}
func parseOpenclawSkillKey(name, output string) (string, error) {
payloadBytes, err := extractEmbeddedJSON(output)
if err != nil {
return "", err
}
var payload openclawSkillInfo
if err := json.Unmarshal(payloadBytes, &payload); err != nil {
return "", err
}
if payload.SkillKey == "" {
return "", fmt.Errorf("skill %s does not have a skillKey", name)
}
return payload.SkillKey, nil
}
func extractEmbeddedJSON(output string) ([]byte, error) {
trimmed := strings.TrimSpace(ansiEscapePattern.ReplaceAllString(output, ""))
if trimmed == "" {
return nil, nil
}
for i := 0; i < len(trimmed); i++ {
if trimmed[i] != '{' && trimmed[i] != '[' {
continue
}
decoder := json.NewDecoder(strings.NewReader(trimmed[i:]))
var raw json.RawMessage
if err := decoder.Decode(&raw); err == nil {
return raw, nil
}
}
return nil, fmt.Errorf("json payload not found")
}
func setOpenclawSkillEnabled(conf map[string]interface{}, skillKey string, enabled bool) {
skills := ensureChildMap(conf, "skills")
entries := ensureChildMap(skills, "entries")
entry := ensureChildMap(entries, skillKey)
entry["enabled"] = enabled
}
File diff suppressed because it is too large Load Diff
+315
View File
@@ -0,0 +1,315 @@
package service
import (
"context"
"errors"
"fmt"
"net"
"sort"
"strings"
"github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/app/repo"
"github.com/1Panel-dev/1Panel/agent/buserr"
"github.com/1Panel-dev/1Panel/agent/constant"
"gorm.io/gorm"
)
func (a AgentService) BindWebsite(req dto.AgentWebsiteBindReq) error {
agent, err := agentRepo.GetFirst(repo.WithByID(req.AgentID))
if err != nil {
return err
}
if agent.WebsiteID != 0 {
return buserr.New("ErrAgentWebsiteBound")
}
website, err := websiteRepo.GetFirst(repo.WithByID(req.WebsiteID))
if err != nil {
return err
}
if !isBindableAgentWebsiteType(website.Type) {
return buserr.New("ErrAgentWebsiteTypeUnsupported")
}
boundAgent, err := agentRepo.GetFirst(repo.WithByWebsiteID(req.WebsiteID))
if err == nil && boundAgent.ID > 0 {
return buserr.New("ErrAgentWebsiteInUse")
}
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
agent.WebsiteID = req.WebsiteID
if err := agentRepo.Save(agent); err != nil {
return err
}
return ensureOpenclawWebsiteAllowedOrigin(agent, &website)
}
func hydrateAgentWebsiteItems(items []dto.AgentItem) error {
explicitWebsiteMap, err := loadAgentWebsiteMapByID(items)
if err != nil {
return err
}
websiteDomainMap, err := loadAgentWebsiteDomainMapByWebsiteID(items)
if err != nil {
return err
}
fillAgentWebsiteItems(items, explicitWebsiteMap, websiteDomainMap)
return nil
}
func loadAgentWebsiteMapByID(items []dto.AgentItem) (map[uint]model.Website, error) {
websiteIDs := make([]uint, 0, len(items))
for _, item := range items {
if item.WebsiteID > 0 {
websiteIDs = append(websiteIDs, item.WebsiteID)
}
}
if len(websiteIDs) == 0 {
return map[uint]model.Website{}, nil
}
websites, err := websiteRepo.GetBy(repo.WithByIDs(uniqueUintList(websiteIDs)))
if err != nil {
return nil, err
}
websiteMap := make(map[uint]model.Website, len(websites))
for _, website := range websites {
websiteMap[website.ID] = website
}
return websiteMap, nil
}
func loadAgentWebsiteDomainMapByWebsiteID(items []dto.AgentItem) (map[uint][]model.WebsiteDomain, error) {
websiteIDs := make([]uint, 0, len(items))
for _, item := range items {
if item.WebsiteID > 0 {
websiteIDs = append(websiteIDs, item.WebsiteID)
}
}
if len(websiteIDs) == 0 {
return map[uint][]model.WebsiteDomain{}, nil
}
websiteDomains, err := websiteDomainRepo.GetBy(websiteDomainRepo.WithWebsiteIds(uniqueUintList(websiteIDs)), repo.WithOrderAsc("id"))
if err != nil {
return nil, err
}
websiteDomainMap := make(map[uint][]model.WebsiteDomain, len(websiteDomains))
for _, websiteDomain := range websiteDomains {
websiteDomainMap[websiteDomain.WebsiteID] = append(websiteDomainMap[websiteDomain.WebsiteID], websiteDomain)
}
return websiteDomainMap, nil
}
func loadAgentWebsiteResourceName(website model.Website) (string, error) {
websiteDomains, err := websiteDomainRepo.GetBy(websiteDomainRepo.WithWebsiteId(website.ID), repo.WithOrderAsc("id"))
if err != nil {
return "", err
}
if len(websiteDomains) > 0 {
return websiteDomains[0].Domain, nil
}
if website.PrimaryDomain != "" {
return website.PrimaryDomain, nil
}
return fmt.Sprintf("%d", website.ID), nil
}
func fillAgentWebsiteItems(items []dto.AgentItem, explicitWebsiteMap map[uint]model.Website, websiteDomainMap map[uint][]model.WebsiteDomain) {
for index := range items {
if items[index].WebsiteID == 0 {
continue
}
website, ok := explicitWebsiteMap[items[index].WebsiteID]
if !ok {
items[index].WebsiteID = 0
items[index].WebsitePrimaryDomain = ""
items[index].WebsiteProtocol = ""
continue
}
items[index].WebsiteProtocol = website.Protocol
websiteDomains := websiteDomainMap[items[index].WebsiteID]
if len(websiteDomains) == 0 {
items[index].WebsitePrimaryDomain = ""
continue
}
items[index].WebsitePrimaryDomain = websiteDomains[0].Domain
}
}
func uniqueDeploymentWebsiteMapByAppInstall(websites []model.Website) map[uint]model.Website {
websiteMap := make(map[uint]model.Website)
duplicateAppInstallIDs := make(map[uint]struct{})
for _, website := range websites {
if website.AppInstallID == 0 {
continue
}
if _, duplicated := duplicateAppInstallIDs[website.AppInstallID]; duplicated {
continue
}
if _, exists := websiteMap[website.AppInstallID]; exists {
delete(websiteMap, website.AppInstallID)
duplicateAppInstallIDs[website.AppInstallID] = struct{}{}
continue
}
websiteMap[website.AppInstallID] = website
}
return websiteMap
}
func UniqueDeploymentWebsiteMapForMigration(websites []model.Website) map[uint]model.Website {
return uniqueDeploymentWebsiteMapByAppInstall(websites)
}
func uniqueUintList(items []uint) []uint {
itemMap := make(map[uint]struct{}, len(items))
uniq := make([]uint, 0, len(items))
for _, item := range items {
if item == 0 {
continue
}
if _, exists := itemMap[item]; exists {
continue
}
itemMap[item] = struct{}{}
uniq = append(uniq, item)
}
return uniq
}
func isBindableAgentWebsiteType(websiteType string) bool {
return websiteType == constant.Proxy || websiteType == constant.Static
}
func bindDeploymentWebsiteToAgentByAppInstall(website *model.Website) error {
if website.ID == 0 || website.Type != constant.Deployment || website.AppInstallID == 0 {
return nil
}
appInstall, err := appInstallRepo.GetFirst(repo.WithByID(website.AppInstallID))
if err != nil {
return err
}
if appInstall.App.Key != constant.AppOpenclaw && appInstall.App.Key != constant.AppCopaw {
return nil
}
agent, err := agentRepo.GetFirst(repo.WithByAppInstallID(website.AppInstallID))
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil
}
if err != nil {
return err
}
if agent.WebsiteID != 0 {
return nil
}
agent.WebsiteID = website.ID
if err := agentRepo.Save(agent); err != nil {
return err
}
return ensureOpenclawWebsiteAllowedOrigin(agent, website)
}
func ensureOpenclawWebsiteAllowedOrigin(agent *model.Agent, website *model.Website) error {
if agent == nil || website == nil || agent.AgentType == constant.AppCopaw {
return nil
}
origins, err := buildWebsiteAllowedOrigins(website)
if err != nil {
return err
}
if len(origins) == 0 {
return nil
}
install, err := appInstallRepo.GetFirst(repo.WithByID(agent.AppInstallID))
if err != nil {
return err
}
conf, err := readOpenclawConfig(agent.ConfigPath)
if err != nil {
return err
}
allowedOrigins := extractSecurityConfig(conf).AllowedOrigins
allowedOrigins, err = normalizeAllowedOrigins(append(allowedOrigins, origins...))
if err != nil {
return err
}
setSecurityConfig(conf, dto.AgentSecurityConfig{AllowedOrigins: allowedOrigins})
if err := writeOpenclawConfigRaw(agent.ConfigPath, conf); err != nil {
return err
}
if err := syncOpenclawAllowedOriginEnv(&install, allowedOrigins); err != nil {
return err
}
return appInstallRepo.Save(context.Background(), &install)
}
func buildWebsiteAllowedOrigins(website *model.Website) ([]string, error) {
websiteDomains, err := websiteDomainRepo.GetBy(websiteDomainRepo.WithWebsiteId(website.ID), repo.WithOrderAsc("id"))
if err != nil {
return nil, err
}
if len(websiteDomains) == 0 {
return nil, nil
}
defaultHTTPSPort := 443
if strings.EqualFold(website.Protocol, "https") {
nginxInstall, err := getAppInstallByKey(constant.AppOpenresty)
if err == nil && nginxInstall.HttpsPort > 0 {
defaultHTTPSPort = nginxInstall.HttpsPort
}
}
return buildWebsiteAllowedOriginsFromDomains(website, websiteDomains, defaultHTTPSPort)
}
func buildWebsiteAllowedOriginsFromDomains(website *model.Website, websiteDomains []model.WebsiteDomain, defaultHTTPSPort int) ([]string, error) {
if len(websiteDomains) == 0 {
return nil, nil
}
sort.Slice(websiteDomains, func(i, j int) bool {
return websiteDomains[i].ID < websiteDomains[j].ID
})
origins := make([]string, 0, len(websiteDomains))
for _, websiteDomain := range websiteDomains {
origin, err := buildWebsiteDomainOrigin(website.Protocol, websiteDomain, defaultHTTPSPort)
if err != nil {
return nil, err
}
origins = append(origins, origin)
}
return origins, nil
}
func buildWebsiteDomainOrigin(protocol string, websiteDomain model.WebsiteDomain, defaultHTTPSPort int) (string, error) {
origin := strings.ToLower(strings.TrimSpace(protocol)) + "://" + formatWebsiteDomainHost(websiteDomain.Domain)
switch strings.ToLower(strings.TrimSpace(protocol)) {
case "http":
if websiteDomain.Port > 0 && websiteDomain.Port != 80 {
origin = fmt.Sprintf("%s:%d", origin, websiteDomain.Port)
}
case "https":
port := websiteDomain.Port
if !websiteDomain.SSL {
port = defaultHTTPSPort
}
if port > 0 && port != 443 {
origin = fmt.Sprintf("%s:%d", origin, port)
}
}
return normalizeAllowedOrigin(origin)
}
func formatWebsiteDomainHost(domain string) string {
host := strings.Trim(strings.TrimSpace(domain), "[]")
if ip := net.ParseIP(host); ip != nil && strings.Contains(host, ":") {
return "[" + host + "]"
}
return host
}
+130 -4
View File
@@ -1,11 +1,14 @@
package service
import (
"bytes"
"context"
"fmt"
"os"
"os/exec"
"path"
"strings"
"syscall"
"time"
"github.com/1Panel-dev/1Panel/agent/app/dto"
@@ -19,6 +22,7 @@ import (
"github.com/1Panel-dev/1Panel/agent/i18n"
"github.com/1Panel-dev/1Panel/agent/utils/cmd"
"github.com/1Panel-dev/1Panel/agent/utils/common"
"github.com/1Panel-dev/1Panel/agent/utils/re"
"github.com/jinzhu/copier"
)
@@ -107,8 +111,7 @@ func (u *AIToolService) Create(req dto.OllamaModelName) error {
}
go func() {
taskItem.AddSubTask(i18n.GetWithName("OllamaModelPull", req.Name), func(t *task.Task) error {
cmdMgr := cmd.NewCommandMgr(cmd.WithTask(*taskItem), cmd.WithTimeout(time.Hour))
return cmdMgr.Run("docker", "exec", containerName, "ollama", "pull", info.Name)
return runOllamaPullWithProcess(t, containerName, info.Name)
}, nil)
taskItem.AddSubTask(i18n.GetWithName("OllamaModelSize", req.Name), func(t *task.Task) error {
itemSize, err := loadModelSize(info.Name, containerName)
@@ -162,8 +165,7 @@ func (u *AIToolService) Recreate(req dto.OllamaModelName) error {
}
go func() {
taskItem.AddSubTask(i18n.GetWithName("OllamaModelPull", req.Name), func(t *task.Task) error {
cmdMgr := cmd.NewCommandMgr(cmd.WithTask(*taskItem), cmd.WithTimeout(time.Hour))
return cmdMgr.Run("docker", "exec", containerName, "ollama", "pull", req.Name)
return runOllamaPullWithProcess(t, containerName, req.Name)
}, nil)
taskItem.AddSubTask(i18n.GetWithName("OllamaModelSize", req.Name), func(t *task.Task) error {
itemSize, err := loadModelSize(modelInfo.Name, containerName)
@@ -394,3 +396,127 @@ func loadModelSize(name string, containerName string) (string, error) {
}
return "", fmt.Errorf("no such model %s in ollama list, std: %s", name, stdout)
}
func runOllamaPullWithProcess(taskItem *task.Task, containerName, modelName string) error {
ctx, cancel := context.WithTimeout(context.Background(), time.Hour)
defer cancel()
cmdItem := exec.CommandContext(ctx, "docker", "exec", containerName, "ollama", "pull", modelName)
cmdItem.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
writer := &ollamaPullLogWriter{taskItem: taskItem}
cmdItem.Stdout = writer
cmdItem.Stderr = writer
if err := cmdItem.Start(); err != nil {
return fmt.Errorf("failed to start ollama pull %s: %w", modelName, err)
}
waitErr := cmdItem.Wait()
writer.Flush()
if ctx.Err() == context.DeadlineExceeded {
if cmdItem.Process != nil && cmdItem.Process.Pid > 0 {
_ = syscall.Kill(-cmdItem.Process.Pid, syscall.SIGKILL)
}
return buserr.New("ErrCmdTimeout")
}
if waitErr == nil {
return nil
}
if len(strings.TrimSpace(writer.errBuf.String())) > 0 {
return fmt.Errorf("%s", strings.TrimSpace(writer.errBuf.String()))
}
return waitErr
}
type ollamaPullLogWriter struct {
taskItem *task.Task
errBuf bytes.Buffer
}
func (w *ollamaPullLogWriter) Write(p []byte) (n int, err error) {
w.errBuf.Write(p)
for _, segment := range splitOllamaPullSegments(string(p)) {
w.logLine(segment)
}
return len(p), nil
}
func (w *ollamaPullLogWriter) Flush() {}
func (w *ollamaPullLogWriter) logLine(line string) {
if w == nil || w.taskItem == nil {
return
}
line = sanitizeOllamaPullLine(line)
if line == "" {
return
}
if prefix := resolveOllamaPullLogPrefix(line); prefix != "" {
_ = replaceOllamaPullLogLine(prefix, line, w.taskItem)
return
}
w.taskItem.Log(line)
}
func sanitizeOllamaPullLine(line string) string {
line = re.StripAnsiControlSeq(line)
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "pulling manifes") {
return ""
}
return line
}
func splitOllamaPullSegments(chunk string) []string {
if chunk == "" {
return nil
}
chunk = strings.ReplaceAll(chunk, "\r", "\n")
parts := strings.Split(chunk, "\x1b[1G")
segments := make([]string, 0, len(parts))
for _, part := range parts {
for _, line := range strings.Split(part, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
segments = append(segments, line)
}
}
return segments
}
func resolveOllamaPullLogPrefix(line string) string {
if strings.HasPrefix(line, "pulling ") {
if idx := strings.Index(line, ":"); idx > 0 {
return strings.TrimSpace(line[:idx])
}
fields := strings.Fields(line)
if len(fields) >= 2 {
return strings.TrimSpace(fields[0] + " " + fields[1])
}
}
return ""
}
func replaceOllamaPullLogLine(prefix, newLine string, taskItem *task.Task) error {
if taskItem == nil || taskItem.Task == nil {
return nil
}
data, err := os.ReadFile(taskItem.Task.LogFile)
if err != nil {
return err
}
lines := strings.Split(string(data), "\n")
for idx, line := range lines {
trimmed := strings.TrimSpace(line)
if trimmed == "" {
continue
}
if strings.Contains(trimmed, prefix) {
lines[idx] = time.Now().Format("2006/01/02 15:04:05") + " " + newLine
return os.WriteFile(taskItem.Task.LogFile, []byte(strings.Join(lines, "\n")), os.ModePerm)
}
}
taskItem.Log(newLine)
return nil
}
+5 -3
View File
@@ -15,6 +15,7 @@ import (
"github.com/1Panel-dev/1Panel/agent/utils/email"
"github.com/1Panel-dev/1Panel/agent/utils/xpack"
"github.com/shirou/gopsutil/v4/disk"
"mime"
"sort"
"strings"
"sync"
@@ -60,7 +61,7 @@ func (a AlertService) PageAlert(search dto.AlertSearch) (int64, []dto.AlertDTO,
if search.Type != "" {
opts = append(opts, alertRepo.WithByType(search.Type))
}
opts = append(opts, repo.WithOrderBy("created_at desc"))
opts = append(opts, repo.WithOrderDesc("created_at"))
total, alerts, err := alertRepo.Page(search.Page, search.PageSize, opts...)
if err != nil {
@@ -343,7 +344,7 @@ func (a AlertService) PageAlertLogs(search dto.AlertLogSearch) (int64, []dto.Ale
if search.Count != 0 {
opts = append(opts, alertRepo.WithByCount(search.Count))
}
opts = append(opts, repo.WithOrderBy("created_at desc"))
opts = append(opts, repo.WithOrderDesc("created_at"))
total, alerts, err := alertRepo.PageLog(search.Page, search.PageSize, opts...)
if err != nil {
@@ -483,13 +484,14 @@ func (a AlertService) TestAlertConfig(req dto.AlertConfigTest) (bool, error) {
if username == "" {
username = req.Sender
}
encodedDisplayName := mime.BEncoding.Encode("UTF-8", req.DisplayName)
cfg := email.SMTPConfig{
Host: req.Host,
Port: req.Port,
Sender: req.Sender,
Username: username,
Password: req.Password,
From: fmt.Sprintf(`"%s" <%s>`, req.DisplayName, req.Sender),
From: fmt.Sprintf(`"%s" <%s>`, encodedDisplayName, req.Sender),
Encryption: req.Encryption,
Recipient: req.Recipient,
}
+134 -54
View File
@@ -2,6 +2,13 @@ package service
import (
"encoding/json"
"fmt"
"math"
"sort"
"strconv"
"strings"
"time"
"github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/app/repo"
@@ -17,11 +24,6 @@ import (
"github.com/shirou/gopsutil/v4/load"
"github.com/shirou/gopsutil/v4/mem"
"github.com/shirou/gopsutil/v4/net"
"math"
"sort"
"strconv"
"strings"
"time"
)
const (
@@ -110,7 +112,7 @@ func (m *AlertTaskHelper) getClassifiedAlerts() (baseAlerts, resourceAlerts []dt
func handleBaseAlerts(baseAlerts []dto.AlertDTO) {
if len(baseAlerts) == 0 {
stopResourceJob()
stopBaseJob()
return
}
if global.AlertBaseJobID == 0 {
@@ -227,11 +229,30 @@ func loadSSLInfo(alert dto.AlertDTO) {
return
}
sender := NewAlertSender(alert, projectJSON)
minDays := math.MaxInt
maxDays := 0
allDomains := make([]string, 0)
for daysDiff, domains := range daysDiffMap {
domainStr := strings.Join(domains, ",")
params := createAlertBaseParams(strconv.Itoa(len(domains)), strconv.Itoa(daysDiff))
sender.Send(domainStr, params)
allDomains = append(allDomains, domains...)
if daysDiff < minDays {
minDays = daysDiff
}
if daysDiff > maxDays {
maxDays = daysDiff
}
}
if len(allDomains) == 0 {
return
}
var daysStr string
if len(allDomains) == 1 {
daysStr = strconv.Itoa(minDays)
} else {
daysStr = strconv.Itoa(minDays) + "-" + strconv.Itoa(maxDays)
}
domainStr := strings.Join(allDomains, ",")
params := createAlertBaseParams(strconv.Itoa(len(daysDiffMap)), daysStr)
sender.Send(domainStr, params)
}
func loadWebsiteInfo(alert dto.AlertDTO) {
@@ -247,11 +268,30 @@ func loadWebsiteInfo(alert dto.AlertDTO) {
return
}
sender := NewAlertSender(alert, projectJSON)
minDays := math.MaxInt
maxDays := 0
allDomains := make([]string, 0)
for daysDiff, domains := range daysDiffMap {
domainStr := strings.Join(domains, ",")
params := createAlertBaseParams(strconv.Itoa(len(domains)), strconv.Itoa(daysDiff))
sender.Send(domainStr, params)
allDomains = append(allDomains, domains...)
if daysDiff < minDays {
minDays = daysDiff
}
if daysDiff > maxDays {
maxDays = daysDiff
}
}
if len(allDomains) == 0 {
return
}
var daysStr string
if len(allDomains) == 1 {
daysStr = strconv.Itoa(minDays)
} else {
daysStr = strconv.Itoa(minDays) + "-" + strconv.Itoa(maxDays)
}
domainStr := strings.Join(allDomains, ",")
params := createAlertBaseParams(strconv.Itoa(len(daysDiffMap)), daysStr)
sender.Send(domainStr, params)
}
func loadPanelPwd(alert dto.AlertDTO) {
@@ -384,25 +424,19 @@ func loadDiskUsage(alert dto.AlertDTO) {
}
if isAlertDue(newDate) {
if strings.Contains(alert.Project, "all") {
err = processAllDisks(alert)
_ = processAllDisks(alert)
} else {
err = processSingleDisk(alert)
_ = processSingleDisk(alert)
}
}
}
func loadPanelLogin(alert dto.AlertDTO) {
count, isAlert, err := alertUtil.CountRecentFailedLoginLogs(alert.Cycle, alert.Count)
alertType := alert.Type
quota := strconv.Itoa(count)
quotaType := strconv.Itoa(int(alert.Cycle))
if err != nil {
global.LOG.Errorf("Failed to count recent failed login logs: %v", err)
}
if isAlert {
alertType = "panelLogin"
quota = strconv.Itoa(count)
quotaType = "panelLogin"
params := []dto.Param{
{
Index: "1",
@@ -415,7 +449,7 @@ func loadPanelLogin(alert dto.AlertDTO) {
Value: "",
},
}
sendAlerts(alert, alertType, quota, quotaType, params)
sendAlerts(alert, "panelLogin", strconv.Itoa(count), "panelLogin", params)
}
whitelist := strings.Split(strings.TrimSpace(alert.AdvancedParams), "\n")
@@ -424,15 +458,13 @@ func loadPanelLogin(alert dto.AlertDTO) {
global.LOG.Errorf("Failed to check recent failed ip login logs: %v", err)
}
if len(records) > 0 {
quota = strings.Join(func() []string {
quota := strings.Join(func() []string {
var ips []string
for _, r := range records {
ips = append(ips, r.IP)
}
return ips
}(), "\n")
alertType = "panelIpLogin"
quotaType = "panelIpLogin"
params := []dto.Param{
{
Index: "1",
@@ -445,22 +477,16 @@ func loadPanelLogin(alert dto.AlertDTO) {
Value: " IP ",
},
}
sendAlerts(alert, alertType, quota, quotaType, params)
sendAlerts(alert, "panelIpLogin", quota, "panelIpLogin", params)
}
}
func loadSSHLogin(alert dto.AlertDTO) {
count, isAlert, err := alertUtil.CountRecentFailedSSHLog(alert.Cycle, alert.Count)
alertType := alert.Type
quota := strconv.Itoa(count)
quotaType := strconv.Itoa(int(alert.Cycle))
if err != nil {
global.LOG.Errorf("Failed to count recent failed ssh login logs: %v", err)
}
if isAlert {
alertType = "sshLogin"
quota = strconv.Itoa(count)
quotaType = "sshLogin"
params := []dto.Param{
{
Index: "1",
@@ -473,7 +499,7 @@ func loadSSHLogin(alert dto.AlertDTO) {
Value: "",
},
}
sendAlerts(alert, alertType, quota, quotaType, params)
sendAlerts(alert, "sshLogin", strconv.Itoa(count), "sshLogin", params)
}
whitelist := strings.Split(strings.TrimSpace(alert.AdvancedParams), "\n")
records, err := alertUtil.FindRecentSuccessLoginNotInWhitelist(30, whitelist)
@@ -481,9 +507,7 @@ func loadSSHLogin(alert dto.AlertDTO) {
global.LOG.Errorf("Failed to check recent failed ip ssh login logs: %v", err)
}
if len(records) > 0 {
quota = strings.Join(records, "\n")
alertType = "sshIpLogin"
quotaType = "sshIpLogin"
quota := strings.Join(records, "\n")
params := []dto.Param{
{
Index: "1",
@@ -496,7 +520,7 @@ func loadSSHLogin(alert dto.AlertDTO) {
Value: " IP ",
},
}
sendAlerts(alert, alertType, quota, quotaType, params)
sendAlerts(alert, "sshIpLogin", quota, "sshIpLogin", params)
}
}
@@ -580,10 +604,12 @@ func sendAlerts(alert dto.AlertDTO, alertType, quota, quotaType string, params [
AlertId: alert.ID,
Count: todayCount + 1,
}
_ = xpack.CreateSMSAlertLog(alertType, alert, create, quotaType, params, constant.SMS)
alertErr := xpack.CreateSMSAlertLog(alertType, alert, create, quotaType, params, constant.SMS)
if alertErr != nil {
global.LOG.Infof("%s alert sms push faild, err: %v", alertType, alertErr.Error())
continue
}
alertUtil.CreateNewAlertTask(quota, alertType, quotaType, constant.SMS)
global.LOG.Infof("%s alert sms push successful", alertType)
case constant.Email:
todayCount, isValid := canSendAlertToday(alertType, quotaType, alert.SendCount, constant.Email)
if !isValid {
@@ -599,9 +625,54 @@ func sendAlerts(alert dto.AlertDTO, alertType, quota, quotaType string, params [
create.AlertRule = alertUtil.ProcessAlertRule(alert)
create.AlertDetail = alertUtil.ProcessAlertDetail(alertInfo, quotaType, params, constant.Email)
transport := xpack.LoadRequestTransport()
_ = alertUtil.CreateEmailAlertLog(create, alertInfo, params, transport)
agentInfo, _ := xpack.GetAgentInfo()
alertErr := alertUtil.CreateEmailAlertLog(create, alertInfo, params, transport, agentInfo)
if alertErr != nil {
global.LOG.Infof("%s alert email push faild, err: %v", alertType, alertErr.Error())
continue
}
alertUtil.CreateNewAlertTask(quota, alertType, quotaType, constant.Email)
global.LOG.Infof("%s alert email push successful", alertType)
case constant.WeCom, constant.DingTalk, constant.FeiShu:
todayCount, isValid := canSendAlertToday(alertType, quotaType, alert.SendCount, m)
if !isValid {
continue
}
var create = dto.AlertLogCreate{
Type: alertUtil.GetCronJobType(alert.Type),
AlertId: alert.ID,
Count: todayCount + 1,
}
transport := xpack.LoadRequestTransport()
agentInfo, _ := xpack.GetAgentInfo()
err := xpack.CreateWebhookAlertLog(alertType, alert, create, quotaType, params, m, transport, agentInfo)
if err != nil {
global.LOG.Infof("%s alert webhook %s push faild, err: %v", alertType, m, err)
continue
}
alertUtil.CreateNewAlertTask(quota, alertType, quotaType, m)
case constant.Bark:
todayCount, isValid := canSendAlertToday(alertType, quotaType, alert.SendCount, m)
if !isValid {
continue
}
var create = dto.AlertLogCreate{
Type: alertUtil.GetCronJobType(alert.Type),
AlertId: alert.ID,
Count: todayCount + 1,
}
alertInfo := alert
alertInfo.Type = alertType
create.AlertRule = alertUtil.ProcessAlertRule(alert)
create.AlertDetail = alertUtil.ProcessAlertDetail(alertInfo, quotaType, params, m)
transport := xpack.LoadRequestTransport()
agentInfo, _ := xpack.GetAgentInfo()
alertErr := alertUtil.CreateBarkAlertLog(create, alertInfo, params, transport, agentInfo)
if alertErr != nil {
global.LOG.Infof("%s alert %s push failed, err: %v", alertType, m, alertErr.Error())
continue
}
alertUtil.CreateNewAlertTask(quota, alertType, quotaType, m)
default:
}
}
}
@@ -641,7 +712,9 @@ func calculateSSLExpiryDays(sslList []model.WebsiteSSL, cycle uint) (map[int][]s
projectMap := make(map[uint][]time.Time)
for _, ssl := range sslList {
daysDiff := int(ssl.ExpireDate.Sub(currentDate).Hours() / 24)
daysDiff := int(math.Ceil(
ssl.ExpireDate.Sub(currentDate).Hours() / 24,
))
if daysDiff > 0 && int(cycle) >= daysDiff {
daysDiffMap[daysDiff] = append(daysDiffMap[daysDiff], ssl.PrimaryDomain)
projectMap[ssl.ID] = append(projectMap[ssl.ID], ssl.ExpireDate)
@@ -656,7 +729,9 @@ func calculateWebsiteExpiryDays(websites []model.Website, cycle uint) (map[int][
projectMap := make(map[uint][]time.Time)
for _, website := range websites {
daysDiff := int(website.ExpireDate.Sub(currentDate).Hours() / 24)
daysDiff := int(math.Ceil(
website.ExpireDate.Sub(currentDate).Hours() / 24,
))
if daysDiff > 0 && int(cycle) >= daysDiff {
daysDiffMap[daysDiff] = append(daysDiffMap[daysDiff], website.PrimaryDomain)
projectMap[website.ID] = append(projectMap[website.ID], website.ExpireDate)
@@ -820,30 +895,36 @@ func processAllDisks(alert dto.AlertDTO) error {
global.LOG.Errorf("error getting disk list, err: %v", err)
return err
}
var errMsgs []string
for _, item := range diskList {
if success, err := checkAndCreateDiskAlert(alert, item.Path); err == nil && success {
global.LOG.Infof("disk alert pushed successfully for %s", item.Path)
err := checkAndCreateDiskAlert(alert, item.Path)
if err != nil {
errMsg := fmt.Sprintf("disk path %s process failed: %v", item.Path, err)
errMsgs = append(errMsgs, errMsg)
global.LOG.Errorf("%s", errMsg)
continue
}
}
if len(errMsgs) > 0 {
return fmt.Errorf("batch process disks failed, error count: %d, details: %s", len(errMsgs), strings.Join(errMsgs, "; "))
}
return nil
}
func processSingleDisk(alert dto.AlertDTO) error {
success, err := checkAndCreateDiskAlert(alert, alert.Project)
err := checkAndCreateDiskAlert(alert, alert.Project)
if err != nil {
global.LOG.Errorf("%s", err.Error())
return err
}
if success {
global.LOG.Infof("disk alert pushed successfully for %s", alert.Project)
}
return nil
}
func checkAndCreateDiskAlert(alert dto.AlertDTO, path string) (bool, error) {
func checkAndCreateDiskAlert(alert dto.AlertDTO, path string) error {
usageStat, err := psutil.DISK.GetUsage(path, false)
if err != nil {
global.LOG.Errorf("error getting disk usage for %s, err: %v", path, err)
return false, err
return err
}
usedTotal, usedStr := calculateUsedTotal(alert.Cycle, usageStat)
@@ -852,13 +933,12 @@ func checkAndCreateDiskAlert(alert dto.AlertDTO, path string) (bool, error) {
commonTotal *= 1024 * 1024 * 1024
}
if usedTotal < commonTotal {
return false, nil
return nil
}
global.LOG.Infof("disk「 %s 」usage: %s", path, usedStr)
params := createAlertDiskParams(path, usedStr)
sender := NewAlertSender(alert, alert.Project)
sender.ResourceSend(path, params)
return true, nil
return nil
}
func calculateUsedTotal(cycle uint, usageStat *disk.UsageStat) (float64, string) {
+113 -7
View File
@@ -30,6 +30,10 @@ func (s *AlertSender) Send(quota string, params []dto.Param) {
s.sendSMS(quota, params)
case constant.Email:
s.sendEmail(quota, params)
case constant.Bark:
s.sendBark(quota, params)
case constant.WeCom, constant.DingTalk, constant.FeiShu:
s.sendWebhook(quota, params, method)
}
}
}
@@ -43,6 +47,10 @@ func (s *AlertSender) ResourceSend(quota string, params []dto.Param) {
s.sendResourceSMS(quota, params)
case constant.Email:
s.sendResourceEmail(quota, params)
case constant.Bark:
s.sendResourceBark(quota, params)
case constant.WeCom, constant.DingTalk, constant.FeiShu:
s.sendResourceWebhook(quota, params, method)
}
}
}
@@ -64,9 +72,12 @@ func (s *AlertSender) sendSMS(quota string, params []dto.Param) {
Type: s.alert.Type,
}
_ = xpack.CreateSMSAlertLog(s.alert.Type, s.alert, create, quota, params, constant.SMS)
err := xpack.CreateSMSAlertLog(s.alert.Type, s.alert, create, quota, params, constant.SMS)
if err != nil {
global.LOG.Errorf("%s alert sms push failed: %v", s.alert.Type, err)
return
}
alertUtil.CreateNewAlertTask(quota, s.alert.Type, s.quotaType, constant.SMS)
global.LOG.Infof("%s alert sms push successful", s.alert.Type)
}
func (s *AlertSender) sendEmail(quota string, params []dto.Param) {
@@ -85,9 +96,60 @@ func (s *AlertSender) sendEmail(quota string, params []dto.Param) {
}
transport := xpack.LoadRequestTransport()
_ = alertUtil.CreateEmailAlertLog(create, s.alert, params, transport)
agentInfo, _ := xpack.GetAgentInfo()
err := alertUtil.CreateEmailAlertLog(create, s.alert, params, transport, agentInfo)
if err != nil {
global.LOG.Errorf("%s alert email push failed: %v", s.alert.Type, err)
return
}
alertUtil.CreateNewAlertTask(quota, s.alert.Type, s.quotaType, constant.Email)
global.LOG.Infof("%s alert email push successful", s.alert.Type)
}
func (s *AlertSender) sendBark(quota string, params []dto.Param) {
totalCount, isValid := s.canSendAlert(constant.Bark)
if !isValid {
return
}
create := dto.AlertLogCreate{
Status: constant.AlertSuccess,
Count: totalCount + 1,
AlertId: s.alert.ID,
Type: s.alert.Type,
AlertRule: alertUtil.ProcessAlertRule(s.alert),
AlertDetail: alertUtil.ProcessAlertDetail(s.alert, quota, params, constant.Bark),
}
transport := xpack.LoadRequestTransport()
agentInfo, _ := xpack.GetAgentInfo()
err := alertUtil.CreateBarkAlertLog(create, s.alert, params, transport, agentInfo)
if err != nil {
global.LOG.Errorf("%s alert bark push failed: %v", s.alert.Type, err)
return
}
alertUtil.CreateNewAlertTask(quota, s.alert.Type, s.quotaType, constant.Bark)
}
func (s *AlertSender) sendWebhook(quota string, params []dto.Param, method string) {
totalCount, isValid := s.canSendAlert(method)
if !isValid {
return
}
create := dto.AlertLogCreate{
Status: constant.AlertSuccess,
Count: totalCount + 1,
AlertId: s.alert.ID,
Type: s.alert.Type,
}
transport := xpack.LoadRequestTransport()
agentInfo, _ := xpack.GetAgentInfo()
err := xpack.CreateWebhookAlertLog(s.alert.Type, s.alert, create, quota, params, method, transport, agentInfo)
if err != nil {
global.LOG.Errorf("%s alert %s webhook push failed: %v", s.alert.Type, method, err)
return
}
alertUtil.CreateNewAlertTask(quota, s.alert.Type, s.quotaType, method)
}
func (s *AlertSender) sendResourceSMS(quota string, params []dto.Param) {
@@ -112,7 +174,6 @@ func (s *AlertSender) sendResourceSMS(quota string, params []dto.Param) {
return
}
alertUtil.CreateNewAlertTask(quota, s.alert.Type, s.quotaType, constant.SMS)
global.LOG.Infof("%s alert sms push successful", s.alert.Type)
}
func (s *AlertSender) sendResourceEmail(quota string, params []dto.Param) {
@@ -131,12 +192,57 @@ func (s *AlertSender) sendResourceEmail(quota string, params []dto.Param) {
}
transport := xpack.LoadRequestTransport()
if err := alertUtil.CreateEmailAlertLog(create, s.alert, params, transport); err != nil {
agentInfo, _ := xpack.GetAgentInfo()
if err := alertUtil.CreateEmailAlertLog(create, s.alert, params, transport, agentInfo); err != nil {
global.LOG.Errorf("failed to send Email alert: %v", err)
return
}
alertUtil.CreateNewAlertTask(quota, s.alert.Type, s.quotaType, constant.Email)
global.LOG.Infof("%s alert email push successful", s.alert.Type)
}
func (s *AlertSender) sendResourceBark(quota string, params []dto.Param) {
todayCount, isValid := s.canResourceSendAlert(constant.Bark)
if !isValid {
return
}
create := dto.AlertLogCreate{
Status: constant.AlertSuccess,
Count: todayCount + 1,
AlertId: s.alert.ID,
Type: s.alert.Type,
AlertRule: alertUtil.ProcessAlertRule(s.alert),
AlertDetail: alertUtil.ProcessAlertDetail(s.alert, quota, params, constant.Bark),
}
transport := xpack.LoadRequestTransport()
agentInfo, _ := xpack.GetAgentInfo()
if err := alertUtil.CreateBarkAlertLog(create, s.alert, params, transport, agentInfo); err != nil {
global.LOG.Errorf("failed to send Bark alert: %v", err)
return
}
alertUtil.CreateNewAlertTask(quota, s.alert.Type, s.quotaType, constant.Bark)
}
func (s *AlertSender) sendResourceWebhook(quota string, params []dto.Param, method string) {
todayCount, isValid := s.canResourceSendAlert(method)
if !isValid {
return
}
create := dto.AlertLogCreate{
Status: constant.AlertSuccess,
Count: todayCount + 1,
AlertId: s.alert.ID,
Type: s.alert.Type,
}
transport := xpack.LoadRequestTransport()
agentInfo, _ := xpack.GetAgentInfo()
if err := xpack.CreateWebhookAlertLog(s.alert.Type, s.alert, create, quota, params, method, transport, agentInfo); err != nil {
global.LOG.Errorf("%s alert %s webhook push failed: %v", s.alert.Type, method, err)
return
}
alertUtil.CreateNewAlertTask(quota, s.alert.Type, s.quotaType, method)
}
func (s *AlertSender) canSendAlert(method string) (uint, bool) {
+151 -266
View File
@@ -12,6 +12,7 @@ import (
"reflect"
"strconv"
"strings"
"sync"
"github.com/gin-gonic/gin"
@@ -25,6 +26,7 @@ import (
"github.com/1Panel-dev/1Panel/agent/constant"
"github.com/1Panel-dev/1Panel/agent/global"
"github.com/1Panel-dev/1Panel/agent/i18n"
"github.com/1Panel-dev/1Panel/agent/utils/appicon"
"github.com/1Panel-dev/1Panel/agent/utils/common"
"github.com/1Panel-dev/1Panel/agent/utils/docker"
"github.com/1Panel-dev/1Panel/agent/utils/files"
@@ -33,6 +35,11 @@ import (
"gopkg.in/yaml.v3"
)
var (
appStoreSyncMu sync.Mutex
appStoreSyncing bool
)
type AppService struct {
}
@@ -41,12 +48,17 @@ type IAppService interface {
GetAppTags(ctx *gin.Context) ([]response.TagDTO, error)
GetApp(ctx *gin.Context, key string) (*response.AppDTO, error)
GetAppDetail(appId uint, version, appType string) (response.AppDetailDTO, error)
Install(req request.AppInstallCreate) (*model.AppInstall, error)
Install(req request.AppInstallCreate, executeScript bool) (*model.AppInstall, error)
SyncAppListFromRemote(taskID string) error
GetAppUpdate() (*response.AppUpdateRes, error)
GetAppDetailByID(id uint) (*response.AppDetailDTO, error)
SyncAppListFromLocal(taskID string)
GetAppIcon(appID uint) ([]byte, error)
GetAppIcon(key string) ([]byte, string, string, error)
GetAppDetailByKey(appKey, version string) (response.AppDetailSimpleDTO, error)
}
type appInstallHooks struct {
AfterCopyData func(appInstall *model.AppInstall) error
}
func NewIAppService() IAppService {
@@ -118,14 +130,15 @@ func (a AppService) PageApp(ctx *gin.Context, req request.AppSearch) (*response.
}
}
appDTO := &response.AppItem{
ID: ap.ID,
Name: ap.Name,
Key: ap.Key,
Limit: ap.Limit,
GpuSupport: ap.GpuSupport,
Recommend: ap.Recommend,
Description: ap.GetDescription(ctx),
Type: ap.Type,
ID: ap.ID,
Name: ap.Name,
Key: ap.Key,
Limit: ap.Limit,
GpuSupport: ap.GpuSupport,
Recommend: ap.Recommend,
Description: ap.GetDescription(ctx),
Type: ap.Type,
BatchInstallSupport: ap.BatchInstallSupport,
}
appDTOs = append(appDTOs, appDTO)
tags, err := getAppTags(ap.ID, lang)
@@ -203,6 +216,20 @@ func (a AppService) GetApp(ctx *gin.Context, key string) (*response.AppDTO, erro
return &appDTO, nil
}
func (a AppService) GetAppDetailByKey(appKey, version string) (response.AppDetailSimpleDTO, error) {
var appDetailDTO response.AppDetailSimpleDTO
app, err := appRepo.GetFirst(appRepo.WithKey(appKey))
if err != nil {
return appDetailDTO, err
}
appDetail, err := appDetailRepo.GetFirst(appDetailRepo.WithAppId(app.ID), appDetailRepo.WithVersion(version))
if err != nil {
return appDetailDTO, err
}
appDetailDTO.ID = appDetail.ID
return appDetailDTO, nil
}
func (a AppService) GetAppDetail(appID uint, version, appType string) (response.AppDetailDTO, error) {
var (
appDetailDTO response.AppDetailDTO
@@ -316,7 +343,11 @@ func (a AppService) GetAppDetailByID(id uint) (*response.AppDetailDTO, error) {
return res, nil
}
func (a AppService) Install(req request.AppInstallCreate) (appInstall *model.AppInstall, err error) {
func (a AppService) Install(req request.AppInstallCreate, executeScript bool) (appInstall *model.AppInstall, err error) {
return a.installWithHooks(req, executeScript, nil)
}
func (a AppService) installWithHooks(req request.AppInstallCreate, executeScript bool, hooks *appInstallHooks) (appInstall *model.AppInstall, err error) {
if err = docker.CreateDefaultDockerNetwork(); err != nil {
err = buserr.WithDetail("Err1PanelNetworkFailed", err.Error(), nil)
return
@@ -386,11 +417,21 @@ func (a AppService) Install(req request.AppInstallCreate) (appInstall *model.App
App: app,
}
composeMap := make(map[string]interface{})
var composeRes []byte
if req.EditCompose {
if err = yaml.Unmarshal([]byte(req.DockerCompose), &composeMap); err != nil {
return
}
} else {
if appDetail.DockerCompose == "" {
dockerComposeUrl := fmt.Sprintf("%s/%s/1panel/%s/%s/docker-compose.yml", global.AppRepoURL(), global.CONF.Base.Mode, app.Key, appDetail.Version)
_, composeRes, err = req_helper.HandleRequest(dockerComposeUrl, http.MethodGet, constant.TimeOut20s)
if err != nil {
return
}
appDetail.DockerCompose = string(composeRes)
_ = appDetailRepo.Update(context.Background(), appDetail)
}
if err = yaml.Unmarshal([]byte(appDetail.DockerCompose), &composeMap); err != nil {
return
}
@@ -483,6 +524,10 @@ func (a AppService) Install(req request.AppInstallCreate) (appInstall *model.App
}
appInstall.Env = string(paramByte)
var maxSort int
global.DB.Model(&model.AppInstall{}).Where("favorite = ?", false).Select("COALESCE(MAX(sort_order),0)").Scan(&maxSort)
appInstall.SortOrder = maxSort + 1
if err = appInstallRepo.Create(context.Background(), appInstall); err != nil {
return
}
@@ -500,8 +545,15 @@ func (a AppService) Install(req request.AppInstallCreate) (appInstall *model.App
if err = copyData(t, app, appDetail, appInstall, req); err != nil {
return err
}
if err = runScript(t, appInstall, "init"); err != nil {
return err
if hooks != nil && hooks.AfterCopyData != nil {
if err = hooks.AfterCopyData(appInstall); err != nil {
return err
}
}
if executeScript {
if err = runScript(t, appInstall, "init"); err != nil {
return err
}
}
if app.Key == "openresty" {
if err = handleSiteDir(app, appDetail, req, t); err != nil {
@@ -776,8 +828,13 @@ func (a AppService) GetAppUpdate() (*response.AppUpdateRes, error) {
res := &response.AppUpdateRes{
CanUpdate: false,
}
mysql, _ := appRepo.GetFirst(appRepo.WithKey("mysql"))
if !mysql.BatchInstallSupport {
res.CanUpdate = true
return res, nil
}
versionUrl := fmt.Sprintf("%s/%s/1panel.json.version.txt", global.CONF.RemoteURL.AppRepo, global.CONF.Base.Mode)
versionUrl := fmt.Sprintf("%s/%s/1panel.json.version.txt", global.AppRepoURL(), global.CONF.Base.Mode)
_, versionRes, err := req_helper.HandleRequest(versionUrl, http.MethodGet, constant.TimeOut20s)
if err != nil {
return nil, err
@@ -808,6 +865,13 @@ func (a AppService) GetAppUpdate() (*response.AppUpdateRes, error) {
res.CanUpdate = true
return res, err
}
if appicon.IsIconFile(app.Icon) {
fileName, _ := appicon.ParseIconField(app.Icon)
if fileName == "" || !appicon.IconFileExists(fileName) {
res.CanUpdate = true
return res, err
}
}
}
list, err := getAppList()
@@ -842,7 +906,7 @@ func getAppFromRepo(downloadPath string) error {
func getAppList() (*dto.AppList, error) {
list := &dto.AppList{}
if err := getAppFromRepo(fmt.Sprintf("%s/%s/1panel.json.zip", global.CONF.RemoteURL.AppRepo, global.CONF.Base.Mode)); err != nil {
if err := getAppFromRepo(fmt.Sprintf("%s/%s/1panel.json.zip", global.AppRepoURL(), global.CONF.Base.Mode)); err != nil {
return nil, err
}
listFile := filepath.Join(global.Dir.ResourceDir, "1panel.json")
@@ -865,295 +929,116 @@ var InitTypes = map[string]struct{}{
}
func deleteCustomApp() {
installs, err := appInstallRepo.ListBy(context.Background())
if err != nil {
global.LOG.Errorf("[AppStore] deleteCustomApp: failed to list installs, skipping: %v", err)
return
}
var appIDS []uint
installs, _ := appInstallRepo.ListBy(context.Background())
for _, install := range installs {
appIDS = append(appIDS, install.AppId)
}
var ops []repo.DBOption
ops = append(ops, repo.WithByIDNotIn(appIDS))
if len(appIDS) > 0 {
ops = append(ops, repo.WithByIDNotIn(appIDS))
}
apps, _ := appRepo.GetBy(ops...)
apps, err := appRepo.GetBy(ops...)
if err != nil {
global.LOG.Errorf("[AppStore] deleteCustomApp: failed to get apps, skipping: %v", err)
return
}
var deleteIDS []uint
for _, app := range apps {
if app.Resource == constant.AppResourceCustom {
deleteIDS = append(deleteIDS, app.ID)
}
}
_ = appRepo.DeleteByIDs(context.Background(), deleteIDS)
_ = appDetailRepo.DeleteByAppIds(context.Background(), deleteIDS)
if len(deleteIDS) == 0 {
return
}
if err = appRepo.DeleteByIDs(context.Background(), deleteIDS); err != nil {
global.LOG.Errorf("[AppStore] deleteCustomApp: failed to delete apps: %v", err)
}
if err = appDetailRepo.DeleteByAppIds(context.Background(), deleteIDS); err != nil {
global.LOG.Errorf("[AppStore] deleteCustomApp: failed to delete app details: %v", err)
}
}
func (a AppService) SyncAppListFromRemote(taskID string) (err error) {
if xpack.IsUseCustomApp() {
return nil
}
appStoreSyncMu.Lock()
global.LOG.Info("[AppStore] sync app from remote task create start")
if appStoreSyncing {
appStoreSyncMu.Unlock()
global.LOG.Info("[AppStore] sync already in progress, skipping")
return nil
}
appStoreSyncing = true
appStoreSyncMu.Unlock()
syncTask, err := task.NewTaskWithOps(i18n.GetMsgByKey("App"), task.TaskSync, task.TaskScopeAppStore, taskID, 0)
if err != nil {
appStoreSyncMu.Lock()
appStoreSyncing = false
appStoreSyncMu.Unlock()
return err
}
syncTask.AddSubTask(task.GetTaskName(i18n.GetMsgByKey("App"), task.TaskSync, task.TaskScopeAppStore), func(t *task.Task) (err error) {
updateRes, err := a.GetAppUpdate()
if err != nil {
return err
}
if !updateRes.CanUpdate {
if updateRes.IsSyncing {
t.Log(i18n.GetMsgByKey("AppStoreIsSyncing"))
return nil
}
t.Log(i18n.GetMsgByKey("AppStoreIsUpToDate"))
return nil
}
list := &dto.AppList{}
if updateRes.AppList == nil {
list, err = getAppList()
if err != nil {
return err
}
} else {
list = updateRes.AppList
}
settingService := NewISettingService()
_ = settingService.Update("AppStoreSyncStatus", constant.StatusSyncing)
setting, err := settingService.GetSettingInfo()
if err != nil {
return err
}
var (
appTags []*model.AppTag
oldAppIds []uint
)
if err = SyncTags(list.Extra); err != nil {
return err
}
deleteCustomApp()
oldApps, err := appRepo.GetBy(appRepo.WithNotLocal())
if err != nil {
return err
}
for _, old := range oldApps {
oldAppIds = append(oldAppIds, old.ID)
}
var sharedCtx *appSyncContext
baseRemoteUrl := fmt.Sprintf("%s/%s/1panel", global.CONF.RemoteURL.AppRepo, global.CONF.Base.Mode)
appsMap := getApps(oldApps, list.Apps, setting.SystemVersion, t)
t.LogStart(i18n.GetMsgByKey("SyncAppDetail"))
for _, l := range list.Apps {
app, ok := appsMap[l.AppProperty.Key]
if !ok {
continue
}
iconStr := ""
_, iconRes, err := req_helper.HandleRequest(l.Icon, http.MethodGet, constant.TimeOut20s)
if err == nil {
if !strings.Contains(string(iconRes), "<xml>") {
iconStr = base64.StdEncoding.EncodeToString(iconRes)
}
}
app.Icon = iconStr
app.TagsKey = l.AppProperty.Tags
if l.AppProperty.Recommend > 0 {
app.Recommend = l.AppProperty.Recommend
} else {
app.Recommend = 9999
}
app.ReadMe = l.ReadMe
app.LastModified = l.LastModified
versions := l.Versions
detailsMap := getAppDetails(app.Details, versions)
for _, v := range versions {
version := v.Name
detail := detailsMap[version]
versionUrl := fmt.Sprintf("%s/%s/%s", baseRemoteUrl, app.Key, version)
paramByte, _ := json.Marshal(v.AppForm)
var appForm dto.AppForm
_ = json.Unmarshal(paramByte, &appForm)
if appForm.SupportVersion > 0 && common.CompareVersion(strconv.FormatFloat(appForm.SupportVersion, 'f', -1, 64), setting.SystemVersion) {
delete(detailsMap, version)
continue
}
if _, ok := InitTypes[app.Type]; ok {
dockerComposeUrl := fmt.Sprintf("%s/%s", versionUrl, "docker-compose.yml")
_, composeRes, err := req_helper.HandleRequest(dockerComposeUrl, http.MethodGet, constant.TimeOut20s)
if err == nil {
detail.DockerCompose = string(composeRes)
}
} else {
detail.DockerCompose = ""
}
detail.Params = string(paramByte)
detail.DownloadUrl = fmt.Sprintf("%s/%s", versionUrl, app.Key+"-"+version+".tar.gz")
detail.DownloadCallBackUrl = v.DownloadCallBackUrl
detail.Update = true
detail.LastModified = v.LastModified
detailsMap[version] = detail
}
var newDetails []model.AppDetail
for _, detail := range detailsMap {
newDetails = append(newDetails, detail)
}
app.Details = newDetails
appsMap[l.AppProperty.Key] = app
}
t.LogSuccess(i18n.GetMsgByKey("SyncAppDetail"))
tags, _ := tagRepo.All()
var (
addAppArray []model.App
updateAppArray []model.App
deleteAppArray []model.App
deleteIds []uint
tagMap = make(map[string]uint, len(tags))
)
for _, v := range appsMap {
if v.ID == 0 {
addAppArray = append(addAppArray, v)
} else {
if v.Status == constant.AppTakeDown {
installs, _ := appInstallRepo.ListBy(context.Background(), appInstallRepo.WithAppId(v.ID))
if len(installs) > 0 {
updateAppArray = append(updateAppArray, v)
continue
}
deleteAppArray = append(deleteAppArray, v)
deleteIds = append(deleteIds, v.ID)
} else {
updateAppArray = append(updateAppArray, v)
}
}
}
tx, ctx := getTxAndContext()
defer func() {
if err != nil {
tx.Rollback()
return
}
}()
if len(addAppArray) > 0 {
if err = appRepo.BatchCreate(ctx, addAppArray); err != nil {
return
}
}
if len(deleteAppArray) > 0 {
if err = appRepo.BatchDelete(ctx, deleteAppArray); err != nil {
return
}
if err = appDetailRepo.DeleteByAppIds(ctx, deleteIds); err != nil {
return
}
}
for _, tag := range tags {
tagMap[tag.Key] = tag.ID
}
for _, update := range updateAppArray {
if err = appRepo.Save(ctx, &update); err != nil {
return
}
}
apps := append(addAppArray, updateAppArray...)
var (
addDetails []model.AppDetail
updateDetails []model.AppDetail
deleteDetails []model.AppDetail
)
for _, app := range apps {
for _, tag := range app.TagsKey {
tagId, ok := tagMap[tag]
if ok {
exist, _ := appTagRepo.GetFirst(ctx, appTagRepo.WithByTagID(tagId), appTagRepo.WithByAppID(app.ID))
if exist == nil {
appTags = append(appTags, &model.AppTag{
AppId: app.ID,
TagId: tagId,
})
}
}
}
for _, d := range app.Details {
d.AppId = app.ID
if d.ID == 0 {
addDetails = append(addDetails, d)
} else {
if d.Status == constant.AppTakeDown {
runtime, _ := runtimeRepo.GetFirst(ctx, runtimeRepo.WithDetailId(d.ID))
if runtime != nil {
updateDetails = append(updateDetails, d)
continue
}
installs, _ := appInstallRepo.ListBy(ctx, appInstallRepo.WithDetailIdsIn([]uint{d.ID}))
if len(installs) > 0 {
updateDetails = append(updateDetails, d)
continue
}
deleteDetails = append(deleteDetails, d)
} else {
updateDetails = append(updateDetails, d)
}
}
}
}
if len(addDetails) > 0 {
if err = appDetailRepo.BatchCreate(ctx, addDetails); err != nil {
return
}
}
if len(deleteDetails) > 0 {
if err = appDetailRepo.BatchDelete(ctx, deleteDetails); err != nil {
return
}
}
for _, u := range updateDetails {
if err = appDetailRepo.Update(ctx, u); err != nil {
return
}
}
if len(oldAppIds) > 0 {
if err = appTagRepo.DeleteByAppIds(ctx, deleteIds); err != nil {
return
}
}
if len(appTags) > 0 {
if err = appTagRepo.BatchCreate(ctx, appTags); err != nil {
return
}
}
tx.Commit()
_ = settingService.Update("AppStoreSyncStatus", constant.StatusSyncSuccess)
_ = settingService.Update("AppStoreLastModified", strconv.Itoa(list.LastModified))
return nil
}, nil)
syncTask.AddSubTask(task.GetTaskName(i18n.GetMsgByKey("App"), task.TaskSync, task.TaskScopeAppStore), a.createSyncAppStoreTask(&sharedCtx), nil)
syncTask.AddSubTask(i18n.GetMsgByKey("SyncAppDetail"), a.createSyncAppStoreMetaTask(&sharedCtx), nil)
go func() {
defer func() {
if r := recover(); r != nil {
global.LOG.Errorf("[AppStore] sync goroutine recovered from panic: %v", r)
if updateErr := NewISettingService().Update("AppStoreSyncStatus", constant.StatusError); updateErr != nil {
global.LOG.Warnf("[AppStore] failed to update sync status after panic: %v", updateErr)
}
}
appStoreSyncMu.Lock()
appStoreSyncing = false
appStoreSyncMu.Unlock()
}()
if err := syncTask.Execute(); err != nil {
_ = NewISettingService().Update("AppStoreLastModified", "0")
_ = NewISettingService().Update("AppStoreSyncStatus", constant.StatusError)
if updateErr := NewISettingService().Update("AppStoreLastModified", "0"); updateErr != nil {
global.LOG.Warnf("[AppStore] failed to reset last modified: %v", updateErr)
}
if updateErr := NewISettingService().Update("AppStoreSyncStatus", constant.StatusError); updateErr != nil {
global.LOG.Warnf("[AppStore] failed to update sync status to error: %v", updateErr)
}
return
}
}()
global.LOG.Info("[AppStore] sync app from remote task create ok")
return nil
}
func (a AppService) GetAppIcon(appID uint) ([]byte, error) {
app, err := appRepo.GetFirst(repo.WithByID(appID))
func (a AppService) GetAppIcon(key string) ([]byte, string, string, error) {
app, err := appRepo.GetFirst(appRepo.WithKey(key))
if err != nil {
return nil, err
return nil, "", "", err
}
if appicon.IsIconFile(app.Icon) {
fileName, etag := appicon.ParseIconField(app.Icon)
iconBytes, err := appicon.ReadIconFile(fileName)
if err != nil {
global.LOG.Warnf("[AppIcon] read icon file failed key=%s, file=%s, err=%v", key, fileName, err)
return nil, "", "", nil
}
return iconBytes, fileName, etag, nil
}
iconBytes, err := base64.StdEncoding.DecodeString(app.Icon)
if err != nil {
return nil, err
global.LOG.Warnf("[AppIcon] decode base64 icon failed key=%s, err=%v", key, err)
return nil, "", "", nil
}
return iconBytes, nil
return iconBytes, "", "", nil
}
+35 -8
View File
@@ -57,6 +57,7 @@ type IAppInstallService interface {
UpdateAppConfig(req request.AppConfigUpdate) error
GetInstallList() ([]dto.AppInstallInfo, error)
GetAppInstallInfo(appInstallID uint) (*response.AppInstallInfo, error)
UpdateSort(req request.AppInstallSort) error
}
func NewIAppInstalledService() IAppInstallService {
@@ -83,6 +84,7 @@ func (a *AppInstallService) Page(req request.AppInstalledSearch) (int64, []respo
err error
)
opts = append(opts, repo.WithOrderRuleBy("favorite", "descending"))
opts = append(opts, repo.WithOrderRuleBy("sort_order", "ascending"))
if req.Name != "" {
opts = append(opts, repo.WithByLikeName(req.Name))
@@ -121,7 +123,7 @@ func (a *AppInstallService) Page(req request.AppInstalledSearch) (int64, []respo
}
}
installDTOs, _ := handleInstalled(installs, req.Update, req.Sync)
installDTOs, _ := handleInstalled(installs, req.Update, req.Sync, req.CheckUpdate)
if req.Update {
total = int64(len(installDTOs))
}
@@ -238,7 +240,7 @@ func (a *AppInstallService) SearchForWebsite(req request.AppInstalledSearch) ([]
}
}
return handleInstalled(installs, false, true)
return handleInstalled(installs, false, true, false)
}
func (a *AppInstallService) Operate(req request.AppInstalledOperate) error {
@@ -300,6 +302,9 @@ func (a *AppInstallService) Operate(req request.AppInstalledOperate) error {
return opNginx(install.ContainerName, constant.NginxReload)
case constant.Favorite:
install.Favorite = req.Favorite
var maxSort int
global.DB.Model(&model.AppInstall{}).Where("favorite = ?", req.Favorite).Select("COALESCE(MAX(sort_order),0)").Scan(&maxSort)
install.SortOrder = maxSort + 1
return appInstallRepo.Save(context.Background(), &install)
default:
return errors.New("operate not support")
@@ -318,17 +323,25 @@ func (a *AppInstallService) UpdateAppConfig(req request.AppConfigUpdate) error {
return appInstallRepo.Save(context.Background(), &installed)
}
func (a *AppInstallService) UpdateSort(req request.AppInstallSort) error {
for _, item := range req.Items {
if err := appInstallRepo.BatchUpdateBy(map[string]interface{}{"sort_order": item.SortOrder}, repo.WithByID(item.InstallID)); err != nil {
return err
}
}
return nil
}
func (a *AppInstallService) Update(req request.AppInstalledUpdate) error {
installed, err := appInstallRepo.GetFirst(repo.WithByID(req.InstallId))
if err != nil {
return err
}
changePort := false
oldInstalled := installed
port, ok := req.Params["PANEL_APP_PORT_HTTP"]
if ok {
portN := int(math.Ceil(port.(float64)))
if portN != installed.HttpPort {
changePort = true
httpPort, err := checkPort("PANEL_APP_PORT_HTTP", req.Params)
if err != nil {
return err
@@ -414,9 +427,17 @@ func (a *AppInstallService) Update(req request.AppInstalledUpdate) error {
installed.Status = constant.StatusRunning
_ = appInstallRepo.Save(context.Background(), &installed)
proxyChanged := hasAppInstallProxyPassChanged(&oldInstalled, &installed)
currentProxy, currentProxyErr := getAppInstallProxyPass(&installed)
website, _ := websiteRepo.GetFirst(websiteRepo.WithAppInstallId(installed.ID))
if changePort && website.ID != 0 && website.Status == constant.StatusRunning {
go func() {
if proxyChanged && website.ID != 0 && currentProxyErr == nil {
website.Proxy = currentProxy
if err := websiteRepo.SaveWithoutCtx(&website); err != nil {
global.LOG.Error(buserr.WithErr("ErrUpdateBuWebsite", err).Error())
}
}
if proxyChanged && website.ID != 0 && website.Status == constant.StatusRunning && currentProxyErr == nil {
go func(website model.Website, proxy string) {
nginxInstall, err := getNginxFull(&website)
if err != nil {
global.LOG.Error(buserr.WithErr("ErrUpdateBuWebsite", err).Error())
@@ -429,7 +450,6 @@ func (a *AppInstallService) Update(req request.AppInstalledUpdate) error {
return
}
server := servers[0]
proxy := fmt.Sprintf("http://127.0.0.1:%d", installed.HttpPort)
server.UpdateRootProxy([]string{proxy})
if err := nginx.WriteConfig(config, nginx.IndentedStyle); err != nil {
@@ -440,7 +460,10 @@ func (a *AppInstallService) Update(req request.AppInstalledUpdate) error {
global.LOG.Error(buserr.WithErr("ErrUpdateBuWebsite", err).Error())
return
}
}()
}(website, currentProxy)
}
if proxyChanged && website.ID != 0 && currentProxyErr != nil {
global.LOG.Error(buserr.WithErr("ErrUpdateBuWebsite", currentProxyErr).Error())
}
return nil
}
@@ -830,6 +853,10 @@ func syncAppInstallStatus(appInstall *model.AppInstall, force bool) error {
return nil
}
func SyncAppInstallStatus(appInstall *model.AppInstall, force bool) error {
return syncAppInstallStatus(appInstall, force)
}
func updateInstallInfoInDB(appKey, appName, param string, value interface{}) error {
if param != "password" && param != "port" && param != "user-password" {
return nil
+608
View File
@@ -0,0 +1,608 @@
package service
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/app/task"
"github.com/1Panel-dev/1Panel/agent/constant"
"github.com/1Panel-dev/1Panel/agent/global"
"github.com/1Panel-dev/1Panel/agent/i18n"
"github.com/1Panel-dev/1Panel/agent/utils/appicon"
"github.com/1Panel-dev/1Panel/agent/utils/common"
"github.com/1Panel-dev/1Panel/agent/utils/req_helper"
"github.com/1Panel-dev/1Panel/agent/utils/xpack"
)
type appSyncContext struct {
task *task.Task
httpClient http.Client
baseRemoteUrl string
systemVersion string
appsMap map[string]model.App
settingService ISettingService
list *dto.AppList
oldAppIds []uint
appTags []*model.AppTag
skipMetaSync bool
pendingIcons map[string]string
}
func (a AppService) createSyncAppStoreTask(sharedCtx **appSyncContext) func(t *task.Task) error {
return func(t *task.Task) (err error) {
t.LogStart(i18n.GetMsgByKey("AppStore") + " " + i18n.GetMsgByKey("TaskSync"))
updateRes, err := a.GetAppUpdate()
if err != nil {
t.LogFailedWithErr(i18n.GetMsgByKey("CheckAppStoreUpdate"), err)
return err
}
if !updateRes.CanUpdate {
if updateRes.IsSyncing {
t.Log(i18n.GetMsgByKey("AppStoreIsSyncing"))
return nil
}
global.LOG.Infof("[AppStore] Appstore is up to date")
t.Log(i18n.GetMsgByKey("AppStoreIsUpToDate"))
*sharedCtx = &appSyncContext{skipMetaSync: true}
t.LogSuccess(i18n.GetMsgByKey("AppStore") + " " + i18n.GetMsgByKey("TaskSync"))
return nil
}
list := &dto.AppList{}
if updateRes.AppList == nil {
list, err = getAppList()
if err != nil {
t.LogFailedWithErr(i18n.GetMsgByKey("DownloadAppList"), err)
return err
}
} else {
list = updateRes.AppList
}
settingService := NewISettingService()
if err := settingService.Update("AppStoreSyncStatus", constant.StatusSyncing); err != nil {
global.LOG.Warnf("[AppStore] failed to update sync status to syncing: %v", err)
}
setting, err := settingService.GetSettingInfo()
if err != nil {
t.LogFailedWithErr("GetSettingInfo", err)
return err
}
ctx := &appSyncContext{
task: t,
httpClient: http.Client{Timeout: time.Duration(constant.TimeOut20s) * time.Second, Transport: xpack.LoadRequestTransport()},
baseRemoteUrl: fmt.Sprintf("%s/%s/1panel", global.AppRepoURL(), global.CONF.Base.Mode),
systemVersion: setting.SystemVersion,
settingService: settingService,
list: list,
appTags: make([]*model.AppTag, 0),
pendingIcons: make(map[string]string),
}
if err = SyncTags(list.Extra); err != nil {
t.LogFailedWithErr(i18n.GetMsgByKey("SyncTags"), err)
return err
}
deleteCustomApp()
oldApps, err := appRepo.GetBy(appRepo.WithNotLocal())
if err != nil {
t.LogFailedWithErr(i18n.GetMsgByKey("LoadLocalApps"), err)
return err
}
ctx.oldAppIds = make([]uint, 0, len(oldApps))
for _, old := range oldApps {
ctx.oldAppIds = append(ctx.oldAppIds, old.ID)
}
ctx.appsMap, ctx.pendingIcons = getApps(oldApps, list.Apps, setting.SystemVersion, t)
var addCount, updateCount, deleteCount int
if err = ctx.classifyAndPersistAppsWithStats(&addCount, &updateCount, &deleteCount); err != nil {
t.LogFailedWithErr(i18n.GetMsgByKey("PersistApps"), err)
return err
}
if err := settingService.Update("AppStoreSyncStatus", constant.StatusSyncSuccess); err != nil {
global.LOG.Warnf("[AppStore] failed to update sync status to success: %v", err)
}
if err := settingService.Update("AppStoreLastModified", strconv.Itoa(list.LastModified)); err != nil {
global.LOG.Warnf("[AppStore] failed to update last modified: %v", err)
}
global.LOG.Infof("[AppStore] Appstore sync completed")
*sharedCtx = ctx
t.LogSuccess(i18n.GetMsgByKey("AppStore") + " " + i18n.GetMsgByKey("TaskSync"))
return nil
}
}
type appWorkItem struct {
appDef dto.AppDefine
app model.App
iconUrl string
hadIcon bool
}
type appWorkResult struct {
appKey string
app model.App
iconStatus int
hadIcon bool
httpFailed bool
}
func (c *appSyncContext) processOneApp(item appWorkItem) appWorkResult {
app := item.app
l := item.appDef
result := appWorkResult{
appKey: l.AppProperty.Key,
hadIcon: item.hadIcon,
}
if item.hadIcon {
status, iconField := c.downloadAppIcon(item.iconUrl, l.AppProperty.Key, app.Icon)
result.iconStatus = status
switch status {
case http.StatusOK:
app.Icon = iconField
case http.StatusNotModified:
default:
result.httpFailed = true
}
}
app.TagsKey = l.AppProperty.Tags
if l.AppProperty.Recommend > 0 {
app.Recommend = l.AppProperty.Recommend
} else {
app.Recommend = 9999
}
app.ReadMe = l.ReadMe
app.LastModified = l.LastModified
versions := l.Versions
detailsMap := getAppDetails(app.Details, versions)
for _, v := range versions {
version := v.Name
detail := detailsMap[version]
versionUrl := fmt.Sprintf("%s/%s/%s", c.baseRemoteUrl, app.Key, version)
paramByte, _ := json.Marshal(v.AppForm)
var appForm dto.AppForm
_ = json.Unmarshal(paramByte, &appForm)
if appForm.SupportVersion > 0 && common.CompareVersion(strconv.FormatFloat(appForm.SupportVersion, 'f', -1, 64), c.systemVersion) {
delete(detailsMap, version)
continue
}
if _, ok := InitTypes[app.Type]; ok {
dockerComposeUrl := fmt.Sprintf("%s/%s", versionUrl, "docker-compose.yml")
_, composeRes, err := req_helper.HandleRequestWithClient(&c.httpClient, dockerComposeUrl, http.MethodGet, constant.TimeOut20s)
if err == nil {
detail.DockerCompose = string(composeRes)
} else {
result.httpFailed = true
}
} else {
detail.DockerCompose = ""
}
detail.Params = string(paramByte)
detail.DownloadUrl = fmt.Sprintf("%s/%s", versionUrl, app.Key+"-"+version+".tar.gz")
detail.DownloadCallBackUrl = v.DownloadCallBackUrl
detail.Update = true
detail.LastModified = v.LastModified
detailsMap[version] = detail
}
newDetails := make([]model.AppDetail, 0, len(detailsMap))
for _, detail := range detailsMap {
newDetails = append(newDetails, detail)
}
app.Details = newDetails
result.app = app
return result
}
func (c *appSyncContext) syncAppIconsAndDetails() error {
total := len(c.list.Apps)
global.LOG.Infof("[AppStore] sync app detail start, total apps: %d", total)
workItems := make([]appWorkItem, 0, total)
for _, l := range c.list.Apps {
app, ok := c.appsMap[l.AppProperty.Key]
if !ok {
continue
}
iconUrl, hasPending := c.pendingIcons[l.AppProperty.Key]
workItems = append(workItems, appWorkItem{
appDef: l,
app: app,
iconUrl: iconUrl,
hadIcon: hasPending,
})
}
totalWork := len(workItems)
if totalWork == 0 {
return nil
}
const maxWorkers = 4
var (
failFlag atomic.Bool
workCh = make(chan int, maxWorkers)
resultCh = make(chan appWorkResult, maxWorkers)
wg sync.WaitGroup
)
wg.Add(maxWorkers)
for range maxWorkers {
go func() {
defer wg.Done()
for idx := range workCh {
result := c.processOneApp(workItems[idx])
resultCh <- result
if result.httpFailed {
failFlag.Store(true)
}
}
}()
}
go func() { wg.Wait(); close(resultCh) }()
var fed atomic.Int32
go func() {
for i := range workItems {
if failFlag.Load() {
break
}
workCh <- i
fed.Store(int32(i + 1))
}
close(workCh)
}()
var (
completed int
icon200Count int
icon304Count int
iconFailCount int
)
milestones := [4]int{totalWork / 4, totalWork / 2, totalWork * 3 / 4, totalWork}
nextMS := 0
checkProgress := func() {
if nextMS < len(milestones) && completed >= milestones[nextMS] {
c.task.LogWithProgress(i18n.GetMsgByKey("SyncAppDetail"), completed, totalWork)
nextMS++
}
}
applyResult := func(result appWorkResult) {
c.appsMap[result.appKey] = result.app
if result.hadIcon {
switch result.iconStatus {
case http.StatusOK:
icon200Count++
case http.StatusNotModified:
icon304Count++
default:
iconFailCount++
}
}
completed++
checkProgress()
}
for result := range resultCh {
applyResult(result)
}
fedCount := int(fed.Load())
if failFlag.Load() && fedCount < len(workItems) {
global.LOG.Warnf("[AppStore] HTTP failure detected, falling back to serial for remaining %d apps", len(workItems)-fedCount)
for i := fedCount; i < len(workItems); i++ {
result := c.processOneApp(workItems[i])
applyResult(result)
}
}
global.LOG.Infof("[AppStore] icon download completed - total: %d, success(200): %d, cached(304): %d, failed: %d",
total, icon200Count, icon304Count, iconFailCount)
return nil
}
func (c *appSyncContext) downloadAppIcon(iconUrl, appKey, oldIcon string) (status int, iconField string) {
iconFileName, existingEtag := appicon.ParseIconField(oldIcon)
reqHeaders := make(map[string]string)
if existingEtag != "" && iconFileName != "" && appicon.IconFileExists(iconFileName) {
reqHeaders["If-None-Match"] = existingEtag
}
resp, err := req_helper.HandleRequestWithHeaders(&c.httpClient, iconUrl, http.MethodGet, constant.TimeOut20s, reqHeaders)
if err != nil {
global.LOG.Warnf("[AppStore] request icon failed url=%s, err=%v", iconUrl, err)
return 0, ""
}
if resp.StatusCode == http.StatusNotModified {
return http.StatusNotModified, ""
}
if resp.StatusCode != http.StatusOK {
global.LOG.Warnf("[AppStore] download icon failed url=%s, status=%d", iconUrl, resp.StatusCode)
return 0, ""
}
if len(resp.Body) == 0 {
global.LOG.Warnf("[AppStore] download icon empty body url=%s", iconUrl)
return 0, ""
}
if resp.Body[0] == '<' {
global.LOG.Warnf("[AppStore] download icon got HTML response url=%s", iconUrl)
return 0, ""
}
contentType := resp.Header.Get("Content-Type")
ct := strings.TrimSpace(strings.Split(contentType, ";")[0])
if strings.ToLower(ct) != "image/png" {
global.LOG.Warnf("[AppStore] unexpected icon content-type: %s, expected image/png, url=%s", ct, iconUrl)
}
fileName, err := appicon.WriteIconFile(appKey, resp.Body)
if err != nil {
global.LOG.Warnf("[AppStore] write icon file failed appKey=%s, err=%v", appKey, err)
return 0, ""
}
newEtag := resp.Header.Get("ETag")
iconField = appicon.BuildIconField(fileName, newEtag)
return http.StatusOK, iconField
}
func (a AppService) createSyncAppStoreMetaTask(sharedCtx **appSyncContext) func(t *task.Task) error {
return func(t *task.Task) (err error) {
t.LogStart(i18n.GetMsgByKey("SyncAppDetail"))
ctx := *sharedCtx
if ctx == nil {
global.LOG.Warnf("[AppStore] meta sync skipped: shared context is nil")
t.Log(i18n.GetMsgByKey("SyncAppDetail") + " skipped: shared context is nil")
return nil
}
if ctx.skipMetaSync {
global.LOG.Infof("[AppStore] meta sync skipped: no update needed")
t.Log(i18n.GetMsgByKey("SyncAppDetail") + " skipped: no update needed")
return nil
}
if ctx.list == nil || ctx.appsMap == nil {
global.LOG.Errorf("[AppStore] meta sync failed: shared context data not initialized")
err := fmt.Errorf("shared context data not initialized")
t.LogFailedWithErr(i18n.GetMsgByKey("SyncAppDetail"), err)
return err
}
t.Logf("%s: %d apps", i18n.GetMsgByKey("SyncAppDetail"), len(ctx.list.Apps))
ctx.task = t
ctx.appTags = make([]*model.AppTag, 0)
if err = ctx.syncAppIconsAndDetails(); err != nil {
t.LogFailedWithErr(i18n.GetMsgByKey("SyncAppDetail"), err)
return err
}
if err = ctx.classifyAndPersistApps(); err != nil {
t.LogFailedWithErr(i18n.GetMsgByKey("PersistAppDetails"), err)
return err
}
global.LOG.Infof("[AppStore] Appstore meta sync completed")
return nil
}
}
func (c *appSyncContext) classifyAndPersistApps() (err error) {
var addCount, updateCount, deleteCount int
return c.classifyAndPersistAppsWithStats(&addCount, &updateCount, &deleteCount)
}
func (c *appSyncContext) classifyAndPersistAppsWithStats(addCount, updateCount, deleteCount *int) (err error) {
tags, _ := tagRepo.All()
var (
addAppArray []model.App
updateAppArray []model.App
deleteAppArray []model.App
deleteIds []uint
tagMap = make(map[string]uint, len(tags))
)
for _, v := range c.appsMap {
if v.ID == 0 {
addAppArray = append(addAppArray, v)
} else {
if v.Status == constant.AppTakeDown {
installs, _ := appInstallRepo.ListBy(context.Background(), appInstallRepo.WithAppId(v.ID))
if len(installs) > 0 {
updateAppArray = append(updateAppArray, v)
continue
}
deleteAppArray = append(deleteAppArray, v)
deleteIds = append(deleteIds, v.ID)
} else {
updateAppArray = append(updateAppArray, v)
}
}
}
if len(addAppArray) > 0 {
addKeys := make([]string, 0, len(addAppArray))
for _, app := range addAppArray {
addKeys = append(addKeys, app.Key)
}
existingApps, _ := appRepo.GetBy(appRepo.WithKeyIn(addKeys))
if len(existingApps) > 0 {
existingMap := make(map[string]model.App, len(existingApps))
for _, e := range existingApps {
existingMap[e.Key] = e
}
filteredAdd := make([]model.App, 0, len(addAppArray))
for _, app := range addAppArray {
if existing, ok := existingMap[app.Key]; ok {
app.ID = existing.ID
if len(app.Details) == 0 {
app.Details = existing.Details
}
updateAppArray = append(updateAppArray, app)
} else {
filteredAdd = append(filteredAdd, app)
}
}
addAppArray = filteredAdd
}
}
*addCount = len(addAppArray)
*updateCount = len(updateAppArray)
*deleteCount = len(deleteAppArray)
tx, ctx := getTxAndContext()
defer func() {
if err != nil {
tx.Rollback()
return
}
}()
if len(addAppArray) > 0 {
if err = appRepo.BatchCreate(ctx, addAppArray); err != nil {
return
}
}
if len(deleteAppArray) > 0 {
if err = appRepo.BatchDelete(ctx, deleteAppArray); err != nil {
return
}
if err = appDetailRepo.DeleteByAppIds(ctx, deleteIds); err != nil {
return
}
}
for _, tag := range tags {
tagMap[tag.Key] = tag.ID
}
if len(updateAppArray) > 0 {
for _, update := range updateAppArray {
if err = appRepo.Save(ctx, &update); err != nil {
return
}
}
}
apps := append(addAppArray, updateAppArray...)
var (
addDetails []model.AppDetail
updateDetails []model.AppDetail
deleteDetails []model.AppDetail
)
totalDetails := 0
for _, app := range apps {
for _, tag := range app.TagsKey {
tagId, ok := tagMap[tag]
if ok {
exist, _ := appTagRepo.GetFirst(ctx, appTagRepo.WithByTagID(tagId), appTagRepo.WithByAppID(app.ID))
if exist == nil {
c.appTags = append(c.appTags, &model.AppTag{
AppId: app.ID,
TagId: tagId,
})
}
}
}
for _, d := range app.Details {
totalDetails++
d.AppId = app.ID
if d.ID == 0 {
addDetails = append(addDetails, d)
} else {
if d.Status == constant.AppTakeDown {
runtime, _ := runtimeRepo.GetFirst(ctx, runtimeRepo.WithDetailId(d.ID))
if runtime != nil {
updateDetails = append(updateDetails, d)
continue
}
installs, _ := appInstallRepo.ListBy(ctx, appInstallRepo.WithDetailIdsIn([]uint{d.ID}))
if len(installs) > 0 {
updateDetails = append(updateDetails, d)
continue
}
deleteDetails = append(deleteDetails, d)
} else {
updateDetails = append(updateDetails, d)
}
}
}
}
if len(addDetails) > 0 {
if err = appDetailRepo.BatchCreate(ctx, addDetails); err != nil {
return
}
}
if len(deleteDetails) > 0 {
if err = appDetailRepo.BatchDelete(ctx, deleteDetails); err != nil {
return
}
}
if len(updateDetails) > 0 {
for _, u := range updateDetails {
if err = appDetailRepo.Update(ctx, u); err != nil {
return
}
}
}
if len(c.oldAppIds) > 0 {
if err = appTagRepo.DeleteByAppIds(ctx, deleteIds); err != nil {
return
}
}
if len(c.appTags) > 0 {
if err = appTagRepo.BatchCreate(ctx, c.appTags); err != nil {
return
}
}
if err = tx.Commit().Error; err != nil {
return
}
return nil
}
+99 -22
View File
@@ -33,7 +33,6 @@ import (
"github.com/1Panel-dev/1Panel/agent/utils/common"
"github.com/1Panel-dev/1Panel/agent/utils/compose"
"github.com/1Panel-dev/1Panel/agent/utils/docker"
composeV2 "github.com/1Panel-dev/1Panel/agent/utils/docker"
"github.com/1Panel-dev/1Panel/agent/utils/env"
"github.com/1Panel-dev/1Panel/agent/utils/files"
"github.com/1Panel-dev/1Panel/agent/utils/nginx"
@@ -363,7 +362,7 @@ func deleteAppInstall(deleteReq request.AppInstallDelete) error {
if err != nil {
return err
}
images, err := composeV2.GetImagesFromDockerCompose(content, []byte(install.DockerCompose))
images, err := docker.GetImagesFromDockerCompose(content, []byte(install.DockerCompose))
if err != nil {
return err
}
@@ -392,6 +391,10 @@ func deleteAppInstall(deleteReq request.AppInstallDelete) error {
if err = appInstallRepo.Delete(ctx, install); err != nil {
return err
}
appKey := install.App.Key
if appKey == constant.AppOpenclaw || appKey == constant.AppCopaw {
_ = agentRepo.DeleteByAppInstallIDWithCtx(ctx, install.ID)
}
resources, _ := appInstallResourceRepo.GetBy(appInstallResourceRepo.WithAppInstallId(install.ID))
if len(resources) > 0 {
@@ -672,6 +675,7 @@ func upgradeInstall(req request.AppInstallUpgrade) error {
if err != nil {
return err
}
oldVersion := install.Version
detail, err := appDetailRepo.GetFirst(repo.WithByID(req.DetailID))
if err != nil {
return err
@@ -748,7 +752,7 @@ func upgradeInstall(req request.AppInstallUpgrade) error {
if req.DockerCompose != "" {
composeContent = []byte(req.DockerCompose)
}
images, err := composeV2.GetImagesFromDockerCompose(content, composeContent)
images, err := docker.GetImagesFromDockerCompose(content, composeContent)
if err != nil {
return err
}
@@ -764,6 +768,30 @@ func upgradeInstall(req request.AppInstallUpgrade) error {
command := exec.Command("/bin/bash", "-c", fmt.Sprintf("cp -rn %s/* %s || true", detailDir, install.GetPath()))
_, _ = command.CombinedOutput()
if install.App.Key == constant.AppOpenresty {
installBuildDir := path.Join(install.GetPath(), "build")
detailBuildDir := path.Join(detailDir, "build")
if !fileOp.Stat(installBuildDir) {
if err := fileOp.CreateDir(installBuildDir, constant.DirPerm); err != nil {
return err
}
}
if err := fileOp.DeleteDir(path.Join(installBuildDir, "tmp")); err != nil {
return err
}
if err := fileOp.CopyDir(path.Join(detailBuildDir, "tmp"), installBuildDir); err != nil {
return err
}
if err := fileOp.CopyFile(path.Join(detailBuildDir, "Dockerfile"), installBuildDir); err != nil {
return err
}
if err := fileOp.CopyFile(path.Join(detailBuildDir, "nginx.conf"), installBuildDir); err != nil {
return err
}
if err := fileOp.CopyFile(path.Join(detailBuildDir, "nginx.vh.default.conf"), installBuildDir); err != nil {
return err
}
}
sourceScripts := path.Join(detailDir, "scripts")
if fileOp.Stat(sourceScripts) {
dstScripts := path.Join(install.GetPath(), "scripts")
@@ -774,6 +802,9 @@ func upgradeInstall(req request.AppInstallUpgrade) error {
}
var newCompose string
if err = migrateOpenclawProtocolUpgrade(&install, oldVersion, detail.Version); err != nil {
return err
}
if req.DockerCompose == "" {
newCompose, err = getUpgradeCompose(install, detail)
if err != nil {
@@ -883,7 +914,7 @@ func getContainerNames(install model.AppInstall) ([]string, error) {
if err != nil {
return nil, err
}
project, err := composeV2.GetComposeProject(install.Name, install.GetPath(), []byte(install.DockerCompose), []byte(envStr), true)
project, err := docker.GetComposeProject(install.Name, install.GetPath(), []byte(install.DockerCompose), []byte(envStr), true)
if err != nil {
return nil, err
}
@@ -1105,7 +1136,7 @@ func runScript(task *task.Task, appInstall *model.AppInstall, operate string) er
}
func checkContainerNameIsExist(containerName, appDir string) (bool, error) {
client, err := composeV2.NewDockerClient()
client, err := docker.NewDockerClient()
if err != nil {
return false, err
}
@@ -1141,7 +1172,7 @@ func upApp(task *task.Task, appInstall *model.AppInstall, pullImages bool) error
if err != nil {
return err
}
images, err := composeV2.GetImagesFromDockerCompose(envByte, []byte(appInstall.DockerCompose))
images, err := docker.GetImagesFromDockerCompose(envByte, []byte(appInstall.DockerCompose))
if err != nil {
return err
}
@@ -1271,8 +1302,9 @@ func getAppDetails(details []model.AppDetail, versions []dto.AppConfigVersion) m
return appDetails
}
func getApps(oldApps []model.App, items []dto.AppDefine, systemVersion string, task *task.Task) map[string]model.App {
func getApps(oldApps []model.App, items []dto.AppDefine, systemVersion string, task *task.Task) (map[string]model.App, map[string]string) {
apps := make(map[string]model.App, len(oldApps))
pendingIcons := make(map[string]string, len(items))
for _, old := range oldApps {
old.Status = constant.AppTakeDown
apps[old.Key] = old
@@ -1312,9 +1344,13 @@ func getApps(oldApps []model.App, items []dto.AppDefine, systemVersion string, t
app.MemoryRequired = config.MemoryRequired
app.Architectures = strings.Join(config.Architectures, ",")
app.GpuSupport = config.GpuSupport
app.BatchInstallSupport = config.BatchInstallSupport
if item.Icon != "" {
pendingIcons[key] = item.Icon
}
apps[key] = app
}
return apps
return apps, pendingIcons
}
func handleLocalAppDetail(versionDir string, appDetail *model.AppDetail) error {
@@ -1530,7 +1566,7 @@ func synAppInstall(containers map[string]container.Summary, appInstall *model.Ap
_ = appInstallRepo.Save(context.Background(), appInstall)
}
func handleInstalled(appInstallList []model.AppInstall, updated bool, sync bool) ([]response.AppInstallDTO, error) {
func handleInstalled(appInstallList []model.AppInstall, updated, sync, checkUpdate bool) ([]response.AppInstallDTO, error) {
var (
res []response.AppInstallDTO
containersMap map[string]container.Summary
@@ -1553,6 +1589,7 @@ func handleInstalled(appInstallList []model.AppInstall, updated bool, sync bool)
if updated && ignoreUpdate(installed) {
continue
}
if sync && !doNotNeedSync(installed) {
synAppInstall(containersMap, &installed, false)
}
@@ -1579,23 +1616,34 @@ func handleInstalled(appInstallList []model.AppInstall, updated bool, sync bool)
Document: installed.App.Document,
},
Favorite: installed.Favorite,
SortOrder: installed.SortOrder,
Container: installed.ContainerName,
ServiceName: strings.ToLower(installed.ServiceName),
}
if !updated {
if !updated && !checkUpdate {
installDTO.LinkDB = hasLinkDB(installed.ID)
res = append(res, installDTO)
continue
}
if installed.Version == "latest" {
if checkUpdate {
installDTO.CanUpdate = false
installDTO.LinkDB = hasLinkDB(installed.ID)
res = append(res, installDTO)
}
continue
}
installDTO.DockerCompose = installed.DockerCompose
installDTO.IsEdit = isEditCompose(installed)
details, err := appDetailRepo.GetBy(appDetailRepo.WithAppId(installed.App.ID))
if err != nil {
return nil, err
}
var versions []string
for _, appDetail := range details {
ignores, _ := appIgnoreUpgradeRepo.List(runtimeRepo.WithDetailId(appDetail.ID), appIgnoreUpgradeRepo.WithScope("version"))
@@ -1607,11 +1655,19 @@ func handleInstalled(appInstallList []model.AppInstall, updated bool, sync bool)
}
versions = append(versions, appDetail.Version)
}
if len(versions) == 0 {
if checkUpdate {
installDTO.CanUpdate = false
installDTO.LinkDB = hasLinkDB(installed.ID)
res = append(res, installDTO)
}
continue
}
versions = common.GetSortedVersions(versions)
lastVersion := versions[0]
if installed.App.Key == constant.AppMysql || installed.App.Key == constant.AppMysqlCluster {
for _, version := range versions {
majorVersion := getMajorVersion(installed.Version)
@@ -1623,15 +1679,23 @@ func handleInstalled(appInstallList []model.AppInstall, updated bool, sync bool)
}
}
}
if common.IsCrossVersion(installed.Version, lastVersion) {
installDTO.CanUpdate = installed.App.CrossVersionUpdate
} else {
installDTO.CanUpdate = common.CompareVersion(lastVersion, installed.Version)
}
if installDTO.CanUpdate {
if updated {
if installDTO.CanUpdate {
res = append(res, installDTO)
}
} else if checkUpdate {
installDTO.LinkDB = hasLinkDB(installed.ID)
res = append(res, installDTO)
}
}
return res, nil
}
@@ -1960,23 +2024,26 @@ func handleSiteDir(app model.App, appDetail model.AppDetail, req request.AppInst
req.Params["WEBSITE_DIR"] = siteDir
oldWebStePath, _ := settingRepo.GetValueByKey("WEBSITE_DIR")
fileOp := files.NewFileOp()
movePath := func(src, dst string) error {
return cmd.NewCommandMgr().Run("mv", src, dst)
}
if oldWebStePath != "" && oldWebStePath != siteDir && fileOp.Stat(oldWebStePath) {
t.Log(i18n.GetWithName("MoveSiteDir", siteDir))
if fileOp.Stat(siteDir) {
if fileOp.Stat(path.Join(siteDir, "conf.d")) {
_ = fileOp.Rename(path.Join(siteDir, "conf.d"), path.Join(siteDir, "conf.d.bak"))
_ = movePath(path.Join(siteDir, "conf.d"), path.Join(siteDir, "conf.d.bak"))
}
if fileOp.Stat(path.Join(siteDir, "sites")) {
_ = fileOp.Rename(path.Join(siteDir, "sites"), path.Join(siteDir, "sites.bak"))
_ = movePath(path.Join(siteDir, "sites"), path.Join(siteDir, "sites.bak"))
}
if err := fileOp.Rename(path.Join(oldWebStePath, "sites"), path.Join(siteDir, "sites")); err != nil {
if err := movePath(path.Join(oldWebStePath, "sites"), path.Join(siteDir, "sites")); err != nil {
return err
}
if err := fileOp.Rename(path.Join(oldWebStePath, "conf.d"), path.Join(siteDir, "conf.d")); err != nil {
if err := movePath(path.Join(oldWebStePath, "conf.d"), path.Join(siteDir, "conf.d")); err != nil {
return err
}
} else {
err := fileOp.Rename(oldWebStePath, siteDir)
err := movePath(oldWebStePath, siteDir)
if err != nil {
return err
}
@@ -2091,15 +2158,21 @@ func handleSSLConfig(appInstall *model.AppInstall, hasDefaultWebsite bool, sslRe
return nil
}
func SyncTags(remoteProperties dto.ExtraProperties) error {
func SyncTags(remoteProperties dto.ExtraProperties) (err error) {
tx, ctx := getTxAndContext()
defer tx.Rollback()
localTags, _ := tagRepo.All()
defer func() {
if err != nil {
tx.Rollback()
}
}()
localTags, err := tagRepo.All()
if err != nil {
return err
}
localTagsMap := make(map[string]*model.Tag)
for i := range localTags {
localTagsMap[localTags[i].Key] = &localTags[i]
}
var err error
remoteTagsMap := make(map[string]*dto.Tag)
for i := range remoteProperties.Tags {
remoteTagsMap[remoteProperties.Tags[i].Key] = &remoteProperties.Tags[i]
@@ -2107,7 +2180,9 @@ func SyncTags(remoteProperties dto.ExtraProperties) error {
for key, localTag := range localTagsMap {
if _, exists := remoteTagsMap[key]; !exists {
_ = tagRepo.DeleteByID(ctx, localTag.ID)
if err = tagRepo.DeleteByID(ctx, localTag.ID); err != nil {
return err
}
}
}
@@ -2137,7 +2212,9 @@ func SyncTags(remoteProperties dto.ExtraProperties) error {
}
}
tx.Commit()
if err = tx.Commit().Error; err != nil {
return err
}
return nil
}
+8 -3
View File
@@ -57,6 +57,11 @@ type IBackupService interface {
AppBackup(db dto.CommonBackup) (*model.BackupRecord, error)
AppRecover(req dto.CommonRecover) error
ContainerBackup(req dto.CommonBackup) error
ContainerRecover(req dto.CommonRecover) error
ComposeBackup(req dto.CommonBackup) error
ComposeRecover(req dto.CommonRecover) error
}
func NewIBackupService() IBackupService {
@@ -72,7 +77,7 @@ func (u *BackupService) GetLocalDir() (string, error) {
}
func (u *BackupService) SearchWithPage(req dto.SearchPageWithType) (int64, interface{}, error) {
options := []repo.DBOption{repo.WithOrderBy("created_at desc")}
options := []repo.DBOption{repo.WithOrderDesc("created_at")}
if len(req.Type) != 0 {
options = append(options, repo.WithByType(req.Type))
}
@@ -372,7 +377,7 @@ func (u *BackupService) checkBackupConn(backup *model.BackupAccount) (bool, erro
}
func (u *BackupService) LoadBackupOptions() ([]dto.BackupOption, error) {
accounts, err := backupRepo.List(repo.WithOrderBy("created_at desc"))
accounts, err := backupRepo.List(repo.WithOrderDesc("created_at"))
if err != nil {
return nil, err
}
@@ -535,7 +540,7 @@ func loadRefreshTokenByCode(backup *model.BackupAccount) (string, error) {
if err := json.Unmarshal([]byte(backup.Vars), &varMap); err != nil {
return "", fmt.Errorf("unmarshal backup vars failed, err: %v", err)
}
if _, ok := varMap["refresh_token"]; ok {
if token, ok := varMap["refresh_token"]; ok && len(token.(string)) != 0 {
return "", nil
}
refreshToken := ""
+153 -39
View File
@@ -11,6 +11,7 @@ import (
"time"
"github.com/1Panel-dev/1Panel/agent/app/repo"
"github.com/subosito/gotenv"
"github.com/1Panel-dev/1Panel/agent/app/task"
"github.com/1Panel-dev/1Panel/agent/i18n"
@@ -179,6 +180,9 @@ func handleAppRecover(install *model.AppInstall, parentTask *task.Task, recoverF
return err
}
tmpPath := strings.ReplaceAll(recoverFile, ".tar.gz", "")
if err := fileOp.TarGzExtractPro(tmpPath+"/app.tar.gz", tmpPath, ""); err != nil {
return err
}
defer func() {
_, _ = compose.Up(install.GetComposePath())
_ = os.RemoveAll(strings.ReplaceAll(recoverFile, ".tar.gz", ""))
@@ -187,19 +191,26 @@ func handleAppRecover(install *model.AppInstall, parentTask *task.Task, recoverF
if !fileOp.Stat(tmpPath+"/app.json") || !fileOp.Stat(tmpPath+"/app.tar.gz") {
return errors.New(i18n.GetMsgByKey("AppBackupFileIncomplete"))
}
var oldInstall model.AppInstall
var backupInstall model.AppInstall
appJson, err := os.ReadFile(tmpPath + "/app.json")
if err != nil {
return err
}
if err := json.Unmarshal(appJson, &oldInstall); err != nil {
if err := json.Unmarshal(appJson, &backupInstall); err != nil {
return fmt.Errorf("unmarshal app.json failed, err: %v", err)
}
if oldInstall.App.Key != install.App.Key || oldInstall.Name != install.Name {
if backupInstall.App.Key != install.App.Key || backupInstall.Name != install.Name {
return errors.New(i18n.GetMsgByKey("AppAttributesNotMatch"))
}
newEnvFile := ""
backupEnvMap, err := getEnvMapByPath(path.Join(tmpPath, install.Name, ".env"))
if err != nil {
return err
}
installedEnvMap, err := getEnvMapByPath(install.GetEnvPath())
if err != nil {
return err
}
var mergedEnvContent string
resources, _ := appInstallResourceRepo.GetBy(appInstallResourceRepo.WithAppInstallId(install.ID))
for _, resource := range resources {
var database model.Database
@@ -225,11 +236,20 @@ func handleAppRecover(install *model.AppInstall, parentTask *task.Task, recoverF
if err != nil {
return err
}
newDB, err := reCreatePostgresqlDB(db.ID, database, backupEnvMap)
if err != nil {
return err
}
backupInstall.Env, mergedEnvContent, err = buildRecoverEnv(backupInstall.Env, backupEnvMap, installedEnvMap)
if err != nil {
return err
}
_ = appInstallResourceRepo.BatchUpdateBy(map[string]interface{}{"resource_id": newDB.ID}, repo.WithByID(resource.ID))
taskName := task.GetTaskName(db.Name, task.TaskRecover, task.TaskScopeDatabase)
t.LogStart(taskName)
if err := handlePostgresqlRecover(dto.CommonRecover{
Name: database.Name,
DetailName: db.Name,
Name: newDB.PostgresqlName,
DetailName: newDB.Name,
File: fmt.Sprintf("%s/%s.sql.gz", tmpPath, install.Name),
}, parentTask, true); err != nil {
t.LogFailedWithErr(taskName, err)
@@ -241,15 +261,11 @@ func handleAppRecover(install *model.AppInstall, parentTask *task.Task, recoverF
if err != nil {
return err
}
newDB, envMap, err := reCreateDB(db.ID, database, oldInstall.Env)
newDB, err := reCreateDB(db.ID, database, backupEnvMap)
if err != nil {
return err
}
oldHost := fmt.Sprintf("\"PANEL_DB_HOST\":\"%v\"", envMap["PANEL_DB_HOST"].(string))
newHost := fmt.Sprintf("\"PANEL_DB_HOST\":\"%v\"", database.Address)
oldInstall.Env = strings.ReplaceAll(oldInstall.Env, oldHost, newHost)
envMap["PANEL_DB_HOST"] = database.Address
newEnvFile, err = coverEnvJsonToStr(oldInstall.Env)
backupInstall.Env, mergedEnvContent, err = buildRecoverEnv(backupInstall.Env, backupEnvMap, installedEnvMap)
if err != nil {
return err
}
@@ -284,22 +300,22 @@ func handleAppRecover(install *model.AppInstall, parentTask *task.Task, recoverF
t.LogSuccess(deCompressName)
_ = fileOp.DeleteDir(backPath)
if len(newEnvFile) != 0 {
if len(mergedEnvContent) != 0 {
envPath := fmt.Sprintf("%s/%s/.env", install.GetAppPath(), install.Name)
file, err := os.OpenFile(envPath, os.O_WRONLY|os.O_TRUNC, 0640)
if err != nil {
return err
}
defer file.Close()
_, _ = file.WriteString(newEnvFile)
_, _ = file.WriteString(mergedEnvContent)
}
oldInstall.ID = install.ID
oldInstall.Status = constant.StatusRunning
oldInstall.AppId = install.AppId
oldInstall.AppDetailId = install.AppDetailId
oldInstall.App.ID = install.AppId
if err := appInstallRepo.Save(context.Background(), &oldInstall); err != nil {
backupInstall.ID = install.ID
backupInstall.Status = constant.StatusRunning
backupInstall.AppId = install.AppId
backupInstall.AppDetailId = install.AppDetailId
backupInstall.App.ID = install.AppId
if err := appInstallRepo.Save(context.Background(), &backupInstall); err != nil {
global.LOG.Errorf("save db app install failed, err: %v", err)
return err
}
@@ -373,33 +389,131 @@ func doAppBackup(install *model.AppInstall, parentTask *task.Task, backupDir, fi
return nil
}
func reCreateDB(dbID uint, database model.Database, oldEnv string) (*model.DatabaseMysql, map[string]interface{}, error) {
func reCreateDB(dbID uint, database model.Database, envMap map[string]interface{}) (*model.DatabaseMysql, error) {
mysqlService := NewIMysqlService()
ctx := context.Background()
_ = mysqlService.Delete(ctx, dto.MysqlDBDelete{ID: dbID, Database: database.Name, Type: database.Type, DeleteBackup: false, ForceDelete: true})
envMap := make(map[string]interface{})
if err := json.Unmarshal([]byte(oldEnv), &envMap); err != nil {
return nil, envMap, err
}
oldName, _ := envMap["PANEL_DB_NAME"].(string)
oldUser, _ := envMap["PANEL_DB_USER"].(string)
oldPassword, _ := envMap["PANEL_DB_USER_PASSWORD"].(string)
dbInfo := getDBCreateInfoFromEnv(envMap, "utf8mb4")
createDB, err := mysqlService.Create(context.Background(), dto.MysqlDBCreate{
Name: oldName,
Name: dbInfo.Name,
From: database.From,
Database: database.Name,
Format: "utf8mb4",
Username: oldUser,
Password: oldPassword,
Format: dbInfo.Format,
Username: dbInfo.User,
Password: dbInfo.Password,
Permission: "%",
})
cronjobs, _ := cronjobRepo.List(cronjobRepo.WithByDbName(fmt.Sprintf("%v", dbID)))
for _, job := range cronjobs {
_ = cronjobRepo.Update(job.ID, map[string]interface{}{"db_name": fmt.Sprintf("%v", createDB.ID)})
}
if err != nil {
return nil, envMap, err
return nil, err
}
return createDB, envMap, nil
updateCronjobsDBRef(dbID, createDB.ID)
return createDB, nil
}
func reCreatePostgresqlDB(dbID uint, database model.Database, envMap map[string]interface{}) (*model.DatabasePostgresql, error) {
postgresqlService := NewIPostgresqlService()
_ = postgresqlService.Delete(context.Background(), dto.PostgresqlDBDelete{
ID: dbID,
Type: database.Type,
Database: database.Name,
DeleteBackup: false,
ForceDelete: true,
})
dbInfo := getDBCreateInfoFromEnv(envMap, "UTF8")
createDB, err := postgresqlService.Create(context.Background(), dto.PostgresqlDBCreate{
Name: dbInfo.Name,
From: database.From,
Database: database.Name,
Format: dbInfo.Format,
Username: dbInfo.User,
Password: dbInfo.Password,
SuperUser: true,
})
if err != nil {
return nil, err
}
updateCronjobsDBRef(dbID, createDB.ID)
return createDB, nil
}
type dbRecreateInfo struct {
Name string
User string
Password string
Format string
}
func getDBCreateInfoFromEnv(envMap map[string]interface{}, defaultFormat string) dbRecreateInfo {
name, _ := envMap["PANEL_DB_NAME"].(string)
user, _ := envMap["PANEL_DB_USER"].(string)
password, _ := envMap["PANEL_DB_USER_PASSWORD"].(string)
format, _ := envMap["format"].(string)
if len(format) == 0 {
format = defaultFormat
}
return dbRecreateInfo{
Name: name,
User: user,
Password: password,
Format: format,
}
}
func updateCronjobsDBRef(oldDBID, newDBID uint) {
cronjobs, _ := cronjobRepo.List(cronjobRepo.WithByDbName(fmt.Sprintf("%v", oldDBID)))
for _, job := range cronjobs {
_ = cronjobRepo.Update(job.ID, map[string]interface{}{"db_name": fmt.Sprintf("%v", newDBID)})
}
}
func buildRecoverEnv(appEnv string, backupEnvMap, installedEnvMap map[string]interface{}) (string, string, error) {
currentHostVal, hasCurrentHost := installedEnvMap["PANEL_DB_HOST"]
if hasCurrentHost && fmt.Sprintf("%v", currentHostVal) != "" {
backupHost := fmt.Sprintf("\"PANEL_DB_HOST\":\"%v\"", backupEnvMap["PANEL_DB_HOST"])
currentHost := fmt.Sprintf("\"PANEL_DB_HOST\":\"%v\"", currentHostVal)
appEnv = strings.ReplaceAll(appEnv, backupHost, currentHost)
if _, ok := backupEnvMap["CASDOOR_DATASOURCE_NAME"]; ok {
backupEnvMap["CASDOOR_DATASOURCE_NAME"] = strings.ReplaceAll(
fmt.Sprintf("%v", backupEnvMap["CASDOOR_DATASOURCE_NAME"]),
fmt.Sprintf("%v", backupEnvMap["PANEL_DB_HOST"]),
fmt.Sprintf("%v", currentHostVal),
)
}
backupEnvMap["PANEL_DB_HOST"] = currentHostVal
}
newEnvMap := make(map[string]string, len(backupEnvMap))
handleMap(backupEnvMap, newEnvMap)
mergedEnvContent, err := gotenv.Marshal(newEnvMap)
if err != nil {
return appEnv, "", err
}
return appEnv, mergedEnvContent, nil
}
func getEnvMapByPath(envPath string) (map[string]interface{}, error) {
envMap := make(map[string]interface{})
envFile, err := os.ReadFile(envPath)
if err != nil {
return envMap, err
}
lines := strings.Split(string(envFile), "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
parts := strings.SplitN(line, "=", 2)
if len(parts) == 2 {
value := strings.TrimSpace(parts[1])
if len(value) >= 2 {
if (value[0] == '"' && value[len(value)-1] == '"') || (value[0] == '\'' && value[len(value)-1] == '\'') {
value = value[1 : len(value)-1]
}
}
envMap[parts[0]] = value
}
}
return envMap, nil
}
+707
View File
@@ -0,0 +1,707 @@
package service
import (
"context"
"encoding/json"
"fmt"
"io/fs"
"os"
"path"
"path/filepath"
"sort"
"strings"
"time"
"github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/app/repo"
"github.com/1Panel-dev/1Panel/agent/app/task"
"github.com/1Panel-dev/1Panel/agent/buserr"
"github.com/1Panel-dev/1Panel/agent/constant"
"github.com/1Panel-dev/1Panel/agent/global"
"github.com/1Panel-dev/1Panel/agent/i18n"
"github.com/1Panel-dev/1Panel/agent/utils/common"
"github.com/1Panel-dev/1Panel/agent/utils/compose"
dockerUtils "github.com/1Panel-dev/1Panel/agent/utils/docker"
"github.com/1Panel-dev/1Panel/agent/utils/files"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/filters"
"github.com/docker/docker/client"
)
type composeBackupFile struct {
OriginalPath string `json:"originalPath"`
FileName string `json:"fileName"`
RelativePath string `json:"relativePath,omitempty"`
BackupPath string `json:"backupPath"`
}
type composeBackupMeta struct {
ComposeName string `json:"composeName"`
ComposePath string `json:"composePath"`
CreatedAt string `json:"createdAt"`
Files []composeBackupFile `json:"files"`
Containers []string `json:"containers"`
}
type composeBackupContext struct {
req dto.CommonBackup
composeName string
composePath string
composeFiles []string
composeDir string
fileOp files.FileOp
dockerClient *client.Client
stopped bool
backupDir string
fileName string
filePath string
tmpDir string
meta composeBackupMeta
}
type composeRecoverContext struct {
req dto.CommonRecover
fileOp files.FileOp
tmpDir string
meta composeBackupMeta
composeName string
targetDir string
composePath string
enqueued bool
}
func (u *BackupService) ComposeBackup(req dto.CommonBackup) error {
timeNow := time.Now().Format(constant.DateTimeSlimLayout) + common.RandStrAndNum(5)
fileName := req.FileName
if fileName == "" {
fileName = fmt.Sprintf("%s_%s.tar.gz", req.Name, timeNow)
}
if !strings.HasSuffix(fileName, ".tar.gz") {
fileName += ".tar.gz"
}
itemDir := fmt.Sprintf("compose/%s", req.Name)
backupDir := path.Join(global.Dir.LocalBackupDir, itemDir)
record := &model.BackupRecord{
Type: req.Type,
Name: req.Name,
SourceAccountIDs: "1",
DownloadAccountID: 1,
FileDir: itemDir,
FileName: fileName,
TaskID: req.TaskID,
Status: constant.StatusWaiting,
Description: req.Description,
}
if err := backupRepo.CreateRecord(record); err != nil {
global.LOG.Errorf("save compose backup record failed, err: %v", err)
return err
}
if err := handleComposeBackup(req, nil, record.ID, backupDir, fileName); err != nil {
backupRepo.UpdateRecordByMap(record.ID, map[string]interface{}{"status": constant.StatusFailed, "message": err.Error()})
return err
}
return nil
}
func (u *BackupService) ComposeRecover(req dto.CommonRecover) error {
return handleComposeRecover(req, nil)
}
func handleComposeBackup(req dto.CommonBackup, parentTask *task.Task, recordID uint, backupDir, fileName string) error {
composeCtx, err := newComposeBackupContext(req, backupDir, fileName)
if err != nil {
return err
}
containerNames, err := loadComposeContainerNames(composeCtx)
if err != nil {
composeCtx.close()
return err
}
backupTask := parentTask
if backupTask == nil {
backupTask, err = task.NewTaskWithOps(composeCtx.composeName, task.TaskBackup, task.TaskScopeBackup, req.TaskID, 1)
if err != nil {
return err
}
}
if req.StopBefore {
backupTask.AddSubTaskWithOps(i18n.GetMsgByKey("ComposeBackupStop"), func(t *task.Task) error {
return stepStopComposeForBackup(composeCtx)
}, func(t *task.Task) {
_ = stepStartComposeAfterBackup(composeCtx)
}, 3, time.Hour)
}
backupTask.AddSubTaskWithOps(i18n.GetMsgByKey("ComposeBackupPrepare"), func(t *task.Task) error { return stepPrepareComposeBackup(composeCtx) }, nil, 3, time.Hour)
backupTask.AddSubTaskWithOps(i18n.GetMsgByKey("ComposeBackupFiles"), func(t *task.Task) error { return stepBackupComposeFiles(composeCtx) }, nil, 3, time.Hour)
backupTask.AddSubTaskWithOps(i18n.GetMsgByKey("ComposeBackupContainers"), func(t *task.Task) error { return nil }, nil, 3, time.Hour)
for _, containerName := range containerNames {
backupFileName := fmt.Sprintf("%s.tar.gz", sanitizeComposeFileName(containerName))
backupFile := path.Join(composeCtx.tmpDir, "containers", backupFileName)
if err := handleContainerBackup(containerName, backupTask, 0, path.Dir(backupFile), path.Base(backupFile), "", "", false); err != nil {
return err
}
composeCtx.meta.Containers = append(composeCtx.meta.Containers, path.Join("containers", backupFileName))
}
backupTask.AddSubTaskWithOps(i18n.GetMsgByKey("ComposeBackupMeta"), func(t *task.Task) error { return stepWriteComposeBackupMeta(composeCtx) }, nil, 3, time.Hour)
backupTask.AddSubTaskWithOps(task.GetTaskName(composeCtx.composeName, task.TaskBackup, task.TaskScopeBackup), func(t *task.Task) error {
return stepPackComposeBackup(composeCtx)
}, nil, 3, time.Hour)
if req.StopBefore {
backupTask.AddSubTaskWithOps(i18n.GetMsgByKey("ComposeBackupStart"), func(t *task.Task) error {
return stepStartComposeAfterBackup(composeCtx)
}, nil, 3, time.Hour)
}
backupTask.AddSubTaskWithOps(i18n.GetMsgByKey("ComposeBackupCleanup"), func(t *task.Task) error {
composeCtx.close()
return nil
}, nil, 0, time.Hour)
if parentTask != nil {
return nil
}
go func() {
defer composeCtx.close()
if err := backupTask.Execute(); err != nil {
backupRepo.UpdateRecordByMap(recordID, map[string]interface{}{"status": constant.StatusFailed, "message": err.Error()})
return
}
backupRepo.UpdateRecordByMap(recordID, map[string]interface{}{"status": constant.StatusSuccess})
}()
return nil
}
func loadComposeContainerNames(composeCtx *composeBackupContext) ([]string, error) {
options := container.ListOptions{All: true}
options.Filters = filters.NewArgs(filters.Arg("label", composeProjectLabel+"="+composeCtx.composeName))
containers, err := composeCtx.dockerClient.ContainerList(context.Background(), options)
if err != nil {
return nil, err
}
names := make([]string, 0, len(containers))
for _, item := range containers {
if len(item.Names) == 0 {
continue
}
names = append(names, strings.TrimPrefix(item.Names[0], "/"))
}
sort.Strings(names)
return names, nil
}
func handleComposeRecover(req dto.CommonRecover, parentTask *task.Task) error {
var recoverCtx *composeRecoverContext
recoverTask := parentTask
var err error
if recoverTask == nil {
if isImportRecover(req) {
taskName := i18n.GetMsgByKey("TaskImport") + i18n.GetMsgByKey("Compose")
recoverTask, err = task.NewTask(taskName, task.TaskImport, task.TaskScopeBackup, req.TaskID, 1)
if err != nil {
return err
}
} else {
taskName := req.Name
if taskName == "" {
taskName = "compose"
}
recoverTask, err = task.NewTaskWithOps(taskName, task.TaskRecover, task.TaskScopeBackup, req.TaskID, 1)
if err != nil {
return err
}
}
}
timeout := loadRecoverTimeout(req.Timeout)
recoverTask.AddSubTaskWithOps(i18n.GetMsgByKey("ComposeRecoverPrepare"), func(t *task.Task) error {
ctx, err := newComposeRecoverContext(req)
if err != nil {
return err
}
recoverCtx = ctx
if err := stepPrepareComposeRecover(recoverCtx); err != nil {
recoverCtx.close()
recoverCtx = nil
return err
}
return nil
}, func(t *task.Task) {
if recoverCtx != nil {
recoverCtx.close()
recoverCtx = nil
}
}, 3, timeout)
recoverTask.AddSubTaskWithOps(i18n.GetMsgByKey("ComposeRecoverExtract"), func(t *task.Task) error { return stepExtractComposeRecover(recoverCtx) }, nil, 3, timeout)
recoverTask.AddSubTaskWithOps(i18n.GetMsgByKey("ComposeRecoverMeta"), func(t *task.Task) error {
if err := stepLoadComposeRecoverMeta(recoverCtx); err != nil {
return err
}
t.Log(i18n.GetMsgWithMap("ComposeRecoverMetaLogName", map[string]interface{}{
"name": recoverCtx.composeName,
}))
t.Log(i18n.GetMsgWithMap("ComposeRecoverMetaLogPath", map[string]interface{}{
"backupPath": recoverCtx.meta.ComposePath,
"targetDir": recoverCtx.targetDir,
}))
t.Log(i18n.GetMsgWithMap("ComposeRecoverMetaLogCount", map[string]interface{}{
"files": len(recoverCtx.meta.Files),
"containers": len(recoverCtx.meta.Containers),
}))
return nil
}, nil, 3, timeout)
recoverTask.AddSubTaskWithOps(i18n.GetMsgByKey("ComposeRecoverFiles"), func(t *task.Task) error { return stepRestoreComposeFiles(recoverCtx) }, nil, 3, timeout)
recoverTask.AddSubTaskWithOps(i18n.GetMsgByKey("ComposeRecoverContainers"), func(t *task.Task) error {
if recoverCtx.enqueued {
return nil
}
containerItems := make([]string, 0, len(recoverCtx.meta.Containers))
for _, item := range recoverCtx.meta.Containers {
backupItem := item
filePath, err := safeJoinWithinBase(recoverCtx.tmpDir, backupItem)
if err != nil {
return fmt.Errorf("invalid container backup path %q, err: %v", backupItem, err)
}
if !recoverCtx.fileOp.Stat(filePath) {
return fmt.Errorf("container backup file not found: %s", backupItem)
}
containerItems = append(containerItems, backupItem)
}
for _, backupItem := range containerItems {
filePath, err := safeJoinWithinBase(recoverCtx.tmpDir, backupItem)
if err != nil {
return fmt.Errorf("invalid container backup path %q, err: %v", backupItem, err)
}
containerLabel := strings.TrimSuffix(path.Base(backupItem), ".tar.gz")
containerReq := recoverCtx.req
containerReq.Type = "container"
containerReq.Name = containerLabel
containerReq.DetailName = ""
containerReq.File = filePath
containerReq.Secret = ""
if err := handleContainerRecover(containerReq, recoverTask); err != nil {
return err
}
}
recoverTask.AddSubTaskWithOps(i18n.GetMsgByKey("ComposeRecoverRecord"), func(t *task.Task) error {
return stepSaveComposeRecord(recoverCtx)
}, nil, 3, timeout)
recoverTask.AddSubTaskWithOps(i18n.GetMsgByKey("ComposeRecoverCleanup"), func(t *task.Task) error {
if recoverCtx != nil {
recoverCtx.close()
recoverCtx = nil
}
return nil
}, nil, 0, timeout)
recoverCtx.enqueued = true
return nil
}, nil, 3, timeout)
if parentTask != nil {
return nil
}
go func() {
_ = recoverTask.Execute()
}()
return nil
}
func newComposeBackupContext(req dto.CommonBackup, backupDir, fileName string) (*composeBackupContext, error) {
if req.Name == "" {
return nil, fmt.Errorf("compose name is required")
}
dockerClient, err := dockerUtils.NewDockerClient()
if err != nil {
return nil, err
}
composePath, composeFiles, err := loadComposePathAndFiles(req.Name, dockerClient)
if err != nil {
_ = dockerClient.Close()
return nil, err
}
filePath := path.Join(backupDir, fileName)
tmpDir := path.Join(path.Dir(filePath), strings.TrimSuffix(path.Base(filePath), ".tar.gz"))
ctx := &composeBackupContext{
req: req,
composeName: req.Name,
composePath: composePath,
composeFiles: composeFiles,
composeDir: path.Dir(composeFiles[0]),
fileOp: files.NewFileOp(),
dockerClient: dockerClient,
backupDir: backupDir,
fileName: fileName,
filePath: filePath,
tmpDir: tmpDir,
meta: composeBackupMeta{
ComposeName: req.Name,
ComposePath: composePath,
CreatedAt: time.Now().Format(constant.DateTimeLayout),
Files: make([]composeBackupFile, 0),
Containers: make([]string, 0),
},
}
return ctx, nil
}
func loadComposePathAndFiles(composeName string, dockerClient *client.Client) (string, []string, error) {
composeRecord, _ := composeRepo.GetRecord(repo.WithByName(composeName))
if composeRecord.ID == 0 {
composeRecord, _ = composeRepo.GetRecord(repo.WithByName(strings.ToLower(composeName)))
}
composePath := composeRecord.Path
if composePath == "" {
options := container.ListOptions{All: true}
options.Filters = filters.NewArgs(filters.Arg("label", composeProjectLabel))
list, err := dockerClient.ContainerList(context.Background(), options)
if err != nil {
return "", nil, err
}
if len(list) == 0 {
return "", nil, fmt.Errorf("compose %s not found", composeName)
}
var targetContainer *container.Summary
for i := range list {
if strings.EqualFold(list[i].Labels[composeProjectLabel], composeName) {
targetContainer = &list[i]
break
}
}
if targetContainer == nil {
return "", nil, fmt.Errorf("compose %s not found", composeName)
}
config := targetContainer.Labels[composeConfigLabel]
workdir := targetContainer.Labels[composeWorkdirLabel]
if len(config) != 0 && len(workdir) != 0 && strings.Contains(config, workdir) {
composePath = config
} else {
composePath = workdir
}
}
composeFiles := normalizeComposeFiles(composePath)
if len(composeFiles) == 0 {
return "", nil, fmt.Errorf("compose file not found for %s", composeName)
}
return composePath, composeFiles, nil
}
func normalizeComposeFiles(composePath string) []string {
items := strings.Split(composePath, ",")
result := make([]string, 0)
seen := make(map[string]struct{})
for _, item := range items {
item = strings.TrimSpace(item)
if item == "" {
continue
}
stat, err := os.Stat(item)
if err == nil && stat.IsDir() {
item = path.Join(item, "docker-compose.yml")
}
if _, err := os.Stat(item); err != nil {
continue
}
if _, ok := seen[item]; ok {
continue
}
seen[item] = struct{}{}
result = append(result, item)
}
sort.Strings(result)
return result
}
func (c *composeBackupContext) close() {
if c.dockerClient != nil {
_ = c.dockerClient.Close()
c.dockerClient = nil
}
if c.tmpDir != "" {
_ = os.RemoveAll(c.tmpDir)
c.tmpDir = ""
}
}
func stepPrepareComposeBackup(composeCtx *composeBackupContext) error {
if err := os.MkdirAll(composeCtx.backupDir, os.ModePerm); err != nil {
return fmt.Errorf("mkdir %s failed, err: %v", composeCtx.backupDir, err)
}
_ = os.RemoveAll(composeCtx.tmpDir)
if err := os.MkdirAll(path.Join(composeCtx.tmpDir, "compose_files"), os.ModePerm); err != nil {
return err
}
if err := os.MkdirAll(path.Join(composeCtx.tmpDir, "containers"), os.ModePerm); err != nil {
return err
}
return nil
}
func stepStopComposeForBackup(composeCtx *composeBackupContext) error {
if composeCtx.stopped {
return nil
}
options := container.ListOptions{All: false}
options.Filters = filters.NewArgs(filters.Arg("label", composeProjectLabel+"="+composeCtx.composeName))
runningList, err := composeCtx.dockerClient.ContainerList(context.Background(), options)
if err != nil {
return err
}
if len(runningList) == 0 {
return nil
}
if stdout, err := compose.Operate(composeCtx.composePath, "stop"); err != nil {
return fmt.Errorf("docker-compose stop failed, std: %s, err: %v", stdout, err)
}
composeCtx.stopped = true
return nil
}
func stepStartComposeAfterBackup(composeCtx *composeBackupContext) error {
if !composeCtx.stopped {
return nil
}
if stdout, err := compose.Up(composeCtx.composePath); err != nil {
return fmt.Errorf("docker-compose up failed, std: %s, err: %v", stdout, err)
}
composeCtx.stopped = false
return nil
}
func stepBackupComposeFiles(composeCtx *composeBackupContext) error {
for i, filePath := range composeCtx.composeFiles {
backupName := fmt.Sprintf("%02d_%s", i, path.Base(filePath))
backupPath := path.Join(composeCtx.tmpDir, "compose_files", backupName)
content, err := os.ReadFile(filePath)
if err != nil {
return err
}
if err := composeCtx.fileOp.SaveFile(backupPath, string(content), fs.ModePerm); err != nil {
return err
}
relativePath := path.Base(filePath)
if composeCtx.composeDir != "" {
rel, relErr := filepath.Rel(composeCtx.composeDir, filePath)
if relErr == nil {
rel = filepath.ToSlash(rel)
if rel != "" && rel != "." && !strings.HasPrefix(rel, "../") {
relativePath = rel
}
}
}
composeCtx.meta.Files = append(composeCtx.meta.Files, composeBackupFile{
OriginalPath: filePath,
FileName: path.Base(filePath),
RelativePath: relativePath,
BackupPath: path.Join("compose_files", backupName),
})
}
if len(composeCtx.composeFiles) != 0 {
envPath := path.Join(path.Dir(composeCtx.composeFiles[0]), ".env")
if composeCtx.fileOp.Stat(envPath) {
envContent, err := os.ReadFile(envPath)
if err != nil {
return err
}
if err := composeCtx.fileOp.SaveFile(path.Join(composeCtx.tmpDir, "compose_files", ".env"), string(envContent), fs.ModePerm); err != nil {
return err
}
}
}
return nil
}
func stepWriteComposeBackupMeta(composeCtx *composeBackupContext) error {
metaBytes, err := json.MarshalIndent(composeCtx.meta, "", " ")
if err != nil {
return err
}
return composeCtx.fileOp.SaveFile(path.Join(composeCtx.tmpDir, "compose_meta.json"), string(metaBytes), fs.ModePerm)
}
func stepPackComposeBackup(composeCtx *composeBackupContext) error {
return composeCtx.fileOp.TarGzCompressPro(true, composeCtx.tmpDir, composeCtx.filePath, composeCtx.req.Secret, "")
}
func newComposeRecoverContext(req dto.CommonRecover) (*composeRecoverContext, error) {
tmpDir := path.Join(path.Dir(req.File), strings.TrimSuffix(path.Base(req.File), ".tar.gz"))
ctx := &composeRecoverContext{
req: req,
fileOp: files.NewFileOp(),
tmpDir: tmpDir,
meta: composeBackupMeta{
Files: make([]composeBackupFile, 0),
Containers: make([]string, 0),
},
}
return ctx, nil
}
func (c *composeRecoverContext) close() {
if c.tmpDir != "" {
_ = os.RemoveAll(c.tmpDir)
}
}
func stepPrepareComposeRecover(recoverCtx *composeRecoverContext) error {
if !recoverCtx.fileOp.Stat(recoverCtx.req.File) {
return buserr.WithName("ErrFileNotFound", recoverCtx.req.File)
}
_ = os.RemoveAll(recoverCtx.tmpDir)
return nil
}
func stepExtractComposeRecover(recoverCtx *composeRecoverContext) error {
return recoverCtx.fileOp.TarGzExtractPro(recoverCtx.req.File, path.Dir(recoverCtx.req.File), recoverCtx.req.Secret)
}
func stepLoadComposeRecoverMeta(recoverCtx *composeRecoverContext) error {
metaPath := path.Join(recoverCtx.tmpDir, "compose_meta.json")
if !recoverCtx.fileOp.Stat(metaPath) {
return fmt.Errorf("compose_meta.json not found in backup file")
}
metaBytes, err := os.ReadFile(metaPath)
if err != nil {
return err
}
if err := json.Unmarshal(metaBytes, &recoverCtx.meta); err != nil {
return fmt.Errorf("unmarshal compose_meta.json failed, err: %v", err)
}
recoverCtx.composeName = strings.TrimSpace(recoverCtx.req.Name)
if recoverCtx.composeName == "" {
recoverCtx.composeName = strings.TrimSpace(recoverCtx.meta.ComposeName)
}
if recoverCtx.composeName == "" {
return fmt.Errorf("compose name not found in recover request or backup file")
}
recoverCtx.targetDir = resolveComposeRecoverTargetDir(recoverCtx.meta, recoverCtx.composeName)
return nil
}
func resolveComposeRecoverTargetDir(meta composeBackupMeta, composeName string) string {
composePath := strings.TrimSpace(meta.ComposePath)
if composePath != "" {
items := strings.Split(composePath, ",")
for _, item := range items {
p := strings.TrimSpace(item)
if p == "" {
continue
}
ext := strings.ToLower(path.Ext(p))
if ext == ".yml" || ext == ".yaml" {
return path.Dir(p)
}
return p
}
}
return path.Join(global.Dir.DataDir, "docker/compose", composeName)
}
func safeJoinWithinBase(baseDir, name string) (string, error) {
base := filepath.Clean(baseDir)
candidate := strings.TrimSpace(name)
candidate = strings.ReplaceAll(candidate, "\\", "/")
candidate = filepath.Clean(filepath.FromSlash(candidate))
if candidate == "" || candidate == "." {
return "", fmt.Errorf("invalid path: empty")
}
if filepath.IsAbs(candidate) {
return "", fmt.Errorf("invalid path %q: absolute path is not allowed", name)
}
if candidate == ".." || strings.HasPrefix(candidate, ".."+string(filepath.Separator)) {
return "", fmt.Errorf("invalid path %q: path escapes base directory", name)
}
resolved := filepath.Clean(filepath.Join(base, candidate))
rel, err := filepath.Rel(base, resolved)
if err != nil {
return "", fmt.Errorf("resolve path %q failed, err: %v", name, err)
}
if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
return "", fmt.Errorf("invalid path %q: path escapes base directory", name)
}
return resolved, nil
}
func stepRestoreComposeFiles(recoverCtx *composeRecoverContext) error {
if recoverCtx.targetDir != "" {
_ = os.RemoveAll(recoverCtx.targetDir)
}
if err := os.MkdirAll(recoverCtx.targetDir, os.ModePerm); err != nil {
return err
}
restored := make([]string, 0, len(recoverCtx.meta.Files))
for _, item := range recoverCtx.meta.Files {
backupPath, err := safeJoinWithinBase(recoverCtx.tmpDir, item.BackupPath)
if err != nil {
return fmt.Errorf("invalid compose backup path %q, err: %v", item.BackupPath, err)
}
if !recoverCtx.fileOp.Stat(backupPath) {
continue
}
targetName := item.FileName
if item.RelativePath != "" {
targetName = item.RelativePath
}
if targetName == "" {
targetName = path.Base(item.OriginalPath)
}
if targetName == "" {
targetName = "docker-compose.yml"
}
targetPath, err := safeJoinWithinBase(recoverCtx.targetDir, targetName)
if err != nil {
return fmt.Errorf("invalid compose target path %q, err: %v", targetName, err)
}
if err := os.MkdirAll(path.Dir(targetPath), os.ModePerm); err != nil {
return err
}
content, err := os.ReadFile(backupPath)
if err != nil {
return err
}
if err := recoverCtx.fileOp.SaveFile(targetPath, string(content), fs.ModePerm); err != nil {
return err
}
restored = append(restored, targetPath)
}
envPath := path.Join(recoverCtx.tmpDir, "compose_files", ".env")
if recoverCtx.fileOp.Stat(envPath) {
envContent, err := os.ReadFile(envPath)
if err != nil {
return err
}
if err := recoverCtx.fileOp.SaveFile(path.Join(recoverCtx.targetDir, ".env"), string(envContent), fs.ModePerm); err != nil {
return err
}
}
if len(restored) == 0 {
defaultPath := path.Join(recoverCtx.targetDir, "docker-compose.yml")
if !recoverCtx.fileOp.Stat(defaultPath) {
return fmt.Errorf("compose file not found in backup data")
}
restored = append(restored, defaultPath)
}
sort.Strings(restored)
recoverCtx.composePath = strings.Join(restored, ",")
return nil
}
func stepSaveComposeRecord(recoverCtx *composeRecoverContext) error {
if recoverCtx.composePath == "" {
recoverCtx.composePath = path.Join(recoverCtx.targetDir, "docker-compose.yml")
}
recordName := strings.ToLower(recoverCtx.composeName)
record, _ := composeRepo.GetRecord(repo.WithByName(recordName))
if record.ID == 0 {
return composeRepo.CreateRecord(&model.Compose{Name: recordName, Path: recoverCtx.composePath})
}
return composeRepo.UpdateRecord(recordName, map[string]interface{}{"path": recoverCtx.composePath})
}
func sanitizeComposeFileName(in string) string {
name := strings.TrimSpace(in)
name = strings.ReplaceAll(name, "/", "_")
name = strings.ReplaceAll(name, ":", "_")
if name == "" {
return "container"
}
return name
}
+914
View File
@@ -0,0 +1,914 @@
package service
import (
"context"
"encoding/json"
"fmt"
"io/fs"
"os"
"path"
"sort"
"strings"
"time"
"github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/app/task"
"github.com/1Panel-dev/1Panel/agent/buserr"
"github.com/1Panel-dev/1Panel/agent/constant"
"github.com/1Panel-dev/1Panel/agent/global"
"github.com/1Panel-dev/1Panel/agent/i18n"
"github.com/1Panel-dev/1Panel/agent/utils/common"
dockerUtils "github.com/1Panel-dev/1Panel/agent/utils/docker"
"github.com/1Panel-dev/1Panel/agent/utils/files"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/mount"
"github.com/docker/docker/api/types/network"
"github.com/docker/docker/api/types/volume"
"github.com/docker/docker/client"
)
type containerBackupMeta struct {
ContainerName string `json:"containerName"`
ContainerID string `json:"containerID"`
CreatedAt string `json:"createdAt"`
Image string `json:"image"`
HostConfig *container.HostConfig `json:"hostConfig,omitempty"`
Config *container.Config `json:"config,omitempty"`
Mounts []containerMountBackup `json:"mounts"`
}
type containerMountBackup struct {
Type string `json:"type"`
Name string `json:"name,omitempty"`
Source string `json:"source,omitempty"`
Destination string `json:"destination"`
Mode string `json:"mode,omitempty"`
RW bool `json:"rw"`
Propagation string `json:"propagation,omitempty"`
BackupPath string `json:"backupPath,omitempty"`
Status string `json:"status"`
Message string `json:"message,omitempty"`
}
type containerBackupContext struct {
containerName string
backupDir string
fileName string
secret string
filePath string
tmpDir string
mountRoot string
wasRunning bool
stopped bool
fileOp files.FileOp
inspectInfo container.InspectResponse
meta containerBackupMeta
}
type containerRecoverContext struct {
req dto.CommonRecover
targetName string
fileOp files.FileOp
client *client.Client
tmpDir string
meta containerBackupMeta
inspectInfo container.InspectResponse
shouldStart bool
createdContainerID string
}
func (u *BackupService) ContainerBackup(req dto.CommonBackup) error {
timeNow := time.Now().Format(constant.DateTimeSlimLayout) + common.RandStrAndNum(5)
fileName := req.FileName
if fileName == "" {
fileName = fmt.Sprintf("%s_%s.tar.gz", req.Name, timeNow)
}
if !strings.HasSuffix(fileName, ".tar.gz") {
fileName += ".tar.gz"
}
itemDir := fmt.Sprintf("container/%s", req.Name)
backupDir := path.Join(global.Dir.LocalBackupDir, itemDir)
record := &model.BackupRecord{
Type: req.Type,
Name: req.Name,
SourceAccountIDs: "1",
DownloadAccountID: 1,
FileDir: itemDir,
FileName: fileName,
TaskID: req.TaskID,
Status: constant.StatusWaiting,
Description: req.Description,
}
if err := backupRepo.CreateRecord(record); err != nil {
global.LOG.Errorf("save backup record failed, err: %v", err)
return err
}
if err := handleContainerBackup(req.Name, nil, record.ID, backupDir, fileName, req.TaskID, req.Secret, req.StopBefore); err != nil {
backupRepo.UpdateRecordByMap(record.ID, map[string]interface{}{"status": constant.StatusFailed, "message": err.Error()})
return err
}
return nil
}
func (u *BackupService) ContainerRecover(req dto.CommonRecover) error {
return handleContainerRecover(req, nil)
}
func handleContainerBackup(containerName string, parentTask *task.Task, recordID uint, backupDir, fileName, taskID, secret string, stopBefore bool) error {
var (
err error
backupTask *task.Task
)
backupCtx, err := newContainerBackupContext(containerName, backupDir, fileName, secret)
if err != nil {
return err
}
backupTask = parentTask
if backupTask == nil {
backupTask, err = task.NewTaskWithOps(containerName, task.TaskBackup, task.TaskScopeBackup, taskID, 1)
if err != nil {
return err
}
}
if stopBefore {
backupTask.AddSubTaskWithOps(i18n.GetMsgByKey("ContainerBackupStop"), func(t *task.Task) error {
return stepStopContainerForBackup(backupCtx)
}, func(t *task.Task) {
_ = stepStartContainerAfterBackup(backupCtx)
}, 3, time.Hour)
}
backupTask.AddSubTaskWithOps(i18n.GetMsgByKey("ContainerBackupPrepare"), func(t *task.Task) error {
t.Logf("------------------ %s ------------------", containerName)
return stepPrepareContainerBackup(backupCtx)
}, nil, 3, time.Hour)
backupTask.AddSubTaskWithOps(i18n.GetMsgByKey("ContainerBackupInspect"), func(t *task.Task) error { return stepBackupContainerInspect(backupCtx) }, nil, 3, time.Hour)
backupTask.AddSubTaskWithOps(i18n.GetMsgByKey("ContainerBackupMounts"), func(t *task.Task) error { return stepBackupContainerMounts(backupCtx) }, nil, 3, time.Hour)
backupTask.AddSubTaskWithOps(i18n.GetMsgByKey("ContainerBackupMeta"), func(t *task.Task) error { return stepWriteContainerMeta(backupCtx) }, nil, 3, time.Hour)
backupTask.AddSubTaskWithOps(task.GetTaskName(containerName, task.TaskBackup, task.TaskScopeBackup), func(t *task.Task) error { return stepPackContainerBackup(backupCtx) }, nil, 3, time.Hour)
if stopBefore {
backupTask.AddSubTaskWithOps(i18n.GetMsgByKey("ContainerBackupStart"), func(t *task.Task) error {
return stepStartContainerAfterBackup(backupCtx)
}, nil, 3, time.Hour)
}
if parentTask != nil {
return nil
}
go func() {
defer backupCtx.close()
if err := backupTask.Execute(); err != nil {
backupRepo.UpdateRecordByMap(recordID, map[string]interface{}{"status": constant.StatusFailed, "message": err.Error()})
return
}
backupRepo.UpdateRecordByMap(recordID, map[string]interface{}{"status": constant.StatusSuccess})
}()
return nil
}
func handleContainerRecover(req dto.CommonRecover, parentTask *task.Task) error {
var (
err error
recoverTask *task.Task
recoverCtx *containerRecoverContext
)
recoverTask = parentTask
if recoverTask == nil {
if isImportRecover(req) {
taskName := i18n.GetMsgByKey("TaskImport") + i18n.GetMsgByKey("Container")
recoverTask, err = task.NewTask(taskName, task.TaskImport, task.TaskScopeBackup, req.TaskID, 1)
if err != nil {
return err
}
} else {
recoverTask, err = task.NewTaskWithOps("container", task.TaskRecover, task.TaskScopeBackup, req.TaskID, 1)
if err != nil {
return err
}
}
}
timeout := loadRecoverTimeout(req.Timeout)
logName := strings.TrimSpace(req.Name)
if logName == "" && req.File != "" {
logName = strings.TrimSuffix(path.Base(req.File), ".tar.gz")
}
if logName == "" {
logName = "container"
}
recoverTask.AddSubTaskWithOps(i18n.GetMsgByKey("ContainerRecoverPrepare"), func(t *task.Task) error {
ctx, err := newContainerRecoverContext(req)
if err != nil {
return err
}
recoverCtx = ctx
t.Logf("------------------ %s ------------------", logName)
if err := stepPrepareContainerRecover(recoverCtx); err != nil {
recoverCtx.close()
recoverCtx = nil
return err
}
return nil
}, func(t *task.Task) {
if recoverCtx != nil {
recoverCtx.close()
recoverCtx = nil
}
}, 3, timeout)
recoverTask.AddSubTaskWithOps(i18n.GetMsgByKey("ContainerRecoverExtract"), func(t *task.Task) error { return stepExtractContainerRecover(recoverCtx) }, nil, 3, timeout)
recoverTask.AddSubTaskWithOps(i18n.GetMsgByKey("ContainerRecoverParse"), func(t *task.Task) error { return stepLoadContainerRecoverData(recoverCtx) }, nil, 3, timeout)
recoverTask.AddSubTaskWithOps(i18n.GetMsgByKey("ContainerRecoverCreate"), func(t *task.Task) error { return stepRecreateContainer(recoverCtx, t) }, nil, 3, timeout)
recoverTask.AddSubTaskWithOps(i18n.GetMsgByKey("ContainerRecoverMounts"), func(t *task.Task) error { return stepRestoreContainerMounts(recoverCtx) }, nil, 3, timeout)
recoverTask.AddSubTaskWithOps(i18n.GetMsgByKey("ContainerRecoverStart"), func(t *task.Task) error { return stepStartRecoveredContainer(recoverCtx) }, nil, 3, timeout)
recoverTask.AddSubTaskWithOps(i18n.GetMsgByKey("ContainerRecoverCleanup"), func(t *task.Task) error {
if recoverCtx != nil {
recoverCtx.close()
recoverCtx = nil
}
return nil
}, nil, 0, timeout)
if parentTask != nil {
return nil
}
go func() {
_ = recoverTask.Execute()
}()
return nil
}
func loadRecoverTimeout(timeout int) time.Duration {
switch timeout {
case -1:
return 0
case 0:
return 3 * time.Hour
default:
return time.Duration(timeout) * time.Second
}
}
func isImportRecover(req dto.CommonRecover) bool {
return req.BackupRecordID == 0
}
func newContainerBackupContext(containerName, backupDir, fileName, secret string) (*containerBackupContext, error) {
dockerClient, err := dockerUtils.NewDockerClient()
if err != nil {
return nil, err
}
defer func() {
_ = dockerClient.Close()
}()
inspectInfo, err := dockerClient.ContainerInspect(context.Background(), containerName)
if err != nil {
return nil, err
}
filePath := path.Join(backupDir, fileName)
tmpDir := path.Join(path.Dir(filePath), strings.TrimSuffix(path.Base(filePath), ".tar.gz"))
backupCtx := &containerBackupContext{
containerName: containerName,
backupDir: backupDir,
fileName: fileName,
secret: secret,
filePath: filePath,
tmpDir: tmpDir,
mountRoot: path.Join(tmpDir, "mounts"),
wasRunning: inspectInfo.State != nil && inspectInfo.State.Running,
fileOp: files.NewFileOp(),
inspectInfo: inspectInfo,
meta: containerBackupMeta{
ContainerName: containerName,
ContainerID: inspectInfo.ID,
CreatedAt: time.Now().Format(constant.DateTimeLayout),
Image: inspectInfo.Config.Image,
HostConfig: inspectInfo.HostConfig,
Config: inspectInfo.Config,
Mounts: make([]containerMountBackup, 0),
},
}
return backupCtx, nil
}
func newContainerRecoverContext(req dto.CommonRecover) (*containerRecoverContext, error) {
dockerClient, err := dockerUtils.NewDockerClient()
if err != nil {
return nil, err
}
tmpDir := path.Join(path.Dir(req.File), strings.TrimSuffix(path.Base(req.File), ".tar.gz"))
ctx := &containerRecoverContext{
req: req,
targetName: req.Name,
fileOp: files.NewFileOp(),
client: dockerClient,
tmpDir: tmpDir,
meta: containerBackupMeta{
Mounts: make([]containerMountBackup, 0),
},
}
return ctx, nil
}
func (c *containerBackupContext) close() {
if c.tmpDir != "" {
_ = os.RemoveAll(c.tmpDir)
}
}
func (c *containerRecoverContext) close() {
if c.client != nil {
_ = c.client.Close()
}
if c.tmpDir != "" {
_ = os.RemoveAll(c.tmpDir)
}
}
func stepPrepareContainerBackup(backupCtx *containerBackupContext) error {
if err := os.MkdirAll(backupCtx.backupDir, os.ModePerm); err != nil {
return fmt.Errorf("mkdir %s failed, err: %v", backupCtx.backupDir, err)
}
_ = os.RemoveAll(backupCtx.tmpDir)
if err := os.MkdirAll(backupCtx.mountRoot, os.ModePerm); err != nil {
return err
}
return nil
}
func stepStopContainerForBackup(backupCtx *containerBackupContext) error {
if !backupCtx.wasRunning || backupCtx.stopped {
return nil
}
dockerClient, err := dockerUtils.NewDockerClient()
if err != nil {
return err
}
defer func() {
_ = dockerClient.Close()
}()
if err := dockerClient.ContainerStop(context.Background(), backupCtx.inspectInfo.ID, container.StopOptions{}); err != nil {
return err
}
backupCtx.stopped = true
return nil
}
func stepStartContainerAfterBackup(backupCtx *containerBackupContext) error {
if !backupCtx.stopped {
return nil
}
dockerClient, err := dockerUtils.NewDockerClient()
if err != nil {
return err
}
defer func() {
_ = dockerClient.Close()
}()
if err := dockerClient.ContainerStart(context.Background(), backupCtx.inspectInfo.ID, container.StartOptions{}); err != nil {
return err
}
backupCtx.stopped = false
return nil
}
func stepBackupContainerInspect(backupCtx *containerBackupContext) error {
inspectBytes, err := json.MarshalIndent(backupCtx.inspectInfo, "", " ")
if err != nil {
return err
}
if err := backupCtx.fileOp.SaveFile(path.Join(backupCtx.tmpDir, "inspect.json"), string(inspectBytes), fs.ModePerm); err != nil {
return err
}
if backupCtx.inspectInfo.NetworkSettings != nil {
networkBytes, err := json.MarshalIndent(backupCtx.inspectInfo.NetworkSettings, "", " ")
if err != nil {
return err
}
if err := backupCtx.fileOp.SaveFile(path.Join(backupCtx.tmpDir, "network.json"), string(networkBytes), fs.ModePerm); err != nil {
return err
}
}
return nil
}
func stepBackupContainerMounts(backupCtx *containerBackupContext) error {
var (
dockerClient *client.Client
clientErr error
)
ensureClient := func() (*client.Client, error) {
if dockerClient != nil || clientErr != nil {
return dockerClient, clientErr
}
dockerClient, clientErr = dockerUtils.NewDockerClient()
return dockerClient, clientErr
}
defer func() {
if dockerClient != nil {
_ = dockerClient.Close()
}
}()
for i, item := range backupCtx.inspectInfo.Mounts {
mountMeta := containerMountBackup{
Type: string(item.Type),
Name: item.Name,
Source: item.Source,
Destination: item.Destination,
Mode: item.Mode,
RW: item.RW,
Propagation: string(item.Propagation),
Status: "skipped",
}
mountDirName := fmt.Sprintf("%02d_%s", i, sanitizeContainerMountName(item.Destination))
mountDir := path.Join(backupCtx.mountRoot, mountDirName)
mountMeta.BackupPath = path.Join("mounts", mountDirName, "data")
switch item.Type {
case mount.TypeBind, mount.TypeVolume:
if item.Source == "" {
mountMeta.Message = "empty source"
backupCtx.meta.Mounts = append(backupCtx.meta.Mounts, mountMeta)
continue
}
sourceInfo, statErr := os.Stat(item.Source)
if statErr != nil {
mountMeta.Message = statErr.Error()
backupCtx.meta.Mounts = append(backupCtx.meta.Mounts, mountMeta)
continue
}
dataDir := path.Join(mountDir, "data")
if err := os.MkdirAll(dataDir, os.ModePerm); err != nil {
return err
}
if sourceInfo.IsDir() {
if err := backupCtx.fileOp.CopyDirWithNewName(item.Source, dataDir, "."); err != nil {
return err
}
} else {
if err := backupCtx.fileOp.CopyFile(item.Source, dataDir); err != nil {
return err
}
}
if item.Type == mount.TypeVolume && item.Name != "" {
cli, err := ensureClient()
if err != nil {
return err
}
volumeInfo, volumeErr := cli.VolumeInspect(context.Background(), item.Name)
if volumeErr == nil {
volumeBytes, volumeMarshalErr := json.MarshalIndent(volumeInfo, "", " ")
if volumeMarshalErr == nil {
_ = backupCtx.fileOp.SaveFile(path.Join(mountDir, "volume.json"), string(volumeBytes), fs.ModePerm)
}
}
}
mountMeta.Status = "backed_up"
default:
mountMeta.Message = "mount type not supported for data backup"
}
backupCtx.meta.Mounts = append(backupCtx.meta.Mounts, mountMeta)
}
return nil
}
func stepWriteContainerMeta(backupCtx *containerBackupContext) error {
metaBytes, err := json.MarshalIndent(backupCtx.meta, "", " ")
if err != nil {
return err
}
if err := backupCtx.fileOp.SaveFile(path.Join(backupCtx.tmpDir, "meta.json"), string(metaBytes), fs.ModePerm); err != nil {
return err
}
return nil
}
func stepPackContainerBackup(backupCtx *containerBackupContext) error {
if err := backupCtx.fileOp.TarGzCompressPro(true, backupCtx.tmpDir, backupCtx.filePath, backupCtx.secret, ""); err != nil {
return err
}
return nil
}
func stepPrepareContainerRecover(recoverCtx *containerRecoverContext) error {
if !recoverCtx.fileOp.Stat(recoverCtx.req.File) {
return buserr.WithName("ErrFileNotFound", recoverCtx.req.File)
}
_ = os.RemoveAll(recoverCtx.tmpDir)
return nil
}
func stepExtractContainerRecover(recoverCtx *containerRecoverContext) error {
return recoverCtx.fileOp.TarGzExtractPro(recoverCtx.req.File, path.Dir(recoverCtx.req.File), recoverCtx.req.Secret)
}
func stepLoadContainerRecoverData(recoverCtx *containerRecoverContext) error {
if err := loadContainerRecoverMeta(recoverCtx); err != nil {
return err
}
if err := loadContainerRecoverInspect(recoverCtx); err != nil {
return err
}
if recoverCtx.targetName == "" {
recoverCtx.targetName = strings.TrimPrefix(recoverCtx.inspectInfo.Name, "/")
}
if recoverCtx.targetName == "" {
recoverCtx.targetName = recoverCtx.meta.ContainerName
}
if recoverCtx.targetName == "" {
return fmt.Errorf("container name not found in recover request or backup file")
}
if recoverCtx.inspectInfo.Config == nil {
recoverCtx.inspectInfo.Config = recoverCtx.meta.Config
}
if recoverCtx.inspectInfo.HostConfig == nil {
recoverCtx.inspectInfo.HostConfig = recoverCtx.meta.HostConfig
}
if recoverCtx.inspectInfo.Config == nil {
return fmt.Errorf("container config not found in backup file")
}
if recoverCtx.inspectInfo.HostConfig == nil {
recoverCtx.inspectInfo.HostConfig = &container.HostConfig{}
}
recoverCtx.shouldStart = recoverCtx.inspectInfo.State != nil && recoverCtx.inspectInfo.State.Running
return nil
}
func loadContainerRecoverMeta(recoverCtx *containerRecoverContext) error {
metaPath := path.Join(recoverCtx.tmpDir, "meta.json")
if !recoverCtx.fileOp.Stat(metaPath) {
return nil
}
metaBytes, err := os.ReadFile(metaPath)
if err != nil {
return err
}
if err := json.Unmarshal(metaBytes, &recoverCtx.meta); err != nil {
return fmt.Errorf("unmarshal meta.json failed, err: %v", err)
}
return nil
}
func loadContainerRecoverInspect(recoverCtx *containerRecoverContext) error {
inspectPath := path.Join(recoverCtx.tmpDir, "inspect.json")
if !recoverCtx.fileOp.Stat(inspectPath) {
return fmt.Errorf("inspect.json not found in backup file")
}
inspectBytes, err := os.ReadFile(inspectPath)
if err != nil {
return err
}
if err := json.Unmarshal(inspectBytes, &recoverCtx.inspectInfo); err != nil {
return fmt.Errorf("unmarshal inspect.json failed, err: %v", err)
}
return nil
}
func stepRecreateContainer(recoverCtx *containerRecoverContext, taskItem *task.Task) error {
ctx := context.Background()
if err := ensureContainerRecoverNetworks(recoverCtx); err != nil {
return err
}
if err := ensureContainerRecoverVolumes(recoverCtx); err != nil {
return err
}
config := cloneContainerConfig(recoverCtx.inspectInfo.Config)
hostConfig := cloneContainerHostConfig(recoverCtx.inspectInfo.HostConfig)
if config.Image == "" {
config.Image = recoverCtx.meta.Image
}
if config.Image == "" {
return fmt.Errorf("container image not found in backup file")
}
if !checkImageExist(recoverCtx.client, config.Image) {
if err := pullImages(taskItem, recoverCtx.client, config.Image); err != nil {
return err
}
}
if _, err := recoverCtx.client.ContainerInspect(ctx, recoverCtx.targetName); err == nil {
if err := recoverCtx.client.ContainerRemove(ctx, recoverCtx.targetName, container.RemoveOptions{Force: true, RemoveVolumes: false}); err != nil {
return err
}
} else if !client.IsErrNotFound(err) {
return err
}
networkConfig, extraNetworks := buildContainerRecoverNetworkConfig(recoverCtx.inspectInfo.NetworkSettings, hostConfig)
removeBridgeDriverIPAM(recoverCtx.client, networkConfig, extraNetworks)
createRes, err := recoverCtx.client.ContainerCreate(ctx, config, hostConfig, networkConfig, nil, recoverCtx.targetName)
if err != nil {
return err
}
recoverCtx.createdContainerID = createRes.ID
extraNames := make([]string, 0, len(extraNetworks))
for name := range extraNetworks {
extraNames = append(extraNames, name)
}
sort.Strings(extraNames)
for _, item := range extraNames {
if err := recoverCtx.client.NetworkConnect(ctx, item, recoverCtx.createdContainerID, extraNetworks[item]); err != nil {
return err
}
}
return nil
}
func removeBridgeDriverIPAM(cli *client.Client, primary *network.NetworkingConfig, extras map[string]*network.EndpointSettings) {
if primary != nil {
removeBridgeDriverIPAMFromEndpoints(cli, primary.EndpointsConfig)
}
removeBridgeDriverIPAMFromEndpoints(cli, extras)
}
func removeBridgeDriverIPAMFromEndpoints(cli *client.Client, endpoints map[string]*network.EndpointSettings) {
for netName, endpoint := range endpoints {
if endpoint == nil || endpoint.IPAMConfig == nil {
continue
}
info, err := cli.NetworkInspect(context.Background(), netName, network.InspectOptions{})
if err != nil {
continue
}
if info.Driver == "bridge" {
endpoint.IPAMConfig = nil
}
}
}
func ensureContainerRecoverNetworks(recoverCtx *containerRecoverContext) error {
if recoverCtx.inspectInfo.NetworkSettings == nil {
return nil
}
for netName := range recoverCtx.inspectInfo.NetworkSettings.Networks {
if netName == "" || netName == "bridge" || netName == "host" || netName == "none" {
continue
}
if _, err := recoverCtx.client.NetworkInspect(context.Background(), netName, network.InspectOptions{}); err != nil {
if !client.IsErrNotFound(err) {
return err
}
if _, err := recoverCtx.client.NetworkCreate(context.Background(), netName, network.CreateOptions{Driver: "bridge"}); err != nil {
return err
}
}
}
return nil
}
func ensureContainerRecoverVolumes(recoverCtx *containerRecoverContext) error {
for _, item := range recoverCtx.meta.Mounts {
if item.Type != string(mount.TypeVolume) || item.Name == "" {
continue
}
if _, err := recoverCtx.client.VolumeInspect(context.Background(), item.Name); err == nil {
continue
} else if !client.IsErrNotFound(err) {
return err
}
createOptions := volume.CreateOptions{Name: item.Name}
if item.BackupPath != "" {
volumeMetaPath := path.Join(recoverCtx.tmpDir, path.Dir(item.BackupPath), "volume.json")
if recoverCtx.fileOp.Stat(volumeMetaPath) {
volumeBytes, readErr := os.ReadFile(volumeMetaPath)
if readErr != nil {
return readErr
}
var volumeInfo volume.Volume
if unmarshalErr := json.Unmarshal(volumeBytes, &volumeInfo); unmarshalErr != nil {
return unmarshalErr
}
if volumeInfo.Driver != "" {
createOptions.Driver = volumeInfo.Driver
}
if len(volumeInfo.Options) != 0 {
createOptions.DriverOpts = volumeInfo.Options
}
if len(volumeInfo.Labels) != 0 {
createOptions.Labels = volumeInfo.Labels
}
}
}
if _, err := recoverCtx.client.VolumeCreate(context.Background(), createOptions); err != nil {
return err
}
}
return nil
}
func buildContainerRecoverNetworkConfig(networkSettings *container.NetworkSettings, hostConfig *container.HostConfig) (*network.NetworkingConfig, map[string]*network.EndpointSettings) {
extraNetworks := make(map[string]*network.EndpointSettings)
if hostConfig != nil {
networkMode := string(hostConfig.NetworkMode)
if networkMode == "host" || networkMode == "none" {
return nil, extraNetworks
}
}
if networkSettings == nil || len(networkSettings.Networks) == 0 {
return nil, extraNetworks
}
primaryName := ""
if hostConfig != nil {
networkMode := string(hostConfig.NetworkMode)
if networkMode != "" && networkMode != "default" && networkMode != "bridge" {
if _, ok := networkSettings.Networks[networkMode]; ok {
primaryName = networkMode
}
}
}
if primaryName == "" {
if _, ok := networkSettings.Networks["bridge"]; ok {
primaryName = "bridge"
} else {
names := make([]string, 0, len(networkSettings.Networks))
for name := range networkSettings.Networks {
names = append(names, name)
}
sort.Strings(names)
if len(names) > 0 {
primaryName = names[0]
}
}
}
config := &network.NetworkingConfig{EndpointsConfig: make(map[string]*network.EndpointSettings)}
for name, endpoint := range networkSettings.Networks {
if name == "host" || name == "none" {
continue
}
endpointSetting := &network.EndpointSettings{Aliases: append([]string(nil), endpoint.Aliases...), MacAddress: endpoint.MacAddress}
if endpoint.IPAMConfig != nil {
endpointSetting.IPAMConfig = &network.EndpointIPAMConfig{
IPv4Address: endpoint.IPAMConfig.IPv4Address,
IPv6Address: endpoint.IPAMConfig.IPv6Address,
}
} else if endpoint.IPAddress != "" || endpoint.GlobalIPv6Address != "" {
endpointSetting.IPAMConfig = &network.EndpointIPAMConfig{
IPv4Address: endpoint.IPAddress,
IPv6Address: endpoint.GlobalIPv6Address,
}
}
if name == primaryName {
config.EndpointsConfig[name] = endpointSetting
} else {
extraNetworks[name] = endpointSetting
}
}
if len(config.EndpointsConfig) == 0 {
return nil, extraNetworks
}
return config, extraNetworks
}
func cloneContainerConfig(config *container.Config) *container.Config {
if config == nil {
return &container.Config{}
}
item := *config
if len(config.Env) != 0 {
item.Env = append([]string(nil), config.Env...)
}
if len(config.Cmd) != 0 {
item.Cmd = append([]string(nil), config.Cmd...)
}
if len(config.Entrypoint) != 0 {
item.Entrypoint = append([]string(nil), config.Entrypoint...)
}
if len(config.Labels) != 0 {
labels := make(map[string]string, len(config.Labels))
for key, val := range config.Labels {
labels[key] = val
}
item.Labels = labels
}
if len(config.Volumes) != 0 {
volumes := make(map[string]struct{}, len(config.Volumes))
for key, val := range config.Volumes {
volumes[key] = val
}
item.Volumes = volumes
}
return &item
}
func cloneContainerHostConfig(hostConfig *container.HostConfig) *container.HostConfig {
if hostConfig == nil {
return &container.HostConfig{}
}
item := *hostConfig
if len(hostConfig.Binds) != 0 {
item.Binds = append([]string(nil), hostConfig.Binds...)
}
if len(hostConfig.DNS) != 0 {
item.DNS = append([]string(nil), hostConfig.DNS...)
}
if len(hostConfig.ExtraHosts) != 0 {
item.ExtraHosts = append([]string(nil), hostConfig.ExtraHosts...)
}
if len(hostConfig.Mounts) != 0 {
item.Mounts = append([]mount.Mount(nil), hostConfig.Mounts...)
}
return &item
}
func stepRestoreContainerMounts(recoverCtx *containerRecoverContext) error {
currentContainer := recoverCtx.createdContainerID
if currentContainer == "" {
currentContainer = recoverCtx.targetName
}
currentInspect, err := recoverCtx.client.ContainerInspect(context.Background(), currentContainer)
if err != nil {
return err
}
currentMounts := make(map[string]container.MountPoint, len(currentInspect.Mounts))
for _, item := range currentInspect.Mounts {
currentMounts[item.Destination] = item
}
for _, item := range recoverCtx.meta.Mounts {
if item.Status != "backed_up" || item.BackupPath == "" || !item.RW {
continue
}
backupPath := path.Join(recoverCtx.tmpDir, item.BackupPath)
if !recoverCtx.fileOp.Stat(backupPath) {
continue
}
sourcePath := item.Source
if currentMount, ok := currentMounts[item.Destination]; ok {
if currentMount.Source != "" {
sourcePath = currentMount.Source
}
if item.Type == string(mount.TypeVolume) && item.Name == "" {
item.Name = currentMount.Name
}
}
if sourcePath == "" && item.Type == string(mount.TypeVolume) && item.Name != "" {
volumeInfo, volumeErr := recoverCtx.client.VolumeInspect(context.Background(), item.Name)
if volumeErr != nil {
return volumeErr
}
sourcePath = volumeInfo.Mountpoint
}
if sourcePath == "" {
continue
}
if err := restoreContainerMountData(recoverCtx.fileOp, backupPath, sourcePath); err != nil {
return err
}
}
return nil
}
func restoreContainerMountData(fileOp files.FileOp, backupPath, sourcePath string) error {
if sourcePath == "/" {
return fmt.Errorf("invalid mount source path /")
}
entries, err := os.ReadDir(backupPath)
if err != nil {
return err
}
if len(entries) == 1 && !entries[0].IsDir() && entries[0].Name() == path.Base(sourcePath) {
if err := os.MkdirAll(path.Dir(sourcePath), os.ModePerm); err != nil {
return err
}
_ = os.RemoveAll(sourcePath)
if err := fileOp.CopyFile(path.Join(backupPath, entries[0].Name()), path.Dir(sourcePath)); err != nil {
return err
}
return nil
}
_ = os.RemoveAll(sourcePath)
if err := os.MkdirAll(sourcePath, os.ModePerm); err != nil {
return err
}
if err := fileOp.CopyDirWithNewName(backupPath, sourcePath, "."); err != nil {
return err
}
return nil
}
func stepStartRecoveredContainer(recoverCtx *containerRecoverContext) error {
if !recoverCtx.shouldStart {
return nil
}
containerID := recoverCtx.createdContainerID
if containerID == "" {
containerID = recoverCtx.targetName
}
return recoverCtx.client.ContainerStart(context.Background(), containerID, container.StartOptions{})
}
func sanitizeContainerMountName(in string) string {
name := strings.TrimSpace(in)
name = strings.Trim(name, "/")
name = strings.ReplaceAll(name, "/", "_")
name = strings.ReplaceAll(name, ":", "_")
if name == "" {
return "root"
}
return name
}
+8 -2
View File
@@ -44,7 +44,7 @@ func (u *BackupService) MysqlBackup(req dto.CommonBackup) error {
return err
}
databaseHelper := DatabaseHelper{Database: req.Name, DBType: req.Type, Name: req.DetailName}
databaseHelper := DatabaseHelper{Database: req.Name, DBType: req.Type, Name: req.DetailName, Args: req.Args}
if err := handleMysqlBackup(databaseHelper, nil, record.ID, targetDir, fileName, req.TaskID, req.Secret); err != nil {
backupRepo.UpdateRecordByMap(record.ID, map[string]interface{}{"status": constant.StatusFailed, "message": err.Error()})
return err
@@ -87,7 +87,12 @@ func handleMysqlBackup(db DatabaseHelper, parentTask *task.Task, recordID uint,
}
}
itemHandler := func() error { return doMysqlBackup(db, targetDir, fileName, secret) }
itemHandler := func() error {
if len(db.Args) != 0 {
backupTask.Logf("%s: %v", i18n.GetMsgByKey("Arg"), db.Args)
}
return doMysqlBackup(db, targetDir, fileName, secret)
}
if parentTask != nil {
return itemHandler()
}
@@ -225,6 +230,7 @@ func doMysqlBackup(db DatabaseHelper, targetDir, fileName, secret string) error
Format: dbInfo.Format,
TargetDir: targetDir,
FileName: fileName,
Args: db.Args,
}
if err := cli.Backup(backupInfo); err != nil {
return err
+5 -5
View File
@@ -38,7 +38,7 @@ func NewIBackupRecordService() IBackupRecordService {
func (u *BackupRecordService) SearchRecordsWithPage(search dto.RecordSearch) (int64, []dto.BackupRecords, error) {
total, records, err := backupRepo.PageRecord(
search.Page, search.PageSize,
repo.WithOrderBy("created_at desc"),
repo.WithOrderDesc("created_at"),
repo.WithByName(search.Name),
repo.WithByType(search.Type),
repo.WithByDetailName(search.DetailName),
@@ -69,7 +69,7 @@ func (u *BackupRecordService) SearchRecordsWithPage(search dto.RecordSearch) (in
func (u *BackupRecordService) SearchRecordsByCronjobWithPage(search dto.RecordSearchByCronjob) (int64, []dto.BackupRecords, error) {
total, records, err := backupRepo.PageRecord(
search.Page, search.PageSize,
repo.WithOrderBy("created_at desc"),
repo.WithOrderDesc("created_at"),
backupRepo.WithByCronID(search.CronjobID),
)
if err != nil {
@@ -166,7 +166,7 @@ func (u *BackupRecordService) BatchDeleteRecord(ids []uint) error {
func (u *BackupRecordService) ListAppRecords(name, detailName, fileName string) ([]model.BackupRecord, error) {
records, err := backupRepo.ListRecord(
repo.WithOrderBy("created_at asc"),
repo.WithOrderAsc("created_at"),
repo.WithByName(name),
repo.WithByType("app"),
backupRepo.WithFileNameStartWith(fileName),
@@ -221,7 +221,7 @@ func (u *BackupRecordService) LoadRecordSize(req dto.SearchForSize) ([]dto.Recor
list = append(list, backupSizeHelper{ID: item.ID, DownloadID: item.DownloadAccountID, FilePath: fmt.Sprintf("system_snapshot/%s.tar.gz", item.Name)})
}
case "cronjob":
_, records, err := backupRepo.PageRecord(req.Page, req.PageSize, repo.WithOrderBy("created_at desc"), backupRepo.WithByCronID(req.CronjobID))
_, records, err := backupRepo.PageRecord(req.Page, req.PageSize, repo.WithOrderDesc("created_at"), backupRepo.WithByCronID(req.CronjobID))
if err != nil {
return nil, err
}
@@ -231,7 +231,7 @@ func (u *BackupRecordService) LoadRecordSize(req dto.SearchForSize) ([]dto.Recor
default:
_, records, err := backupRepo.PageRecord(
req.Page, req.PageSize,
repo.WithOrderBy("created_at desc"),
repo.WithOrderDesc("created_at"),
repo.WithByName(req.Name),
repo.WithByType(req.Type),
repo.WithByDetailName(req.DetailName),
+3 -3
View File
@@ -96,7 +96,7 @@ func (c *ClamService) LoadBaseInfo() (dto.ClamBaseInfo, error) {
}
}
} else {
_ = clam.StopAllClamJob(false, clamRepo)
_ = clam.CheckWithStopAll(false, clamRepo)
}
if baseInfo.FreshIsActive {
version, err := cmd.RunDefaultWithStdoutBashC("freshclam --version")
@@ -313,7 +313,7 @@ func (c *ClamService) Delete(req dto.ClamDelete) error {
}
func (c *ClamService) HandleOnce(id uint) error {
if active := clam.StopAllClamJob(true, clamRepo); !active {
if active := clam.CheckWithStopAll(true, clamRepo); !active {
return buserr.New("ErrClamdscanNotFound")
}
clamItem, _ := clamRepo.Get(repo.WithByID(id))
@@ -337,9 +337,9 @@ func (c *ClamService) HandleOnce(id uint) error {
clamRepo.EndRecords(record, constant.StatusFailed, err.Error())
return
}
handleAlert(record.InfectedFiles, clamItem.Name, clamItem.ID)
clam.AnalysisFromLog(taskItem.LogFile, &record)
clamRepo.EndRecords(record, constant.StatusDone, "")
handleAlert(record.InfectedFiles, clamItem.Name, clamItem.ID)
}()
return nil
}
+495 -53
View File
@@ -1,7 +1,9 @@
package service
import (
"archive/tar"
"bufio"
"bytes"
"context"
"encoding/base64"
"encoding/json"
@@ -11,6 +13,7 @@ import (
"net/url"
"os"
"os/exec"
"path"
"path/filepath"
"sort"
"strconv"
@@ -40,10 +43,10 @@ import (
"github.com/docker/docker/api/types/registry"
"github.com/docker/docker/api/types/volume"
"github.com/docker/docker/client"
"github.com/docker/docker/pkg/stdcopy"
"github.com/docker/go-connections/nat"
"github.com/gin-gonic/gin"
v1 "github.com/opencontainers/image-spec/specs-go/v1"
"github.com/pkg/errors"
"github.com/shirou/gopsutil/v4/cpu"
"github.com/shirou/gopsutil/v4/mem"
)
@@ -59,11 +62,16 @@ type IContainerService interface {
ListNetwork() ([]dto.Options, error)
PageVolume(req dto.SearchWithPage) (int64, interface{}, error)
ListVolume() ([]dto.Options, error)
PageCompose(req dto.SearchWithPage) (int64, interface{}, error)
LoadComposeEnv(name string) (string, error)
CreateCompose(req dto.ComposeCreate) error
ComposeOperation(req dto.ComposeOperation) error
TestCompose(req dto.ComposeCreate) (bool, error)
ComposeUpdate(req dto.ComposeUpdate) error
ComposeLogClean(req dto.ComposeLogClean) error
ContainerCreate(req dto.ContainerOperate, inThread bool) error
ContainerCreateByCommand(req dto.ContainerCreateByCommand) error
ContainerUpdate(req dto.ContainerOperate) error
ContainerUpgrade(req dto.ContainerUpgrade) error
ContainerInfo(req dto.OperationWithName) (*dto.ContainerOperate, error)
@@ -74,19 +82,23 @@ type IContainerService interface {
ContainerCommit(req dto.ContainerCommit) error
ContainerLogClean(req dto.OperationWithName) error
ContainerOperation(req dto.ContainerOperation) error
DownloadContainerLogs(containerType, container, since, tail string, c *gin.Context) error
DownloadContainerLogs(containerType, container, since, tail string, timestamp bool, c *gin.Context) error
ContainerStats(id string) (*dto.ContainerStats, error)
Inspect(req dto.InspectReq) (string, error)
DeleteNetwork(req dto.BatchDelete) error
CreateNetwork(req dto.NetworkCreate) error
DeleteVolume(req dto.BatchDelete) error
CreateVolume(req dto.VolumeCreate) error
TestCompose(req dto.ComposeCreate) (bool, error)
ComposeUpdate(req dto.ComposeUpdate) error
ComposeLogClean(req dto.ComposeLogClean) error
Prune(req dto.ContainerPrune) error
LoadUsers(req dto.OperationWithName) []string
ListContainerFiles(req dto.ContainerFileReq) ([]dto.ContainerFileInfo, error)
UploadContainerFile(req dto.ContainerFileReq, fileName string, fileSize int64, file io.Reader) error
GetContainerFileContent(req dto.ContainerFileReq) (*dto.ContainerFileContent, error)
GetContainerFileSize(req dto.ContainerFileReq) (int64, error)
DeleteContainerFile(req dto.ContainerFileBatchDeleteReq) error
DownloadContainerFile(req dto.ContainerFileReq) (io.ReadCloser, string, string, error)
StreamLogs(ctx *gin.Context, params dto.StreamLog)
}
@@ -100,7 +112,7 @@ func (u *ContainerService) Page(req dto.PageContainer) (int64, interface{}, erro
if err != nil {
return 0, nil, err
}
defer client.Close()
defer func() { _ = client.Close() }()
options := container.ListOptions{All: true}
if len(req.Filters) != 0 {
options.Filters = filters.NewArgs()
@@ -298,42 +310,6 @@ func (u *ContainerService) ContainerListStats() ([]dto.ContainerListStats, error
return datas, nil
}
func (u *ContainerService) ContainerCreateByCommand(req dto.ContainerCreateByCommand) error {
if cmd.CheckIllegal(req.Command) {
return buserr.New("ErrCmdIllegal")
}
if !strings.HasPrefix(strings.TrimSpace(req.Command), "docker run ") {
return errors.New("error command format")
}
containerName := ""
commands := strings.Split(req.Command, " ")
for index, val := range commands {
if val == "--name" && len(commands) > index+1 {
containerName = commands[index+1]
}
}
if !strings.Contains(req.Command, " -d ") {
req.Command = strings.ReplaceAll(req.Command, "docker run", "docker run -d")
}
if len(containerName) == 0 {
containerName = fmt.Sprintf("1Panel-%s-%s", common.RandStr(5), common.RandStrAndNum(4))
req.Command += fmt.Sprintf(" --name %s", containerName)
}
taskItem, err := task.NewTaskWithOps(containerName, task.TaskCreate, task.TaskScopeContainer, req.TaskID, 1)
if err != nil {
global.LOG.Errorf("new task for create container failed, err: %v", err)
return err
}
go func() {
taskItem.AddSubTask(i18n.GetWithName("ContainerCreate", containerName), func(t *task.Task) error {
cmdMgr := cmd.NewCommandMgr(cmd.WithTask(*taskItem), cmd.WithTimeout(5*time.Minute))
return cmdMgr.RunBashC(req.Command)
}, nil)
_ = taskItem.Execute()
}()
return nil
}
func (u *ContainerService) Inspect(req dto.InspectReq) (string, error) {
client, err := docker.NewDockerClient()
if err != nil {
@@ -435,7 +411,6 @@ func (u *ContainerService) Prune(req dto.ContainerPrune) error {
}
}
taskItem.Log(i18n.GetMsgByKey("PruneStart"))
DeletedNumber := 0
SpaceReclaimed := 0
switch req.PruneType {
case "container":
@@ -443,21 +418,18 @@ func (u *ContainerService) Prune(req dto.ContainerPrune) error {
if err != nil {
return err
}
DeletedNumber = len(rep.ContainersDeleted)
SpaceReclaimed = int(rep.SpaceReclaimed)
case "image":
rep, err := client.ImagesPrune(context.Background(), pruneFilters)
if err != nil {
return err
}
DeletedNumber = len(rep.ImagesDeleted)
SpaceReclaimed = int(rep.SpaceReclaimed)
case "network":
rep, err := client.NetworksPrune(context.Background(), pruneFilters)
_, err := client.NetworksPrune(context.Background(), pruneFilters)
if err != nil {
return err
}
DeletedNumber = len(rep.NetworksDeleted)
case "volume":
versions, err := client.ServerVersion(context.Background())
if err != nil {
@@ -470,7 +442,6 @@ func (u *ContainerService) Prune(req dto.ContainerPrune) error {
if err != nil {
return err
}
DeletedNumber = len(rep.VolumesDeleted)
SpaceReclaimed = int(rep.SpaceReclaimed)
case "buildcache":
opts := build.CachePruneOptions{}
@@ -479,10 +450,9 @@ func (u *ContainerService) Prune(req dto.ContainerPrune) error {
if err != nil {
return err
}
DeletedNumber = len(rep.CachesDeleted)
SpaceReclaimed = int(rep.SpaceReclaimed)
}
taskItem.Log(i18n.GetMsgWithMap("PruneHelper", map[string]interface{}{"name": i18n.GetMsgByKey(name), "count": DeletedNumber, "size": common.LoadSizeUnit2F(float64(SpaceReclaimed))}))
taskItem.Log(i18n.GetMsgWithMap("PruneHelper", map[string]interface{}{"name": i18n.GetMsgByKey(name), "size": common.LoadSizeUnit2F(float64(SpaceReclaimed))}))
return nil
}, nil)
go func() {
@@ -623,6 +593,16 @@ func (u *ContainerService) ContainerInfo(req dto.OperationWithName) (*dto.Contai
data.ExposedPorts = loadContainerPortForInfo(exposePorts)
data.Hostname = oldContainer.Config.Hostname
data.DNS = oldContainer.HostConfig.DNS
for _, item := range oldContainer.HostConfig.ExtraHosts {
parts := strings.SplitN(item, ":", 2)
if len(parts) != 2 || len(parts[0]) == 0 || len(parts[1]) == 0 {
continue
}
data.ExtraHosts = append(data.ExtraHosts, dto.ExtraHost{
Hostname: parts[0],
IP: parts[1],
})
}
data.DomainName = oldContainer.Config.Domainname
data.Cmd = oldContainer.Config.Cmd
@@ -958,6 +938,9 @@ func collectLogs(done <-chan struct{}, params dto.StreamLog, messageChan chan<-
if params.Follow {
cmdArgs = append(cmdArgs, "-f")
}
if params.Timestamp {
cmdArgs = append(cmdArgs, "-t")
}
if params.Tail != "0" {
cmdArgs = append(cmdArgs, "--tail", params.Tail)
}
@@ -1054,7 +1037,7 @@ func collectLogs(done <-chan struct{}, params dto.StreamLog, messageChan chan<-
_ = dockerCmd.Wait()
}
func (u *ContainerService) DownloadContainerLogs(containerType, container, since, tail string, c *gin.Context) error {
func (u *ContainerService) DownloadContainerLogs(containerType, container, since, tail string, timestamp bool, c *gin.Context) error {
if cmd.CheckIllegal(container, since, tail) {
return buserr.New("ErrCmdIllegal")
}
@@ -1082,6 +1065,9 @@ func (u *ContainerService) DownloadContainerLogs(containerType, container, since
commandArg = append(commandArg, "--since")
commandArg = append(commandArg, since)
}
if timestamp {
commandArg = append(commandArg, "-t")
}
var dockerCmd *exec.Cmd
if containerType == "compose" && dockerCommand == "docker-compose" {
dockerCmd = exec.Command("docker-compose", commandArg...)
@@ -1177,7 +1163,7 @@ func (u *ContainerService) ContainerStats(id string) (*dto.ContainerStats, error
func (u *ContainerService) LoadUsers(req dto.OperationWithName) []string {
var users []string
std, err := cmd.RunDefaultWithStdoutBashCf("docker exec %s cat /etc/passwd", req.Name)
std, err := cmd.NewCommandMgr(cmd.WithTimeout(20*time.Second)).RunWithStdout("docker", "exec", req.Name, "cat", "/etc/passwd")
if err != nil {
return users
}
@@ -1190,6 +1176,455 @@ func (u *ContainerService) LoadUsers(req dto.OperationWithName) []string {
return users
}
func (u *ContainerService) ListContainerFiles(req dto.ContainerFileReq) ([]dto.ContainerFileInfo, error) {
if len(req.Path) == 0 {
req.Path = "/"
}
cli, err := docker.NewDockerClient()
if err != nil {
return nil, err
}
defer cli.Close()
ctx := context.Background()
stat, err := cli.ContainerStatPath(ctx, req.ContainerID, req.Path)
if err != nil {
return nil, normalizeContainerFileError(err)
}
isDir := stat.Mode.IsDir()
isLink := stat.Mode&os.ModeSymlink != 0
if isLink && !isDir {
linkDir, linkErr := isContainerDir(cli, req.ContainerID, req.Path)
if linkErr == nil {
isDir = linkDir
}
}
if !isDir {
return []dto.ContainerFileInfo{toContainerFileInfo(req.Path, stat, isDir)}, nil
}
output, err := runContainerCommand(cli, req.ContainerID, []string{"ls", "-1A", "--", req.Path})
if err != nil {
return nil, normalizeContainerFileError(err)
}
lines := strings.Split(strings.TrimSpace(output), "\n")
files := make([]dto.ContainerFileInfo, 0, len(lines))
for _, line := range lines {
name := strings.TrimSpace(line)
if len(name) == 0 || name == "." || name == ".." {
continue
}
childPath := req.Path
if childPath == "/" {
childPath = "/" + name
} else {
childPath = strings.TrimSuffix(childPath, "/") + "/" + name
}
childStat, statErr := cli.ContainerStatPath(ctx, req.ContainerID, childPath)
if statErr != nil {
continue
}
childIsDir := childStat.Mode.IsDir()
if childStat.Mode&os.ModeSymlink != 0 && !childIsDir {
linkDir, linkErr := isContainerDir(cli, req.ContainerID, childPath)
if linkErr == nil {
childIsDir = linkDir
}
}
files = append(files, toContainerFileInfo(childPath, childStat, childIsDir))
}
sort.Slice(files, func(i, j int) bool {
if files[i].IsDir != files[j].IsDir {
return files[i].IsDir
}
return strings.ToLower(files[i].Name) < strings.ToLower(files[j].Name)
})
return files, nil
}
func (u *ContainerService) DeleteContainerFile(req dto.ContainerFileBatchDeleteReq) error {
for _, item := range req.Paths {
if strings.TrimSpace(item) == "/" {
return buserr.New("ErrPathNotDelete")
}
}
cli, err := docker.NewDockerClient()
if err != nil {
return err
}
defer cli.Close()
command := []string{"rm", "-rf", "--"}
command = append(command, req.Paths...)
_, err = runContainerCommand(cli, req.ContainerID, command)
return err
}
func (u *ContainerService) UploadContainerFile(req dto.ContainerFileReq, fileName string, fileSize int64, file io.Reader) error {
if len(req.Path) == 0 {
req.Path = "/"
}
safeName := path.Base(fileName)
if safeName == "." || safeName == "/" || len(safeName) == 0 {
return buserr.New("ErrInvalidChar")
}
cli, err := docker.NewDockerClient()
if err != nil {
return err
}
defer cli.Close()
ctx := context.Background()
stat, err := cli.ContainerStatPath(ctx, req.ContainerID, req.Path)
if err != nil {
if _, mkErr := runContainerCommand(cli, req.ContainerID, []string{"mkdir", "-p", "--", req.Path}); mkErr != nil {
return mkErr
}
stat, err = cli.ContainerStatPath(ctx, req.ContainerID, req.Path)
if err != nil {
return err
}
}
if !stat.Mode.IsDir() {
return fmt.Errorf("path %s is not directory", req.Path)
}
pipeReader, pipeWriter := io.Pipe()
writeErr := make(chan error, 1)
go func() {
tw := tar.NewWriter(pipeWriter)
header := &tar.Header{
Name: safeName,
Mode: 0644,
Size: fileSize,
ModTime: time.Now(),
}
if err := tw.WriteHeader(header); err != nil {
_ = tw.Close()
_ = pipeWriter.CloseWithError(err)
writeErr <- err
return
}
if _, err := io.Copy(tw, file); err != nil {
_ = tw.Close()
_ = pipeWriter.CloseWithError(err)
writeErr <- err
return
}
if err := tw.Close(); err != nil {
_ = pipeWriter.CloseWithError(err)
writeErr <- err
return
}
_ = pipeWriter.Close()
writeErr <- nil
}()
err = cli.CopyToContainer(ctx, req.ContainerID, req.Path, pipeReader, container.CopyToContainerOptions{
CopyUIDGID: true,
})
if err != nil {
_ = pipeReader.CloseWithError(err)
_ = pipeWriter.CloseWithError(err)
<-writeErr
return err
}
if err := <-writeErr; err != nil {
return err
}
return nil
}
func (u *ContainerService) GetContainerFileContent(req dto.ContainerFileReq) (*dto.ContainerFileContent, error) {
if len(req.Path) == 0 {
return nil, buserr.New("ErrInvalidChar")
}
cli, err := docker.NewDockerClient()
if err != nil {
return nil, err
}
defer cli.Close()
stat, err := cli.ContainerStatPath(context.Background(), req.ContainerID, req.Path)
if err != nil {
return nil, normalizeContainerFileError(err)
}
if stat.Mode.IsDir() {
return nil, fmt.Errorf("path %s is directory", req.Path)
}
content := &dto.ContainerFileContent{Size: stat.Size}
headBytes, err := runContainerCommandRaw(cli, req.ContainerID, []string{"head", "-c", "4096", "--", req.Path})
if err != nil {
return nil, err
}
if bytes.IndexByte(headBytes, 0) >= 0 {
content.IsBinary = true
return content, nil
}
const inlinePreviewMax = 512 * 1024
if stat.Size <= inlinePreviewMax {
raw, err := runContainerCommandRaw(cli, req.ContainerID, []string{"cat", "--", req.Path})
if err != nil {
return nil, err
}
content.Content = string(raw)
return content, nil
}
raw, err := runContainerCommandRaw(cli, req.ContainerID, []string{"tail", "-n", "300", "--", req.Path})
if err != nil {
return nil, err
}
content.Content = string(raw)
content.Truncated = true
return content, nil
}
func (u *ContainerService) GetContainerFileSize(req dto.ContainerFileReq) (int64, error) {
if len(req.Path) == 0 {
return 0, buserr.New("ErrInvalidChar")
}
cli, err := docker.NewDockerClient()
if err != nil {
return 0, err
}
defer cli.Close()
stat, err := cli.ContainerStatPath(context.Background(), req.ContainerID, req.Path)
if err != nil {
return 0, normalizeContainerFileError(err)
}
if !stat.Mode.IsDir() {
return stat.Size, nil
}
output, err := runContainerCommand(cli, req.ContainerID, []string{"du", "-sb", "--", req.Path})
if err != nil {
return 0, err
}
parts := strings.Fields(output)
if len(parts) == 0 {
return 0, fmt.Errorf("invalid du output")
}
size, err := strconv.ParseInt(parts[0], 10, 64)
if err != nil {
return 0, err
}
return size, nil
}
func (u *ContainerService) DownloadContainerFile(req dto.ContainerFileReq) (io.ReadCloser, string, string, error) {
if len(req.Path) == 0 {
req.Path = "/"
}
cli, err := docker.NewDockerClient()
if err != nil {
return nil, "", "", err
}
ctx := context.Background()
stat, err := cli.ContainerStatPath(ctx, req.ContainerID, req.Path)
if err != nil {
_ = cli.Close()
return nil, "", "", normalizeContainerFileError(err)
}
fileName := stat.Name
if len(fileName) == 0 {
fileName = "container-file"
}
if stat.Mode.IsDir() {
if _, err := runContainerCommand(cli, req.ContainerID, []string{"sh", "-c", "command -v tar >/dev/null 2>&1"}); err != nil {
_ = cli.Close()
return nil, "", "", fmt.Errorf("tar command not found in container")
}
targetPath := path.Clean(req.Path)
parentPath := path.Dir(targetPath)
targetName := path.Base(targetPath)
if parentPath == "." || parentPath == "" {
parentPath = "/"
}
tarStream, err := runContainerCommandStream(cli, req.ContainerID, []string{
"tar", "-czf", "-", "-C", parentPath, "--", targetName,
})
if err != nil {
_ = cli.Close()
return nil, "", "", err
}
if !strings.HasSuffix(fileName, ".tar.gz") {
fileName += ".tar.gz"
}
return &closeHookReader{
ReadCloser: tarStream,
onClose: cli.Close,
}, fileName, "application/gzip", nil
}
fileStream, err := runContainerCommandStream(cli, req.ContainerID, []string{"cat", "--", req.Path})
if err != nil {
_ = cli.Close()
return nil, "", "", err
}
return &closeHookReader{
ReadCloser: fileStream,
onClose: cli.Close,
}, fileName, "application/octet-stream", nil
}
func normalizeContainerFileError(err error) error {
if err == nil {
return nil
}
message := strings.ToLower(err.Error())
if strings.Contains(message, "no such file or directory") || strings.Contains(message, "not found") {
return buserr.New("ErrPathNotFound")
}
return err
}
func runContainerCommand(cli *client.Client, containerID string, command []string) (string, error) {
raw, err := runContainerCommandRaw(cli, containerID, command)
if err != nil {
return "", err
}
return strings.TrimSpace(string(raw)), nil
}
type closeHookReader struct {
io.ReadCloser
onClose func() error
}
func (r *closeHookReader) Close() error {
var closeErr error
if r.ReadCloser != nil {
closeErr = r.ReadCloser.Close()
}
if r.onClose != nil {
if err := r.onClose(); err != nil && closeErr == nil {
closeErr = err
}
}
return closeErr
}
func runContainerCommandRaw(cli *client.Client, containerID string, command []string) ([]byte, error) {
ctx := context.Background()
resp, err := cli.ContainerExecCreate(ctx, containerID, container.ExecOptions{
Cmd: command,
AttachStdout: true,
AttachStderr: true,
})
if err != nil {
return nil, err
}
hijack, err := cli.ContainerExecAttach(ctx, resp.ID, container.ExecAttachOptions{})
if err != nil {
return nil, err
}
defer hijack.Close()
raw, err := io.ReadAll(hijack.Reader)
if err != nil {
return nil, err
}
var stdout bytes.Buffer
var stderr bytes.Buffer
if _, err := stdcopy.StdCopy(&stdout, &stderr, bytes.NewReader(raw)); err != nil {
return nil, err
}
output := strings.TrimSpace(stdout.String())
errorOutput := strings.TrimSpace(stderr.String())
info, err := cli.ContainerExecInspect(ctx, resp.ID)
if err != nil {
return nil, err
}
if info.ExitCode != 0 {
if len(errorOutput) != 0 {
return nil, fmt.Errorf("%s", errorOutput)
}
if len(output) == 0 {
return nil, fmt.Errorf("container command failed with exit code %d", info.ExitCode)
}
return nil, fmt.Errorf("%s", output)
}
return stdout.Bytes(), nil
}
func runContainerCommandStream(cli *client.Client, containerID string, command []string) (io.ReadCloser, error) {
ctx := context.Background()
resp, err := cli.ContainerExecCreate(ctx, containerID, container.ExecOptions{
Cmd: command,
AttachStdout: true,
AttachStderr: true,
})
if err != nil {
return nil, err
}
hijack, err := cli.ContainerExecAttach(ctx, resp.ID, container.ExecAttachOptions{})
if err != nil {
return nil, err
}
pipeReader, pipeWriter := io.Pipe()
go func() {
defer hijack.Close()
var stderr bytes.Buffer
_, copyErr := stdcopy.StdCopy(pipeWriter, &stderr, hijack.Reader)
if copyErr != nil {
_ = pipeWriter.CloseWithError(copyErr)
return
}
info, inspectErr := cli.ContainerExecInspect(ctx, resp.ID)
if inspectErr != nil {
_ = pipeWriter.CloseWithError(inspectErr)
return
}
if info.ExitCode != 0 {
msg := strings.TrimSpace(stderr.String())
if len(msg) == 0 {
msg = fmt.Sprintf("container command failed with exit code %d", info.ExitCode)
}
_ = pipeWriter.CloseWithError(fmt.Errorf("%s", msg))
return
}
_ = pipeWriter.Close()
}()
return pipeReader, nil
}
func toContainerFileInfo(filePath string, stat container.PathStat, isDir bool) dto.ContainerFileInfo {
name := stat.Name
if len(name) == 0 {
items := strings.Split(strings.TrimSuffix(filePath, "/"), "/")
name = items[len(items)-1]
}
isLink := stat.Mode&os.ModeSymlink != 0
return dto.ContainerFileInfo{
Name: name,
Path: filePath,
IsDir: isDir,
IsLink: isLink,
LinkTo: stat.LinkTarget,
Size: stat.Size,
Mode: stat.Mode.String(),
ModTime: stat.Mtime.Format(constant.DateTimeLayout),
}
}
func isContainerDir(cli *client.Client, containerID, targetPath string) (bool, error) {
_, err := runContainerCommand(cli, containerID, []string{
"sh", "-c", "[ -d \"$1\" ]", "sh", targetPath,
})
if err != nil {
return false, err
}
return true, nil
}
func stringsToMap(list []string) map[string]string {
var labelMap = make(map[string]string)
for _, label := range list {
@@ -1484,6 +1919,13 @@ func loadConfigInfo(isCreate bool, req dto.ContainerOperate, oldContainer *conta
hostConf.Binds = []string{}
hostConf.Mounts = []mount.Mount{}
hostConf.DNS = req.DNS
hostConf.ExtraHosts = []string{}
for _, item := range req.ExtraHosts {
if len(item.Hostname) == 0 || len(item.IP) == 0 {
continue
}
hostConf.ExtraHosts = append(hostConf.ExtraHosts, fmt.Sprintf("%s:%s", item.Hostname, item.IP))
}
config.Volumes = make(map[string]struct{})
for _, volume := range req.Volumes {
item := mount.Mount{
+71 -23
View File
@@ -135,6 +135,7 @@ func (u *ContainerService) PageCompose(req dto.SearchWithPage) (int64, interface
for key, value := range mergedMap {
value.Name = key
value.ComposeFileExists = composeFileExists(value.Workdir, value.ConfigFile)
records = append(records, value)
}
if len(req.Info) != 0 {
@@ -164,6 +165,29 @@ func (u *ContainerService) PageCompose(req dto.SearchWithPage) (int64, interface
return int64(total), listItem, nil
}
func composeFileExists(workdir, configFile string) bool {
workdir = strings.TrimSpace(workdir)
configFile = strings.TrimSpace(configFile)
if configFile == "" {
return false
}
for _, item := range strings.Split(configFile, ",") {
file := strings.TrimSpace(item)
if file == "" {
continue
}
if !filepath.IsAbs(file) && workdir != "" {
file = filepath.Join(workdir, file)
}
file = filepath.Clean(file)
info, err := os.Stat(file)
if err == nil && !info.IsDir() {
return true
}
}
return false
}
func (u *ContainerService) TestCompose(req dto.ComposeCreate) (bool, error) {
if cmd.CheckIllegal(req.Path) {
return false, buserr.New("ErrCmdIllegal")
@@ -205,7 +229,7 @@ func (u *ContainerService) CreateCompose(req dto.ComposeCreate) error {
}
go func() {
taskItem.AddSubTask(i18n.GetMsgByKey("ComposeCreate"), func(t *task.Task) error {
err := compose.UpWithTask(req.Path, t)
err := compose.UpWithTask(req.Path, t, req.ForcePull)
t.LogWithStatus(i18n.GetMsgByKey("ComposeCreate"), err)
if err != nil {
_, _ = compose.Down(req.Path)
@@ -258,31 +282,43 @@ func (u *ContainerService) ComposeUpdate(req dto.ComposeUpdate) error {
if cmd.CheckIllegal(req.Name, req.Path) {
return buserr.New("ErrCmdIllegal")
}
oldFile, err := os.ReadFile(req.DetailPath)
if err != nil {
return fmt.Errorf("load file with path %s failed, %v", req.DetailPath, err)
}
file, err := os.OpenFile(req.DetailPath, os.O_WRONLY|os.O_TRUNC, 0640)
taskItem, err := task.NewTaskWithOps(req.Name, task.TaskUpdate, task.TaskScopeCompose, req.TaskID, 1)
if err != nil {
global.LOG.Errorf("new task for update compose failed, err: %v", err)
return err
}
defer file.Close()
write := bufio.NewWriter(file)
_, _ = write.WriteString(req.Content)
write.Flush()
go func() {
taskItem.AddSubTask(i18n.GetMsgByKey("TaskUpdate"), func(t *task.Task) error {
oldFile, err := os.ReadFile(req.DetailPath)
if err != nil {
return fmt.Errorf("load file with path %s failed, %v", req.DetailPath, err)
}
file, err := os.OpenFile(req.DetailPath, os.O_WRONLY|os.O_TRUNC, 0640)
if err != nil {
return err
}
defer file.Close()
write := bufio.NewWriter(file)
_, _ = write.WriteString(req.Content)
write.Flush()
global.LOG.Infof("docker-compose.yml %s has been replaced, now start to docker-compose restart", req.DetailPath)
if err := newComposeEnv(req.DetailPath, req.Env); err != nil {
return err
}
global.LOG.Infof("docker-compose.yml %s has been replaced, now start to docker-compose restart", req.DetailPath)
if err := newComposeEnv(req.DetailPath, req.Env); err != nil {
return err
}
if stdout, err := compose.Up(req.Path); err != nil {
global.LOG.Errorf("update failed when handle compose up, std: %s, err: %s, now try to recreate the old compose file", stdout, err)
if err := recreateCompose(string(oldFile), req.Path); err != nil {
return fmt.Errorf("update failed and recreate old compose file also failed, err: %v", err)
}
return fmt.Errorf("update failed when handle compose up, std: %v, err: %s", stdout, err)
}
if err := compose.UpWithTask(req.Path, t, req.ForcePull); err != nil {
global.LOG.Errorf("update failed when handle compose up, err: %s, now try to recreate the old compose file", err)
if err := recreateCompose(string(oldFile), req.Path); err != nil {
return fmt.Errorf("update failed and recreate old compose file also failed, err: %v", err)
}
return fmt.Errorf("update failed when handle compose up, err: %s", err)
}
return nil
}, nil)
_ = taskItem.Execute()
}()
return nil
}
@@ -338,6 +374,15 @@ func (u *ContainerService) ComposeLogClean(req dto.ComposeLogClean) error {
})
}
func (u *ContainerService) LoadComposeEnv(name string) (string, error) {
envFilePath := path.Join(path.Dir(name), ".env")
file, err := os.ReadFile(envFilePath)
if err != nil {
return "", err
}
return string(file), nil
}
func (u *ContainerService) loadPath(req *dto.ComposeCreate) error {
if req.From == "template" || req.From == "edit" {
dir := fmt.Sprintf("%s/docker/compose/%s", global.Dir.DataDir, req.Name)
@@ -362,8 +407,11 @@ func (u *ContainerService) loadPath(req *dto.ComposeCreate) error {
}
func removeContainerForCompose(composeName, composePath string) error {
if stdout, err := compose.Operate(composePath, "down"); err != nil {
return errors.New(stdout)
if _, err := os.Stat(composePath); err == nil {
if stdout, err := compose.Operate(composePath, "down"); err != nil {
return errors.New(stdout)
}
return nil
}
var options container.ListOptions
options.All = true
+4 -1
View File
@@ -143,6 +143,7 @@ func (u *CronjobService) Export(req dto.OperateByIDs) (string, error) {
Timeout: cronjob.Timeout,
IgnoreErr: cronjob.IgnoreErr,
Secret: cronjob.Secret,
Args: cronjob.Args,
}
switch cronjob.Type {
case "app":
@@ -237,6 +238,7 @@ func (u *CronjobService) Import(req []dto.CronjobTrans) error {
Timeout: item.Timeout,
IgnoreErr: item.IgnoreErr,
Secret: item.Secret,
Args: item.Args,
}
hasNotFound := false
switch item.Type {
@@ -733,6 +735,7 @@ func (u *CronjobService) Update(id uint, req dto.CronjobOperate) error {
upMap["timeout"] = req.Timeout
upMap["ignore_err"] = req.IgnoreErr
upMap["secret"] = req.Secret
upMap["args"] = req.Args
err = cronjobRepo.Update(id, upMap)
if err != nil {
return err
@@ -762,7 +765,7 @@ func (u *CronjobService) UpdateStatus(id uint, status string) error {
)
if status == constant.StatusEnable {
entryIDs, err = u.StartJob(&cronjob, false)
entryIDs, err = u.StartJob(&cronjob, true)
if err != nil {
return err
}
+5
View File
@@ -379,6 +379,7 @@ type DatabaseHelper struct {
DBType string
Database string
Name string
Args []string
}
func addSkipTask(source string, taskItem *task.Task) {
@@ -400,6 +401,7 @@ func loadDbsForJob(cronjob model.Cronjob) []DatabaseHelper {
DBType: cronjob.DBType,
Database: mysql.Database,
Name: mysql.Name,
Args: strings.Split(cronjob.Args, ","),
})
}
} else {
@@ -410,6 +412,7 @@ func loadDbsForJob(cronjob model.Cronjob) []DatabaseHelper {
DBType: cronjob.DBType,
Database: pg.PostgresqlName,
Name: pg.Name,
Args: strings.Split(cronjob.Args, ","),
})
}
}
@@ -425,6 +428,7 @@ func loadDbsForJob(cronjob model.Cronjob) []DatabaseHelper {
DBType: cronjob.DBType,
Database: mysqlItem.MysqlName,
Name: mysqlItem.Name,
Args: strings.Split(cronjob.Args, ","),
})
} else {
pgItem, _ := postgresqlRepo.Get(repo.WithByID(uint(itemID)))
@@ -433,6 +437,7 @@ func loadDbsForJob(cronjob model.Cronjob) []DatabaseHelper {
DBType: cronjob.DBType,
Database: pgItem.PostgresqlName,
Name: pgItem.Name,
Args: strings.Split(cronjob.Args, ","),
})
}
}
+2 -2
View File
@@ -418,7 +418,7 @@ func (u *CronjobService) removeExpiredBackup(cronjob model.Cronjob, accountMap m
var opts []repo.DBOption
opts = append(opts, repo.WithByFrom("cronjob"))
opts = append(opts, backupRepo.WithByCronID(cronjob.ID))
opts = append(opts, repo.WithOrderBy("created_at desc"))
opts = append(opts, repo.WithOrderDesc("created_at"))
if record.ID != 0 {
opts = append(opts, repo.WithByType(record.Type))
opts = append(opts, repo.WithByName(record.Name))
@@ -461,7 +461,7 @@ func (u *CronjobService) removeExpiredBackup(cronjob model.Cronjob, accountMap m
}
func (u *CronjobService) removeExpiredLog(cronjob model.Cronjob) {
records, _ := cronjobRepo.ListRecord(cronjobRepo.WithByJobID(int(cronjob.ID)), repo.WithOrderBy("created_at desc"))
records, _ := cronjobRepo.ListRecord(cronjobRepo.WithByJobID(int(cronjob.ID)), repo.WithOrderDesc("created_at"))
if len(records) <= int(cronjob.RetainCopies) {
return
}
+12 -4
View File
@@ -142,7 +142,7 @@ func (u *DashboardService) LoadCurrentInfoForNode() *dto.NodeCurrent {
func (u *DashboardService) LoadBaseInfo(ioOption string, netOption string) (*dto.DashboardBase, error) {
var baseInfo dto.DashboardBase
hostInfo, err := psutil.HOST.GetHostInfo(false)
hostInfo, err := psutil.HOST.GetHostInfo(true)
if err != nil {
return nil, err
}
@@ -284,7 +284,7 @@ func (u *DashboardService) LoadAppLauncher(ctx *gin.Context) ([]dto.AppLauncher,
return data, err
}
showList, err := launcherRepo.ListName()
showList, _ := launcherRepo.ListName()
defaultList, err := appRepo.GetTopRecommend()
if err != nil {
return data, nil
@@ -361,6 +361,9 @@ func (u *DashboardService) LoadQuickOptions() []dto.QuickJump {
_ = copier.Copy(&item, &quick)
list = append(list, item)
}
sort.Slice(list, func(i, j int) bool {
return list[i].Recommend < list[j].Recommend
})
return list
}
func (u *DashboardService) ChangeQuick(req dto.ChangeQuicks) error {
@@ -612,6 +615,9 @@ func loadQuickJump(base *dto.DashboardBase) {
website, _ := websiteRepo.GetBy()
base.WebsiteNumber = len(website)
agents, _ := agentRepo.List()
base.AgentNumber = len(agents)
postgresqlDbs, _ := postgresqlRepo.List()
mysqlDbs, _ := mysqlRepo.List()
base.DatabaseNumber = len(mysqlDbs) + len(postgresqlDbs)
@@ -625,6 +631,8 @@ func loadQuickJump(base *dto.DashboardBase) {
quicks := launcherRepo.ListQuickJump(false)
for i := 0; i < len(quicks); i++ {
switch quicks[i].Name {
case "Agent":
quicks[i].Detail = fmt.Sprintf("%d", base.AgentNumber)
case "Website":
quicks[i].Detail = fmt.Sprintf("%d", base.WebsiteNumber)
case "Database":
@@ -638,7 +646,7 @@ func loadQuickJump(base *dto.DashboardBase) {
_ = copier.Copy(&item, quicks[i])
base.QuickJumps = append(base.QuickJumps, item)
}
sort.Slice(quicks, func(i, j int) bool {
return quicks[i].Recommend < quicks[j].Recommend
sort.Slice(base.QuickJumps, func(i, j int) bool {
return base.QuickJumps[i].Recommend < base.QuickJumps[j].Recommend
})
}
+5
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"os"
"path"
"path/filepath"
"strings"
"github.com/1Panel-dev/1Panel/agent/app/dto"
@@ -43,6 +44,10 @@ func (u *DBCommonService) LoadBaseInfo(req dto.OperationWithNameAndType) (*dto.D
func (u *DBCommonService) LoadDatabaseFile(req dto.OperationWithNameAndType) (string, error) {
filePath := ""
safeName := filepath.Base(req.Name)
if safeName != req.Name || strings.Contains(safeName, "..") {
return "", buserr.New("ErrInvalidParams")
}
switch req.Type {
case "mysql-cluster-conf":
filePath = path.Join(global.Dir.DataDir, fmt.Sprintf("apps/mysql-cluster/%s/conf/my.cnf", req.Name))
+8 -8
View File
@@ -109,7 +109,7 @@ func (u *DeviceService) CheckDNS(key, value string) (bool, error) {
if err != nil {
return false, err
}
defer conn.Close()
defer func() { _ = conn.Close() }()
return true, nil
}
@@ -132,7 +132,7 @@ func (u *DeviceService) Update(key, value string) error {
if cmd.CheckIllegal(value) {
return buserr.New("ErrCmdIllegal")
}
if err := cmd.RunDefaultBashCf("%s hostnamectl set-hostname %s", cmd.SudoHandleCmd(), value); err != nil {
if err := cmd.NewCommandMgr(cmd.WithTimeout(20*time.Second)).Run("hostnamectl", "set-hostname", value); err != nil {
return err
}
_, _ = psutil.HOST.GetHostInfo(true)
@@ -245,7 +245,7 @@ func (u *DeviceService) UpdateSwap(req dto.SwapHelper) error {
}
cmdMgr := cmd.NewCommandMgr(cmd.WithTask(*taskItem))
if !req.IsNew {
if err := cmdMgr.RunBashCf("%s swapoff %s", cmd.SudoHandleCmd(), req.Path); err != nil {
if err := cmdMgr.Run("swapoff", req.Path); err != nil {
return fmt.Errorf("handle swapoff %s failed, %v", req.Path, err)
}
}
@@ -256,22 +256,22 @@ func (u *DeviceService) UpdateSwap(req dto.SwapHelper) error {
return operateSwapWithFile(true, req)
}
taskItem.LogStart(i18n.GetMsgByKey("CreateSwap"))
if err := cmdMgr.RunBashCf("%s dd if=/dev/zero of=%s bs=1024 count=%d", cmd.SudoHandleCmd(), req.Path, req.Size); err != nil {
if err := cmdMgr.Run("dd", "if=/dev/zero", fmt.Sprintf("of=%s", req.Path), "bs=1024", fmt.Sprintf("count=%d", req.Size)); err != nil {
return fmt.Errorf("handle dd %s failed, %v", req.Path, err)
}
taskItem.Log("chmod 0600 " + req.Path)
if err := cmdMgr.RunBashCf("%s chmod 0600 %s", cmd.SudoHandleCmd(), req.Path); err != nil {
if err := cmdMgr.Run("chmod", "0600", req.Path); err != nil {
return fmt.Errorf("handle chmod 0600 %s failed, %v", req.Path, err)
}
taskItem.LogStart(i18n.GetMsgByKey("FormatSwap"))
if err := cmdMgr.RunBashCf("%s mkswap -f %s", cmd.SudoHandleCmd(), req.Path); err != nil {
if err := cmdMgr.Run("mkswap", "-f", req.Path); err != nil {
return fmt.Errorf("handle mkswap -f %s failed, %v", req.Path, err)
}
taskItem.LogStart(i18n.GetMsgByKey("EnableSwap"))
if err := cmdMgr.RunBashCf("%s swapon %s", cmd.SudoHandleCmd(), req.Path); err != nil {
_ = cmdMgr.RunBashCf("%s swapoff %s", cmd.SudoHandleCmd(), req.Path)
if err := cmdMgr.Run("swapon", req.Path); err != nil {
_ = cmdMgr.Run("swapoff", req.Path)
return fmt.Errorf("handle swapoff %s failed, %v", req.Path, err)
}
return operateSwapWithFile(false, req)
+551 -130
View File
@@ -11,36 +11,27 @@ import (
"strings"
"time"
"github.com/1Panel-dev/1Panel/agent/constant"
"github.com/1Panel-dev/1Panel/agent/i18n"
"github.com/1Panel-dev/1Panel/agent/utils/controller"
"github.com/1Panel-dev/1Panel/agent/utils/docker"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/build"
"github.com/docker/docker/api/types/filters"
"github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/app/repo"
"github.com/1Panel-dev/1Panel/agent/app/task"
"github.com/1Panel-dev/1Panel/agent/constant"
"github.com/1Panel-dev/1Panel/agent/global"
"github.com/1Panel-dev/1Panel/agent/i18n"
"github.com/1Panel-dev/1Panel/agent/utils/common"
"github.com/1Panel-dev/1Panel/agent/utils/docker"
fileUtils "github.com/1Panel-dev/1Panel/agent/utils/files"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/build"
"github.com/docker/docker/api/types/filters"
"github.com/google/uuid"
)
const (
upgradePath = "1panel/tmp/upgrade"
snapshotTmpPath = "1panel/tmp/system"
rollbackPath = "1panel/tmp"
cachePath = "1panel/cache"
oldOriginalPath = "original"
oldAppBackupPath = "1panel/resource/apps_bak"
oldDownloadPath = "1panel/tmp/download"
oldUpgradePath = "1panel/tmp"
tmpUploadPath = "1panel/tmp/upload"
uploadPath = "1panel/uploads"
downloadPath = "1panel/download"
rollbackPath = "1panel/tmp"
upgradePath = "1panel/tmp/upgrade"
uploadPath = "1panel/uploads"
downloadPath = "1panel/download"
)
func (u *DeviceService) Scan() dto.CleanData {
@@ -58,57 +49,14 @@ func (u *DeviceService) Scan() dto.CleanData {
Size: uint64(originalSize),
IsCheck: originalSize > 0,
IsRecommend: true,
CanDelete: false,
Type: "1panel_original",
Children: loadTreeWithDir(true, "1panel_original", originalPath, fileOp),
})
treeData = append(treeData, loadUpgradeTree(fileOp))
treeData = append(treeData, loadAgentPackage(fileOp))
upgradePath := path.Join(global.Dir.BaseDir, upgradePath)
upgradeSize, _ := fileOp.GetDirSize(upgradePath)
upgradeTree := dto.CleanTree{
ID: uuid.NewString(),
Label: "upgrade",
Size: uint64(upgradeSize),
IsCheck: false,
IsRecommend: true,
Type: "upgrade",
Children: loadTreeWithDir(true, "upgrade", upgradePath, fileOp),
}
if len(upgradeTree.Children) != 0 {
sort.Slice(upgradeTree.Children, func(i, j int) bool {
return common.CompareVersion(upgradeTree.Children[i].Label, upgradeTree.Children[j].Label)
})
if global.IsMaster {
var copiesSetting model.Setting
_ = global.CoreDB.Where("key = ?", "UpgradeBackupCopies").First(&copiesSetting).Error
copies, _ := strconv.Atoi(copiesSetting.Value)
if copies == 0 || copies > len(upgradeTree.Children) {
copies = len(upgradeTree.Children)
}
for i := 0; i < copies; i++ {
upgradeTree.Children[i].IsCheck = false
upgradeTree.Children[i].IsRecommend = false
}
} else {
upgradeTree.Children[0].IsCheck = false
upgradeTree.Children[0].IsRecommend = false
}
}
treeData = append(treeData, upgradeTree)
tmpBackupTree := loadTmpBackupTree(fileOp)
tmpBackupSize := uint64(0)
for _, tmp := range tmpBackupTree {
tmpBackupSize += tmp.Size
}
treeData = append(treeData, dto.CleanTree{
ID: uuid.NewString(),
Label: "backup",
Size: tmpBackupSize,
IsCheck: tmpBackupSize > 0,
IsRecommend: true,
Type: "backup",
Children: tmpBackupTree,
})
SystemClean.BackupClean = loadBackupTree(fileOp)
rollBackTree := loadRollBackTree(fileOp)
rollbackSize := uint64(0)
@@ -121,20 +69,10 @@ func (u *DeviceService) Scan() dto.CleanData {
Size: rollbackSize,
IsCheck: rollbackSize > 0,
IsRecommend: true,
CanDelete: false,
Type: "rollback",
Children: rollBackTree,
})
cachePath := path.Join(global.Dir.BaseDir, cachePath)
cacheSize, _ := fileOp.GetDirSize(cachePath)
treeData = append(treeData, dto.CleanTree{
ID: uuid.NewString(),
Label: "cache",
Size: uint64(cacheSize),
IsCheck: false,
IsRecommend: false,
Type: "cache",
})
SystemClean.SystemClean = treeData
uploadTreeData := loadUploadTree(fileOp)
@@ -154,7 +92,6 @@ func (u *DeviceService) Scan() dto.CleanData {
func (u *DeviceService) Clean(req []dto.Clean) {
size := uint64(0)
restart := false
for _, item := range req {
size += item.Size
switch item.TreeType {
@@ -164,13 +101,13 @@ func (u *DeviceService) Clean(req []dto.Clean) {
case "upgrade":
dropFileOrDir(path.Join(global.Dir.BaseDir, upgradePath, item.Name))
case "backup":
dropFileOrDir(path.Join(global.Dir.LocalBackupDir, "tmp/app"))
dropFileOrDir(path.Join(global.Dir.LocalBackupDir, "tmp/database"))
dropFileOrDir(path.Join(global.Dir.LocalBackupDir, "tmp/website"))
dropFileOrDir(path.Join(global.Dir.LocalBackupDir, "tmp/directory"))
dropFileOrDir(path.Join(global.Dir.LocalBackupDir, "tmp/log"))
dropFileOrDir(path.Join(global.Dir.LocalBackupDir, "tmp/system"))
case "agent":
dropFileOrDir(path.Join(global.Dir.BaseDir, "1panel/agent/package", item.Name))
case "tmp_backup":
dropFileOrDir(path.Join(global.Dir.LocalBackupDir, "tmp"))
case "unknown_backup":
dropFileOrDir(item.Name)
case "rollback":
dropFileOrDir(path.Join(global.Dir.BaseDir, rollbackPath, "app"))
@@ -183,25 +120,14 @@ func (u *DeviceService) Clean(req []dto.Clean) {
case "rollback_website":
dropFileOrDir(path.Join(global.Dir.BaseDir, rollbackPath, "website", item.Name))
case "cache":
dropFileOrDir(path.Join(global.Dir.BaseDir, cachePath, item.Name))
restart = true
case "upload":
dropFileOrDir(path.Join(global.Dir.BaseDir, uploadPath, item.Name))
if len(item.Name) == 0 {
dropFileOrDir(path.Join(global.Dir.BaseDir, tmpUploadPath))
}
case "upload_tmp":
dropFileOrDir(path.Join(global.Dir.BaseDir, tmpUploadPath, item.Name))
case "upload_app":
dropFileOrDir(path.Join(global.Dir.BaseDir, uploadPath, "app", item.Name))
case "upload_database":
dropFileOrDir(path.Join(global.Dir.BaseDir, uploadPath, "database", item.Name))
case "upload_website":
dropFileOrDir(path.Join(global.Dir.BaseDir, uploadPath, "website", item.Name))
case "upload_directory":
dropFileOrDir(path.Join(global.Dir.BaseDir, uploadPath, "directory", item.Name))
case "download":
dropFileOrDir(path.Join(global.Dir.BaseDir, downloadPath, item.Name))
case "download_app":
@@ -210,8 +136,6 @@ func (u *DeviceService) Clean(req []dto.Clean) {
dropFileOrDir(path.Join(global.Dir.BaseDir, downloadPath, "database", item.Name))
case "download_website":
dropFileOrDir(path.Join(global.Dir.BaseDir, downloadPath, "website", item.Name))
case "download_directory":
dropFileOrDir(path.Join(global.Dir.BaseDir, downloadPath, "directory", item.Name))
case "system_log":
if len(item.Name) == 0 {
@@ -243,6 +167,8 @@ func (u *DeviceService) Clean(req []dto.Clean) {
} else {
dropTaskLog(path.Join(global.Dir.TaskDir, item.Name))
}
case "website_log":
dropWebsiteLog(item.Name)
case "script":
dropFileOrDir(path.Join(global.Dir.TmpDir, "script", item.Name))
case "images":
@@ -253,16 +179,15 @@ func (u *DeviceService) Clean(req []dto.Clean) {
_, _ = dropVolumes()
case "build_cache":
_, _ = dropBuildCache()
case "app_tmp_download_version":
dropFileOrDir(path.Join(global.Dir.RemoteAppResourceDir, item.Name))
}
}
_ = cleanEmptyDirs(global.Dir.LocalBackupDir)
_ = settingRepo.Update("LastCleanTime", time.Now().Format(constant.DateTimeLayout))
_ = settingRepo.Update("LastCleanSize", fmt.Sprintf("%v", size))
_ = settingRepo.Update("LastCleanData", fmt.Sprintf("%v", len(req)))
if restart {
go controller.RestartPanel(false, true, false)
}
}
func doSystemClean(taskItem *task.Task) func(t *task.Task) error {
@@ -284,25 +209,20 @@ func doSystemClean(taskItem *task.Task) func(t *task.Task) error {
}
}
dropWithTask(path.Join(global.Dir.BaseDir, snapshotTmpPath), taskItem, &size, &fileCount)
dropWithTask(path.Join(global.Dir.LocalBackupDir, "tmp/system"), taskItem, &size, &fileCount)
dropWithTask(path.Join(global.Dir.BaseDir, rollbackPath, "app"), taskItem, &size, &fileCount)
dropWithTask(path.Join(global.Dir.BaseDir, rollbackPath, "website"), taskItem, &size, &fileCount)
dropWithTask(path.Join(global.Dir.BaseDir, rollbackPath, "database"), taskItem, &size, &fileCount)
dropWithTask(path.Join(global.Dir.BaseDir, oldOriginalPath), taskItem, &size, &fileCount)
dropWithTask(path.Join(global.Dir.BaseDir, oldAppBackupPath), taskItem, &size, &fileCount)
dropWithTask(path.Join(global.Dir.BaseDir, oldDownloadPath), taskItem, &size, &fileCount)
oldUpgradePath := path.Join(global.Dir.BaseDir, oldUpgradePath)
oldUpgradeFiles, _ := os.ReadDir(oldUpgradePath)
upgrades := path.Join(global.Dir.BaseDir, upgradePath)
oldUpgradeFiles, _ := os.ReadDir(upgrades)
if len(oldUpgradeFiles) != 0 {
for i := 0; i < len(oldUpgradeFiles); i++ {
dropWithTask(path.Join(oldUpgradePath, oldUpgradeFiles[i].Name()), taskItem, &size, &fileCount)
dropWithTask(path.Join(upgrades, oldUpgradeFiles[i].Name()), taskItem, &size, &fileCount)
}
}
dropWithTask(path.Join(global.Dir.BaseDir, tmpUploadPath), taskItem, &size, &fileCount)
dropWithExclude(path.Join(global.Dir.BaseDir, uploadPath), []string{"theme"}, taskItem, &size, &fileCount)
dropWithTask(path.Join(global.Dir.BaseDir, downloadPath), taskItem, &size, &fileCount)
@@ -338,17 +258,354 @@ func doSystemClean(taskItem *task.Task) func(t *task.Task) error {
}
}
func loadTmpBackupTree(fileOp fileUtils.FileOp) []dto.CleanTree {
func loadUpgradeTree(fileOp fileUtils.FileOp) dto.CleanTree {
upgradePath := path.Join(global.Dir.BaseDir, upgradePath)
upgradeSize, _ := fileOp.GetDirSize(upgradePath)
upgradeTree := dto.CleanTree{
ID: uuid.NewString(),
Label: "upgrade",
Size: uint64(upgradeSize),
IsCheck: false,
IsRecommend: true,
CanDelete: false,
Type: "upgrade",
Children: loadTreeWithDir(true, "upgrade", upgradePath, fileOp),
}
if len(upgradeTree.Children) != 0 {
sort.Slice(upgradeTree.Children, func(i, j int) bool {
return common.CompareVersion(upgradeTree.Children[i].Label, upgradeTree.Children[j].Label)
})
if global.IsMaster {
var copiesSetting model.Setting
_ = global.CoreDB.Where("key = ?", "UpgradeBackupCopies").First(&copiesSetting).Error
copies, _ := strconv.Atoi(copiesSetting.Value)
if copies == 0 || copies > len(upgradeTree.Children) {
copies = len(upgradeTree.Children)
}
for i := 0; i < copies; i++ {
upgradeTree.Children[i].IsCheck = false
upgradeTree.Children[i].IsRecommend = false
}
} else {
upgradeTree.Children[0].IsCheck = false
upgradeTree.Children[0].IsRecommend = false
}
}
return upgradeTree
}
func loadAgentPackage(fileOp fileUtils.FileOp) dto.CleanTree {
pathItem := path.Join(global.Dir.BaseDir, "1panel/agent/package")
itemTree := dto.CleanTree{
ID: uuid.NewString(),
Label: "agent_packages",
IsCheck: false,
IsRecommend: true,
CanDelete: false,
Type: "agent",
}
files, _ := os.ReadDir(pathItem)
for _, file := range files {
if file.IsDir() {
itemSize, _ := fileOp.GetDirSize(path.Join(pathItem, file.Name()))
itemTree.Size += uint64(itemSize)
itemTree.Children = append(itemTree.Children, dto.CleanTree{
ID: uuid.NewString(),
Label: file.Name(),
Name: file.Name(),
Size: uint64(itemSize),
IsCheck: true,
IsRecommend: true,
CanDelete: true,
Type: "agent",
})
} else {
itemSize, _ := file.Info()
itemName := file.Name()
isCurrentVersion := strings.HasPrefix(itemName, fmt.Sprintf("1panel-agent_%s_", global.CONF.Base.Version))
if isCurrentVersion {
continue
}
itemTree.Size += uint64(itemSize.Size())
itemTree.Children = append(itemTree.Children, dto.CleanTree{
ID: uuid.NewString(),
Label: itemName,
Name: itemName,
Size: uint64(itemSize.Size()),
IsCheck: !isCurrentVersion,
IsRecommend: true,
CanDelete: true,
Type: "agent",
})
}
}
if itemTree.Size == 0 {
itemTree.IsCheck = false
}
return itemTree
}
func loadBackupTree(fileOp fileUtils.FileOp) []dto.CleanTree {
var treeData []dto.CleanTree
treeData = loadTreeWithCheck(treeData, path.Join(global.Dir.LocalBackupDir, "tmp/app"), "tmp_backup_app", fileOp)
treeData = loadTreeWithCheck(treeData, path.Join(global.Dir.LocalBackupDir, "tmp/website"), "tmp_backup_website", fileOp)
treeData = loadTreeWithCheck(treeData, path.Join(global.Dir.LocalBackupDir, "tmp/database"), "tmp_backup_database", fileOp)
treeData = loadTreeWithCheck(treeData, path.Join(global.Dir.LocalBackupDir, "tmp/system"), "tmp_backup_snapshot", fileOp)
treeData = loadTreeWithCheck(treeData, path.Join(global.Dir.LocalBackupDir, "tmp/directory"), "tmp_backup_directory", fileOp)
treeData = loadTreeWithCheck(treeData, path.Join(global.Dir.LocalBackupDir, "tmp/log"), "tmp_backup_log", fileOp)
tmpSize, _ := fileOp.GetDirSize(path.Join(global.Dir.LocalBackupDir, "tmp"))
treeData = append(treeData, dto.CleanTree{
ID: uuid.NewString(),
Label: "tmp_backup",
Size: uint64(tmpSize),
IsCheck: tmpSize != 0,
IsRecommend: true,
CanDelete: true,
Type: "tmp_backup",
})
backupRecords, _ := backupRepo.ListRecord()
var recordMap = make(map[string][]string)
for _, record := range backupRecords {
if val, ok := recordMap[record.FileDir]; ok {
val = append(val, record.FileName)
recordMap[record.FileDir] = val
} else {
recordMap[record.FileDir] = []string{record.FileName}
}
}
treeData = append(treeData, loadUnknownApps(fileOp, recordMap))
treeData = append(treeData, loadUnknownDbs(fileOp, recordMap))
treeData = append(treeData, loadUnknownWebsites(fileOp, recordMap))
treeData = append(treeData, loadUnknownSnapshot(fileOp))
treeData = append(treeData, loadUnknownWebsiteLog(fileOp))
return treeData
}
func loadUnknownApps(fileOp fileUtils.FileOp, recordMap map[string][]string) dto.CleanTree {
apps, _ := appInstallRepo.ListBy(context.Background())
var excludePaths []string
for _, app := range apps {
itemName := fmt.Sprintf("app/%s/%s", app.App.Key, app.Name)
if val, ok := recordMap[itemName]; ok {
for _, item := range val {
excludePaths = append(excludePaths, path.Join(global.Dir.LocalBackupDir, itemName, item))
}
}
}
backupPath := path.Join(global.Dir.LocalBackupDir, "app")
treeData := dto.CleanTree{
ID: uuid.NewString(),
Label: "unknown_app",
IsCheck: false,
IsRecommend: false,
CanDelete: false,
Name: backupPath,
Type: "unknown_backup",
}
_ = loadFileOrDirWithExclude(fileOp, 0, backupPath, &treeData, excludePaths)
return treeData
}
func loadUnknownDbs(fileOp fileUtils.FileOp, recordMap map[string][]string) dto.CleanTree {
dbs, _ := databaseRepo.GetList()
var excludePaths []string
dbMap := make(map[string]struct{})
for _, db := range dbs {
dbMap[fmt.Sprintf("database/%s/%s", db.Type, db.Name)] = struct{}{}
}
for key, val := range recordMap {
itemName := path.Dir(key)
if _, ok := dbMap[itemName]; ok {
for _, item := range val {
excludePaths = append(excludePaths, path.Join(global.Dir.LocalBackupDir, key, item))
}
}
}
backupPath := path.Join(global.Dir.LocalBackupDir, "database")
treeData := dto.CleanTree{
ID: uuid.NewString(),
Label: "unknown_database",
Name: backupPath,
IsCheck: false,
IsRecommend: false,
CanDelete: false,
Type: "unknown_backup",
}
_ = loadFileOrDirWithExclude(fileOp, 0, backupPath, &treeData, excludePaths)
return treeData
}
func loadUnknownWebsites(fileOp fileUtils.FileOp, recordMap map[string][]string) dto.CleanTree {
websites, _ := websiteRepo.List()
var excludePaths []string
for _, website := range websites {
itemName := fmt.Sprintf("website/%s", website.Alias)
if val, ok := recordMap[itemName]; ok {
for _, item := range val {
excludePaths = append(excludePaths, path.Join(global.Dir.LocalBackupDir, itemName, item))
}
}
}
backupPath := path.Join(global.Dir.LocalBackupDir, "website")
treeData := dto.CleanTree{
ID: uuid.NewString(),
Label: "unknown_website",
Name: backupPath,
IsCheck: false,
IsRecommend: false,
CanDelete: false,
Type: "unknown_backup",
}
_ = loadFileOrDirWithExclude(fileOp, 0, backupPath, &treeData, excludePaths)
return treeData
}
func loadUnknownSnapshot(fileOp fileUtils.FileOp) dto.CleanTree {
snaps, _ := snapshotRepo.GetList()
var excludePaths []string
for _, item := range snaps {
excludePaths = append(excludePaths, path.Join(global.Dir.LocalBackupDir, "system_snapshot", item.Name+".tar.gz"))
}
backupPath := path.Join(global.Dir.LocalBackupDir, "system_snapshot")
treeData := dto.CleanTree{
ID: uuid.NewString(),
Label: "unknown_snapshot",
Name: backupPath,
IsCheck: false,
IsRecommend: false,
CanDelete: false,
Type: "unknown_backup",
}
entries, _ := os.ReadDir(backupPath)
for _, entry := range entries {
childPath := filepath.Join(backupPath, entry.Name())
if isExactPathMatch(childPath, excludePaths) {
continue
}
childNode := dto.CleanTree{
ID: uuid.NewString(),
Label: entry.Name(),
IsCheck: false,
IsRecommend: false,
CanDelete: true,
Name: childPath,
Type: "unknown_backup",
}
if entry.IsDir() {
itemSize, _ := fileOp.GetDirSize(childPath)
childNode.Size = uint64(itemSize)
childNode.IsCheck = true
childNode.IsRecommend = true
treeData.Size += childNode.Size
} else {
info, _ := entry.Info()
childNode.Size = uint64(info.Size())
treeData.Size += childNode.Size
}
treeData.Children = append(treeData.Children, childNode)
}
return treeData
}
func loadUnknownWebsiteLog(fileOp fileUtils.FileOp) dto.CleanTree {
treeData := dto.CleanTree{
ID: uuid.NewString(),
Label: "unknown_website_log",
IsCheck: false,
IsRecommend: true,
CanDelete: false,
Type: "unknown_backup",
}
dir := path.Join(global.Dir.LocalBackupDir, "log/website")
websites, _ := websiteRepo.List()
websiteMap := make(map[string]struct{})
for _, website := range websites {
websiteMap[website.Alias] = struct{}{}
}
entries, _ := os.ReadDir(dir)
for _, entry := range entries {
if !entry.IsDir() {
continue
}
dirName := entry.Name()
if _, ok := websiteMap[dirName]; !ok {
dirPath := path.Join(dir, dirName)
itemSize, _ := fileOp.GetDirSize(dirPath)
childData := dto.CleanTree{
ID: uuid.NewString(),
Label: dirName,
IsCheck: true,
IsRecommend: true,
CanDelete: true,
Name: dirPath,
Type: "unknown_backup",
Size: uint64(itemSize),
}
treeData.Size += uint64(itemSize)
treeData.Children = append(treeData.Children, childData)
}
}
if treeData.Size > 0 {
treeData.IsCheck = true
}
return treeData
}
func loadFileOrDirWithExclude(fileOp fileUtils.FileOp, index uint, dir string, rootTree *dto.CleanTree, excludes []string) error {
index++
entries, err := os.ReadDir(dir)
if err != nil {
return err
}
for _, entry := range entries {
childPath := filepath.Join(dir, entry.Name())
if isExactPathMatch(childPath, excludes) {
continue
}
childNode := dto.CleanTree{
ID: uuid.NewString(),
Label: entry.Name(),
IsCheck: false,
IsRecommend: false,
CanDelete: true,
Name: childPath,
Type: "unknown_backup",
}
if entry.IsDir() {
if index < 4 {
if err = loadFileOrDirWithExclude(fileOp, index, childPath, &childNode, excludes); err != nil {
return err
}
childNode.Size = 0
for _, child := range childNode.Children {
childNode.Size += child.Size
}
} else {
itemSize, _ := fileOp.GetDirSize(childPath)
childNode.Size = uint64(itemSize)
}
if childNode.Size == 0 {
continue
}
} else {
info, _ := entry.Info()
childNode.Size = uint64(info.Size())
}
rootTree.Size += childNode.Size
rootTree.Children = append(rootTree.Children, childNode)
}
return nil
}
func isExactPathMatch(path string, excludePaths []string) bool {
cleanPath := filepath.Clean(path)
for _, excludePath := range excludePaths {
cleanExclude := filepath.Clean(excludePath)
if cleanPath == cleanExclude {
return true
}
}
return false
}
func loadRollBackTree(fileOp fileUtils.FileOp) []dto.CleanTree {
var treeData []dto.CleanTree
treeData = loadTreeWithCheck(treeData, path.Join(global.Dir.BaseDir, rollbackPath, "app"), "rollback_app", fileOp)
@@ -360,7 +617,6 @@ func loadRollBackTree(fileOp fileUtils.FileOp) []dto.CleanTree {
func loadUploadTree(fileOp fileUtils.FileOp) []dto.CleanTree {
var treeData []dto.CleanTree
treeData = loadTreeWithCheck(treeData, path.Join(global.Dir.BaseDir, tmpUploadPath), "upload_tmp", fileOp)
treeData = loadTreeWithCheck(treeData, path.Join(global.Dir.BaseDir, uploadPath, "app"), "upload_app", fileOp)
treeData = loadTreeWithCheck(treeData, path.Join(global.Dir.BaseDir, uploadPath, "website"), "upload_website", fileOp)
treeData = loadTreeWithCheck(treeData, path.Join(global.Dir.BaseDir, uploadPath, "database"), "upload_database", fileOp)
@@ -382,6 +638,23 @@ func loadDownloadTree(fileOp fileUtils.FileOp) []dto.CleanTree {
uploadTreeData := loadTreeWithAllFile(true, path5, "download", path5, fileOp)
treeData = append(treeData, uploadTreeData...)
appTmpDownloadTree := loadAppTmpDownloadTree(fileOp)
if len(appTmpDownloadTree) > 0 {
parentTree := dto.CleanTree{
ID: uuid.NewString(),
Label: "app_tmp_download",
IsCheck: true,
IsRecommend: true,
CanDelete: false,
Type: "app_tmp_download",
Name: "apps",
}
for _, child := range appTmpDownloadTree {
parentTree.Size += child.Size
}
parentTree.Children = appTmpDownloadTree
treeData = append(treeData, parentTree)
}
return treeData
}
@@ -393,20 +666,109 @@ func loadLogTree(fileOp fileUtils.FileOp) []dto.CleanTree {
for _, file := range list1 {
size += file.Size
}
treeData = append(treeData, dto.CleanTree{ID: uuid.NewString(), Label: "system_log", Size: uint64(size), Children: list1, Type: "system_log", IsRecommend: true})
treeData = append(treeData, dto.CleanTree{ID: uuid.NewString(), Label: "system_log", Size: size, Children: list1, Type: "system_log", IsRecommend: true, CanDelete: false})
path2 := path.Join(global.Dir.TaskDir)
list2 := loadTreeWithDir(false, "task_log", path2, fileOp)
size2, _ := fileOp.GetDirSize(path2)
treeData = append(treeData, dto.CleanTree{ID: uuid.NewString(), Label: "task_log", Size: uint64(size2), Children: list2, Type: "task_log"})
treeData = append(treeData, dto.CleanTree{ID: uuid.NewString(), Label: "task_log", Size: uint64(size2), Children: list2, Type: "task_log", CanDelete: false})
websiteLogList := loadWebsiteLogTree(fileOp)
logTotalSize := uint64(0)
for _, websiteLog := range websiteLogList {
logTotalSize += websiteLog.Size
}
treeData = append(treeData, dto.CleanTree{ID: uuid.NewString(), Label: "website_log", Size: logTotalSize, Children: websiteLogList, Type: "website_log", IsRecommend: false, CanDelete: false})
path3 := path.Join(global.Dir.TmpDir, "script")
list3 := loadTreeWithAllFile(true, path3, "script", path3, fileOp)
size3, _ := fileOp.GetDirSize(path3)
treeData = append(treeData, dto.CleanTree{ID: uuid.NewString(), Label: "script", Size: uint64(size3), Children: list3, Type: "script", IsRecommend: true})
return treeData
}
func loadWebsiteLogTree(fileOp fileUtils.FileOp) []dto.CleanTree {
websites, _ := websiteRepo.List()
if len(websites) == 0 {
return nil
}
var res []dto.CleanTree
for _, website := range websites {
size3, _ := fileOp.GetDirSize(path.Join(GetSiteDir(website.Alias), "log"))
res = append(res, dto.CleanTree{
ID: uuid.NewString(),
Label: website.PrimaryDomain,
Size: uint64(size3),
Type: "website_log",
Name: website.Alias,
CanDelete: true,
})
}
return res
}
func loadAppTmpDownloadTree(fileOp fileUtils.FileOp) []dto.CleanTree {
appDirs, err := os.ReadDir(global.Dir.RemoteAppResourceDir)
if err != nil {
return nil
}
var res []dto.CleanTree
for _, appDir := range appDirs {
if !appDir.IsDir() {
continue
}
appKey := appDir.Name()
app, _ := appRepo.GetFirst(appRepo.WithKey(appKey))
if app.ID == 0 {
continue
}
appPath := filepath.Join(global.Dir.RemoteAppResourceDir, appKey)
versionDirs, err := os.ReadDir(appPath)
if err != nil {
continue
}
appDetails, _ := appDetailRepo.GetBy(appDetailRepo.WithAppId(app.ID))
existingVersions := make(map[string]bool)
for _, appDetail := range appDetails {
existingVersions[appDetail.Version] = true
}
var missingVersions []string
for _, versionDir := range versionDirs {
if !versionDir.IsDir() {
continue
}
version := versionDir.Name()
if !existingVersions[version] {
missingVersions = append(missingVersions, version)
}
}
if len(missingVersions) > 0 {
var appTree dto.CleanTree
appTree.ID = uuid.NewString()
appTree.Label = app.Name
appTree.Type = "app_tmp_download"
appTree.Name = appKey
appTree.IsRecommend = true
appTree.IsCheck = true
appTree.CanDelete = false
for _, version := range missingVersions {
versionPath := filepath.Join(appPath, version)
size, _ := fileOp.GetDirSize(versionPath)
appTree.Size += uint64(size)
appTree.Children = append(appTree.Children, dto.CleanTree{
ID: uuid.NewString(),
Label: version,
Size: uint64(size),
IsCheck: true,
IsRecommend: true,
CanDelete: true,
Type: "app_tmp_download_version",
Name: path.Join(appKey, version),
})
}
res = append(res, appTree)
}
}
return res
}
func loadContainerTree() []dto.CleanTree {
var treeData []dto.CleanTree
client, err := docker.NewDockerClient()
@@ -424,7 +786,7 @@ func loadContainerTree() []dto.CleanTree {
imageSize += uint64(file.Size)
}
}
treeData = append(treeData, dto.CleanTree{ID: uuid.NewString(), Label: "container_images", Size: imageSize, Children: nil, Type: "images", IsRecommend: true})
treeData = append(treeData, dto.CleanTree{ID: uuid.NewString(), Label: "container_images", Size: imageSize, Children: nil, Type: "images", IsRecommend: true, CanDelete: true})
containerSize := uint64(0)
for _, file := range diskUsage.Containers {
@@ -432,7 +794,7 @@ func loadContainerTree() []dto.CleanTree {
containerSize += uint64(file.SizeRw)
}
}
treeData = append(treeData, dto.CleanTree{ID: uuid.NewString(), Label: "container_containers", Size: containerSize, Children: nil, Type: "containers", IsRecommend: true})
treeData = append(treeData, dto.CleanTree{ID: uuid.NewString(), Label: "container_containers", Size: containerSize, Children: nil, Type: "containers", IsRecommend: true, CanDelete: true})
volumeSize := uint64(0)
for _, file := range diskUsage.Volumes {
@@ -440,7 +802,7 @@ func loadContainerTree() []dto.CleanTree {
volumeSize += uint64(file.UsageData.Size)
}
}
treeData = append(treeData, dto.CleanTree{ID: uuid.NewString(), Label: "container_volumes", Size: volumeSize, IsCheck: volumeSize > 0, Children: nil, Type: "volumes", IsRecommend: true})
treeData = append(treeData, dto.CleanTree{ID: uuid.NewString(), Label: "container_volumes", Size: volumeSize, IsCheck: volumeSize > 0, Children: nil, Type: "volumes", IsRecommend: true, CanDelete: true})
var buildCacheTotalSize int64
for _, cache := range diskUsage.BuildCache {
@@ -448,7 +810,7 @@ func loadContainerTree() []dto.CleanTree {
buildCacheTotalSize += cache.Size
}
}
treeData = append(treeData, dto.CleanTree{ID: uuid.NewString(), Label: "build_cache", Size: uint64(buildCacheTotalSize), IsCheck: buildCacheTotalSize > 0, Type: "build_cache", IsRecommend: true})
treeData = append(treeData, dto.CleanTree{ID: uuid.NewString(), Label: "build_cache", Size: uint64(buildCacheTotalSize), IsCheck: buildCacheTotalSize > 0, Type: "build_cache", IsRecommend: true, CanDelete: true})
return treeData
}
@@ -458,7 +820,7 @@ func loadTreeWithCheck(treeData []dto.CleanTree, pathItem, treeType string, file
return treeData
}
list := loadTreeWithAllFile(true, pathItem, treeType, pathItem, fileOp)
treeData = append(treeData, dto.CleanTree{ID: uuid.NewString(), Label: treeType, Size: uint64(size), IsCheck: size > 0, Children: list, Type: treeType, IsRecommend: true})
treeData = append(treeData, dto.CleanTree{ID: uuid.NewString(), Label: treeType, Size: uint64(size), IsCheck: size > 0, Children: list, Type: treeType, IsRecommend: true, CanDelete: false})
return treeData
}
@@ -485,6 +847,7 @@ func loadTreeWithDir(isCheck bool, treeType, pathItem string, fileOp fileUtils.F
Name: strings.TrimPrefix(file.Name(), "/"),
IsCheck: isCheck,
IsRecommend: isCheck,
CanDelete: true,
}
lists = append(lists, item)
}
@@ -528,10 +891,11 @@ func loadTreeWithAllFile(isCheck bool, originalPath, treeType, pathItem string,
ID: uuid.NewString(),
Label: file.Name(),
Type: treeType,
Size: uint64(size),
Size: size,
Name: name,
IsCheck: isCheck,
IsRecommend: isCheck,
CanDelete: true,
}
if file.IsDir() {
item.Children = loadTreeWithAllFile(isCheck, originalPath, treeType, path.Join(pathItem, file.Name()), fileOp)
@@ -546,6 +910,18 @@ func dropFileOrDir(itemPath string) {
global.LOG.Errorf("drop file %s failed, err %v", itemPath, err)
}
}
func dropFile(itemPath string) {
info, err := os.Stat(itemPath)
if err != nil {
return
}
if info.IsDir() {
return
}
if err := os.Remove(itemPath); err != nil {
global.LOG.Errorf("drop file %s failed, err %v", itemPath, err)
}
}
func dropBuildCache() (int, int) {
client, err := docker.NewDockerClient()
@@ -621,6 +997,18 @@ func dropVolumes() (int, int) {
return len(res.VolumesDeleted), int(res.SpaceReclaimed)
}
func dropWebsiteLog(alias string) {
accessLogPath := path.Join(GetSiteDir(alias), "log", "access.log")
errorLogPath := path.Join(GetSiteDir(alias), "log", "error.log")
if err := os.Truncate(accessLogPath, 0); err != nil {
global.LOG.Errorf("truncate access log %s failed, err %v", accessLogPath, err)
}
if err := os.Truncate(errorLogPath, 0); err != nil {
global.LOG.Errorf("truncate error log %s failed, err %v", errorLogPath, err)
}
}
func dropTaskLog(logDir string) {
files, err := os.ReadDir(logDir)
if err != nil {
@@ -732,3 +1120,36 @@ func scanFile(pathItem string, size *int64, count *int) {
}
}
}
func cleanEmptyDirs(root string) error {
dirsToCheck := make([]string, 0)
err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
if err != nil {
return nil
}
if d.IsDir() {
dirsToCheck = append(dirsToCheck, path)
}
return nil
})
if err != nil {
return err
}
for i := len(dirsToCheck) - 1; i >= 0; i-- {
dir := dirsToCheck[i]
if dir == root {
continue
}
entries, err := os.ReadDir(dir)
if err != nil {
continue
}
if len(entries) == 0 {
_ = os.Remove(dir)
}
}
return nil
}
+24 -10
View File
@@ -2,13 +2,14 @@ package service
import (
"fmt"
"os"
"strings"
"time"
"github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/buserr"
"github.com/1Panel-dev/1Panel/agent/global"
"github.com/1Panel-dev/1Panel/agent/utils/cmd"
"os"
"strings"
"time"
"github.com/1Panel-dev/1Panel/agent/app/dto/request"
"github.com/1Panel-dev/1Panel/agent/app/dto/response"
@@ -50,6 +51,9 @@ func (s *DiskService) GetCompleteDiskInfo() (*response.CompleteDiskInfo, error)
}
func (s *DiskService) PartitionDisk(req request.DiskPartitionRequest) (string, error) {
if cmd.CheckIllegal(req.Device, req.Filesystem, req.MountPoint, req.Label) {
return "", buserr.New("ErrCmdIllegal")
}
if !strings.HasPrefix("/dev", req.Device) {
req.Device = "/dev/" + req.Device
}
@@ -62,19 +66,19 @@ func (s *DiskService) PartitionDisk(req request.DiskPartitionRequest) (string, e
}
cmdMgr := cmd.NewCommandMgr(cmd.WithTimeout(10 * time.Second))
if err := cmdMgr.RunBashC(fmt.Sprintf("partprobe %s", req.Device)); err != nil {
if err := cmdMgr.Run("partprobe", req.Device); err != nil {
return "", buserr.WithErr("PartitionDiskErr", err)
}
if err := cmdMgr.RunBashC(fmt.Sprintf("parted -s %s mklabel gpt", req.Device)); err != nil {
if err := cmdMgr.Run("parted", "-s", req.Device, "mklabel", "gpt"); err != nil {
return "", buserr.WithErr("PartitionDiskErr", err)
}
if err := cmdMgr.RunBashC(fmt.Sprintf("parted -s %s mkpart primary 1MiB 100%%", req.Device)); err != nil {
if err := cmdMgr.Run("parted", "-s", req.Device, "mkpart", "primary", "1MiB", "100%"); err != nil {
return "", buserr.WithErr("PartitionDiskErr", err)
}
if err := cmdMgr.RunBashC(fmt.Sprintf("partprobe %s", req.Device)); err != nil {
if err := cmdMgr.Run("partprobe", req.Device); err != nil {
return "", buserr.WithErr("PartitionDiskErr", err)
}
partition := req.Device + "1"
@@ -104,6 +108,9 @@ func (s *DiskService) PartitionDisk(req request.DiskPartitionRequest) (string, e
}
func (s *DiskService) MountDisk(req request.DiskMountRequest) error {
if cmd.CheckIllegal(req.Device, req.MountPoint, req.Filesystem) {
return buserr.New("ErrCmdIllegal")
}
if !deviceExists(req.Device) {
return buserr.WithName("DeviceNotFound", req.Device)
}
@@ -127,11 +134,15 @@ func (s *DiskService) MountDisk(req request.DiskMountRequest) error {
}
cmdMgr := cmd.NewCommandMgr(cmd.WithTimeout(1 * time.Minute))
if err := cmdMgr.RunBashC(fmt.Sprintf("mount -t %s %s %s", req.Filesystem, req.Device, req.MountPoint)); err != nil {
if err := cmdMgr.Run("mount", "-t", req.Filesystem, req.Device, req.MountPoint); err != nil {
return buserr.WithErr("MountDiskErr", err)
}
if req.AutoMount {
if err := addToFstabWithOptions(req.Device, req.MountPoint, req.Filesystem, ""); err != nil {
options := ""
if req.NoFail {
options = "defaults,nofail"
}
if err := addToFstabWithOptions(req.Device, req.MountPoint, req.Filesystem, options); err != nil {
return buserr.WithErr("MountDiskErr", err)
}
}
@@ -140,10 +151,13 @@ func (s *DiskService) MountDisk(req request.DiskMountRequest) error {
}
func (s *DiskService) UnmountDisk(req request.DiskUnmountRequest) error {
if cmd.CheckIllegal(req.MountPoint) {
return buserr.New("ErrCmdIllegal")
}
if !isPointMounted(req.MountPoint) {
return buserr.New("MountDiskErr")
}
if err := cmd.RunDefaultBashC(fmt.Sprintf("umount -f %s", req.MountPoint)); err != nil {
if err := cmd.NewCommandMgr(cmd.WithTimeout(20*time.Second)).Run("umount", "-f", req.MountPoint); err != nil {
return buserr.WithErr("MountDiskErr", err)
}
if err := removeFromFstab(req.MountPoint); err != nil {
+18 -1
View File
@@ -463,6 +463,11 @@ func isPointMounted(mountPoint string) bool {
}
func addToFstabWithOptions(device, mountPoint, filesystem, options string) error {
uuid, err := getDeviceUUID(device)
if err != nil {
return fmt.Errorf("failed to get UUID for device %s: %v", device, err)
}
if filesystem == "" {
fsType, err := getFilesystemType(device)
if err != nil {
@@ -476,7 +481,7 @@ func addToFstabWithOptions(device, mountPoint, filesystem, options string) error
options = "defaults"
}
entry := fmt.Sprintf("%s %s %s %s 0 2\n", device, mountPoint, filesystem, options)
entry := fmt.Sprintf("UUID=%s %s %s %s 0 2\n", uuid, mountPoint, filesystem, options)
file, err := os.OpenFile("/etc/fstab", os.O_APPEND|os.O_WRONLY, 0644)
if err != nil {
@@ -568,3 +573,15 @@ func parseSizeToBytes(sizeStr string) int64 {
return val
}
}
func getDeviceUUID(device string) (string, error) {
output, err := cmd.RunDefaultWithStdoutBashC(fmt.Sprintf("blkid -s UUID -o value %s", device))
if err != nil {
return "", err
}
uuid := strings.TrimSpace(output)
if uuid == "" {
return "", fmt.Errorf("no UUID found for device %s", device)
}
return uuid, nil
}
+8 -4
View File
@@ -12,9 +12,12 @@ var (
appInstallResourceRepo = repo.NewIAppInstallResourceRpo()
appIgnoreUpgradeRepo = repo.NewIAppIgnoreUpgradeRepo()
aiRepo = repo.NewIAiRepo()
mcpServerRepo = repo.NewIMcpServerRepo()
tensorrtLLMRepo = repo.NewITensorRTLLMRepo()
aiRepo = repo.NewIAiRepo()
mcpServerRepo = repo.NewIMcpServerRepo()
tensorrtLLMRepo = repo.NewITensorRTLLMRepo()
agentRepo = repo.NewIAgentRepo()
agentAccountRepo = repo.NewIAgentAccountRepo()
agentAccountModelRepo = repo.NewIAgentAccountModelRepo()
mysqlRepo = repo.NewIMysqlRepo()
postgresqlRepo = repo.NewIPostgresqlRepo()
@@ -46,7 +49,8 @@ var (
runtimeRepo = repo.NewIRunTimeRepo()
phpExtensionsRepo = repo.NewIPHPExtensionsRepo()
favoriteRepo = repo.NewIFavoriteRepo()
favoriteRepo = repo.NewIFavoriteRepo()
fileShareRepo = repo.NewIFileShareRepo()
taskRepo = repo.NewITaskRepo()
+284 -24
View File
@@ -3,6 +3,7 @@ package service
import (
"bufio"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
@@ -40,6 +41,7 @@ import (
"github.com/1Panel-dev/1Panel/agent/global"
"github.com/1Panel-dev/1Panel/agent/utils/common"
"github.com/1Panel-dev/1Panel/agent/utils/files"
terminalai "github.com/1Panel-dev/1Panel/agent/utils/terminal/ai"
"github.com/pkg/errors"
)
@@ -75,11 +77,15 @@ type IFileService interface {
GetUsersAndGroups() (*response.UserGroupResponse, error)
Convert(req request.FileConvertRequest)
ConvertLog(req dto.PageInfo) (int64, []response.FileConvertLog, error)
BatchGetRemarks(req request.FileRemarkBatch) map[string]string
SetRemark(req request.FileRemarkUpdate) error
AISearch(req request.FileAISearch) (*response.FileAISearchResult, error)
}
var filteredPaths = []string{
"/.1panel_clash",
}
const (
fileRemarkXattr = "user.1panel.remark"
fileRemarkEncodedMaxLen = 256
)
func NewIFileService() IFileService {
return &FileService{}
@@ -98,10 +104,29 @@ func (f *FileService) GetFileList(op request.FileOption) (response.FileInfo, err
if err != nil {
return fileInfo, err
}
shareMap, err := NewIFileShareService().SharePathCodeMap()
if err != nil {
return fileInfo, err
}
applyFileShares(info, shareMap)
fileInfo.FileInfo = *info
return fileInfo, nil
}
func applyFileShares(info *files.FileInfo, shareMap map[string]string) {
if info == nil {
return
}
if code, ok := shareMap[info.Path]; ok {
info.ShareCode = code
} else {
info.ShareCode = ""
}
for _, item := range info.Items {
applyFileShares(item, shareMap)
}
}
func (f *FileService) SearchUploadWithPage(req request.SearchUploadWithPage) (int64, interface{}, error) {
var (
files []response.UploadInfo
@@ -162,14 +187,7 @@ func (f *FileService) GetFileTree(op request.FileOption) ([]response.FileTree, e
}
func shouldFilterPath(path string) bool {
cleanedPath := filepath.Clean(path)
for _, filteredPath := range filteredPaths {
cleanedFilteredPath := filepath.Clean(filteredPath)
if cleanedFilteredPath == cleanedPath || strings.HasPrefix(cleanedPath, cleanedFilteredPath+"/") {
return true
}
}
return false
return files.ShouldFilterSensitivePath(path)
}
func (f *FileService) buildFileTree(node *response.FileTree, items []*files.FileInfo, op request.FileOption, level int) error {
@@ -255,7 +273,7 @@ func (f *FileService) Create(op request.FileCreate) error {
func (f *FileService) Delete(op request.FileDelete) error {
if op.IsDir {
excludeDir := global.Dir.DataDir
if filepath.Base(op.Path) == ".1panel_clash" || op.Path == excludeDir {
if path.Base(op.Path) == ".1panel_clash" || op.Path == excludeDir {
return buserr.New("ErrPathNotDelete")
}
}
@@ -307,19 +325,18 @@ func (f *FileService) ChangeMode(op request.FileCreate) error {
func (f *FileService) BatchChangeModeAndOwner(op request.FileRoleReq) error {
fo := files.NewFileOp()
for _, path := range op.Paths {
if !fo.Stat(path) {
for _, p := range op.Paths {
if !fo.Stat(p) {
return buserr.New("ErrPathNotFound")
}
if err := fo.ChownR(path, op.User, op.Group, op.Sub); err != nil {
return err
}
if err := fo.ChmodR(path, op.Mode, op.Sub); err != nil {
return err
}
}
if err := fo.ChownRPaths(op.Paths, op.User, op.Group, op.Sub); err != nil {
return err
}
if err := fo.ChmodRPaths(op.Paths, op.Mode, op.Sub); err != nil {
return err
}
return nil
}
func (f *FileService) ChangeOwner(req request.FileRoleUpdate) error {
@@ -585,6 +602,12 @@ func (f *FileService) DepthDirSize(req request.DirSizeReq) ([]response.DepthDirS
func (f *FileService) ReadLogByLine(req request.FileReadByLineReq) (*response.FileLineContent, error) {
logFilePath := ""
taskStatus := ""
if len(req.Name) != 0 {
safeName := path.Base(req.Name)
if safeName != req.Name || strings.Contains(safeName, "..") {
return nil, buserr.New("ErrInvalidParams")
}
}
switch req.Type {
case constant.TypeWebsite:
website, err := websiteRepo.GetFirst(repo.WithByID(req.ID))
@@ -642,9 +665,9 @@ func (f *FileService) ReadLogByLine(req request.FileReadByLineReq) (*response.Fi
logFilePath = taskModel.LogFile
taskStatus = taskModel.Status
case "mysql-slow-logs":
logFilePath = path.Join(global.Dir.DataDir, fmt.Sprintf("apps/mysql/%s/data/1Panel-slow.log", req.Name))
logFilePath = path.Join(global.Dir.DataDir, "apps", "mysql", req.Name, "data", "1Panel-slow.log")
case "mariadb-slow-logs":
logFilePath = path.Join(global.Dir.DataDir, fmt.Sprintf("apps/mariadb/%s/db/data/1Panel-slow.log", req.Name))
logFilePath = path.Join(global.Dir.DataDir, "apps", "mariadb", req.Name, "db", "data", "1Panel-slow.log")
case "php-fpm-slow-logs":
php, err := runtimeRepo.GetFirst(context.Background(), repo.WithByID(req.ID))
if err != nil {
@@ -658,6 +681,8 @@ func (f *FileService) ReadLogByLine(req request.FileReadByLineReq) (*response.Fi
configPath = pathSet.Value
}
logFilePath, _ = ini_conf.GetIniValue(configPath, "supervisord", "logfile")
case constant.Supervisor:
logFilePath = path.Join(global.Dir.DataDir, "tools", "supervisord", "log", req.Name)
}
file, err := os.Open(logFilePath)
@@ -676,7 +701,7 @@ func (f *FileService) ReadLogByLine(req request.FileReadByLineReq) (*response.Fi
logFileRes *dto.LogFileRes
)
if stat.Size() > files.MaxReadFileSize {
lines, err = files.TailFromEnd(logFilePath, req.PageSize)
lines, _ = files.TailFromEnd(logFilePath, req.PageSize)
isEndOfFile = true
scope = "tail"
} else {
@@ -757,6 +782,52 @@ func (f *FileService) GetUsersAndGroups() (*response.UserGroupResponse, error) {
}, nil
}
func (f *FileService) BatchGetRemarks(req request.FileRemarkBatch) map[string]string {
remarks := make(map[string]string)
for _, filePath := range req.Paths {
remark, err := getFileRemark(filePath)
if err != nil {
if isXattrNotSupported(err) {
return map[string]string{}
}
continue
}
if remark == "" {
continue
}
remarks[filePath] = remark
}
return remarks
}
func (f *FileService) SetRemark(req request.FileRemarkUpdate) error {
if req.Remark == "" {
if err := unix.Lremovexattr(req.Path, fileRemarkXattr); err != nil {
if isXattrNotFound(err) {
return nil
}
if isXattrNotSupported(err) {
return buserr.WithDetail("ErrInvalidParams", "xattr not supported", err)
}
return err
}
return nil
}
encoded := base64.StdEncoding.EncodeToString([]byte(req.Remark))
if len(encoded) >= fileRemarkEncodedMaxLen {
return buserr.WithDetail("ErrInvalidParams", "remark length must be less than 256", nil)
}
if err := unix.Lsetxattr(req.Path, fileRemarkXattr, []byte(encoded), 0); err != nil {
if isXattrNotSupported(err) {
return buserr.WithDetail("ErrInvalidParams", "xattr not supported", err)
}
return err
}
return nil
}
func getValidGroups() (map[string]bool, error) {
groupFile, err := os.Open("/etc/group")
if err != nil {
@@ -783,6 +854,37 @@ func getValidGroups() (map[string]bool, error) {
return groupMap, nil
}
func getFileRemark(filePath string) (string, error) {
size, err := unix.Lgetxattr(filePath, fileRemarkXattr, nil)
if err != nil {
if isXattrNotFound(err) {
return "", nil
}
return "", err
}
if size == 0 {
return "", nil
}
buf := make([]byte, size)
n, err := unix.Lgetxattr(filePath, fileRemarkXattr, buf)
if err != nil {
return "", err
}
decoded, err := base64.StdEncoding.DecodeString(string(buf[:n]))
if err != nil {
return "", err
}
return string(decoded), nil
}
func isXattrNotSupported(err error) bool {
return errors.Is(err, unix.ENOTSUP) || errors.Is(err, unix.EOPNOTSUPP)
}
func isXattrNotFound(err error) bool {
return errors.Is(err, unix.ENODATA)
}
func getValidUsers(validGroups map[string]bool) ([]response.UserInfo, map[string]struct{}, error) {
passwdFile, err := os.Open("/etc/passwd")
if err != nil {
@@ -927,3 +1029,161 @@ func (f *FileService) ConvertLog(req dto.PageInfo) (total int64, data []response
return total, data, nil
}
func (f *FileService) AISearch(req request.FileAISearch) (*response.FileAISearchResult, error) {
root := filepath.Clean(strings.TrimSpace(req.Path))
if root == "" {
return nil, buserr.WithDetail("ErrInvalidParams", "path is required", nil)
}
query := strings.TrimSpace(req.Query)
if query == "" {
return nil, buserr.WithDetail("ErrInvalidParams", "query is required", nil)
}
st, err := os.Stat(root)
if err != nil {
if os.IsNotExist(err) {
return nil, buserr.New("ErrPathNotFound")
}
return nil, err
}
if !st.IsDir() {
return nil, buserr.New("ErrPathNotFound")
}
maxItems := req.MaxItems
if maxItems <= 0 {
maxItems = files.DefaultFileAIMaxItems
}
if maxItems > 2000 {
maxItems = 2000
}
containSub := true
if req.ContainSub != nil {
containSub = *req.ContainSub
}
searchOpts, err := files.MergeContentSearchOptions(
req.MatchCase, req.WholeWord, req.UseRegex,
req.Extensions,
req.MinSize, req.MaxSize,
req.ModifiedAfter, req.ModifiedBefore,
req.MaxScanFiles,
req.MaxFileBytes,
req.MaxHitsPerFile, req.MaxTotalHits,
req.ContentHitsPromptMaxBytes,
req.LlmMaxOutputTokens,
)
if err != nil {
return nil, buserr.WithDetail("ErrInvalidParams", err.Error(), nil)
}
matchFn, err := files.NewContentLineMatcher(query, searchOpts)
if err != nil {
return nil, buserr.WithDetail("ErrFileAISearchBadPattern", err.Error(), nil)
}
cfg, timeout, err := terminalai.LoadFileAIRuntimeConfig()
aiEnabled := err == nil
if err != nil && !errors.Is(err, os.ErrNotExist) {
return nil, err
}
items, truncated, err := files.CollectDirInventory(root, containSub, maxItems)
if err != nil {
return nil, err
}
preFiltered := false
llmItems := items
qLower := strings.ToLower(query)
if len(llmItems) > 0 && query != "" {
filtered := make([]files.AISearchInventoryItem, 0, len(llmItems))
for _, it := range llmItems {
rel := strings.TrimSpace(it.RelPath)
if rel == "" {
continue
}
if !req.UseRegex && !req.MatchCase && !req.WholeWord && strings.Contains(strings.ToLower(rel), qLower) {
filtered = append(filtered, it)
}
}
if len(filtered) >= 8 {
llmItems = filtered
preFiltered = true
}
}
start := time.Now()
contentHits, scannedFiles, hitsTrunc := files.SearchFileAIContentHits(root, llmItems, searchOpts, matchFn)
hitsDTO := make([]response.FileAIContentHit, 0, len(contentHits))
for _, h := range contentHits {
hitsDTO = append(hitsDTO, response.FileAIContentHit{Path: h.Path, Line: h.Line, Text: h.Text})
}
matchDesc := searchOpts.ContentMatchDescription()
result := &response.FileAISearchResult{
Hits: hitsDTO,
ContentScannedFiles: scannedFiles,
ContentHitsTruncated: hitsTrunc,
Truncated: truncated,
PreFiltered: preFiltered,
ItemCount: len(llmItems),
}
if len(llmItems) == 0 {
if aiEnabled {
result.Mode = "ai"
result.Summary = i18n.GetMsgByKey("FileAISearchEmptyDir")
if result.Summary == "" || result.Summary == "FileAISearchEmptyDir" {
result.Summary = "No files or directories found under this path (or all entries were filtered)."
}
} else {
result.Mode = "grep"
result.Summary = ""
}
result.Duration = time.Since(start).Round(time.Millisecond).String()
return result, nil
}
if !aiEnabled {
result.Mode = "grep"
result.Summary = ""
result.Duration = time.Since(start).Round(time.Millisecond).String()
return result, nil
}
result.Mode = "ai"
clientTimeout := timeout
if clientTimeout < 30*time.Second {
clientTimeout = 90 * time.Second
}
if clientTimeout > 5*time.Minute {
clientTimeout = 5 * time.Minute
}
runCtx, cancel := context.WithTimeout(context.Background(), timeout+time.Minute)
defer cancel()
llmMaxOut := searchOpts.LlmMaxOutputTokens
summary, usage, err := files.RunFileAISearchLLM(runCtx, cfg, clientTimeout, root, query, req.ResponseLanguage, llmItems, truncated, preFiltered, contentHits, scannedFiles, hitsTrunc, matchDesc, searchOpts.ContentHitsPromptMaxBytes, llmMaxOut)
if err != nil {
result.Mode = "grep"
result.Summary = ""
result.Duration = time.Since(start).Round(time.Millisecond).String()
if errors.Is(err, context.DeadlineExceeded) || strings.Contains(strings.ToLower(err.Error()), "timeout") {
return result, nil
}
return result, nil
}
result.Summary = summary
result.PromptTokens = usage.PromptTokens
result.CompletionTokens = usage.CompletionTokens
result.TotalTokens = usage.TotalTokens
if result.TotalTokens == 0 {
result.TotalTokens = usage.PromptTokens + usage.CompletionTokens
}
result.Duration = time.Since(start).Round(time.Millisecond).String()
return result, nil
}

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