Compare commits

..
126 Commits
Author SHA1 Message Date
Yuki Guo ecedaf8c21 feat(container): support batch upgrades with bounded concurrency and recovery (#14231) 2026-10-28 18:45:13 +08:00
ssongliu d7c42e3e8b fix(ai): unify model deletion prompts and register template route (#14233) 2026-10-28 18:20:06 +08:00
蘭 d32cbb40f6 feat(mcp): add Panel MCP task configuration and website binding structures (#14229)
* feat(mcp): add Panel MCP task configuration and website binding structures

* fix(mcp): correct variable assignment in mcpWebsiteDomain function
2026-10-28 14:04:38 +08:00
ssongliu 8684a26459 feat(ai): refine model navigation and downloader translations (#14228) 2026-10-28 12:11:41 +08:00
CityFun 1798f9768e fix(ai): preserve legacy Feishu and safely migrate OpenClaw 2026.10.7 upgrades (#14214)
* fix(ai): preserve legacy Feishu and migrate bundled plugin upgrades

* fix(ai): override inherited Feishu allowlists when cleared
2026-10-28 11:24:03 +08:00
Yuki Guo eb4beb50c4 fix: docker image pull and stop (#14202) 2026-10-27 23:07:21 +08:00
ssongliu 3a7cd211af fix: unify database credential validation (#14216) 2026-10-27 23:06:13 +08:00
ssongliu bd0bc0079e refactor(ai): remove obsolete tokenizer directory patterns (#14225) 2026-10-27 23:05:54 +08:00
ssongliu 93ed58d784 fix(terminal): improve dock button border in dark mode (#14224) 2026-10-27 17:22:51 +08:00
ssongliu 8d320ef2ca feat(node): add translations for existing node packages (#14222) 2026-10-27 17:12:14 +08:00
ssongliu b3c1e0df42 refactor(frontend): use DialogPro for task logs (#14212) 2026-10-27 14:34:20 +08:00
ssongliu 5346d5f58a fix(ai): reconcile Ollama models and improve engine management (#14211) 2026-10-27 11:06:32 +08:00
Raphael Fakhri ac09426f10 fix(app): keep signed numeric env values unchanged (#14180) 2026-10-27 09:58:53 +08:00
CityFun 19707a68dd fix(agent): support new Hermes Weixin module path (#14197) 2026-10-26 23:34:56 +08:00
ssongliu 7c7fd492c4 feat(ai): support benchmark report routes and parameter translations (#14210) 2026-10-26 23:33:17 +08:00
ssongliu fbf66766c2 fix: align AI menu order and labels (#14192) 2026-10-26 14:03:40 +08:00
蘭 d7355f3ccc feat(mcp): add 1Panel MCP and enhance MCP credential management (#14189)
* feat(mcp): add 1Panel MCP and enhance MCP credential management

* feat(i18n): implement MCP document localization and remove legacy code
2026-10-26 13:59:49 +08:00
CityFun 93ca724985 fix(appstore): refresh cached app resources and PHP runtime version options (#14185) 2026-10-26 11:29:29 +08:00
ssongliu 9481a7535d fix: align AI menu order and labels (#14183) 2026-10-26 11:29:20 +08:00
ssongliu 14be5b7870 feat(ai): translate benchmark comparison metrics (#14182) 2026-10-25 14:05:09 +08:00
ssongliu c4b76a8e89 feat(ai): unify model services and inference engine management (#14175) 2026-10-23 18:39:22 +08:00
Woolsenandssongliu 9295f4ef8a fix: correct 1pctl password complexity validation (#14169)
* fix: correct 1pctl password complexity validation

`isValidPassword("newPassword")` passed a literal string and the condition was inverted, so the complexity check never ran. Extract the validation into a testable helper and cover both the enabled and disabled paths.

Refs #12640

* Delete core/cmd/server/cmd/update_test.go

---------

Co-authored-by: ssongliu <sloooop1x@gmail.com>
2026-10-23 18:26:25 +08:00
Yuki Guo c925184bf6 fix: container list (#14156)
* fix: container list

* fix: container list
2026-10-23 18:19:52 +08:00
CityFun 87828b03ae fix(website): sync PHP runtime changes to subsites (#14166)
Update subsite upstreams and runtime references together with the parent site. Preserve stopped sites and custom PHP settings, and restore configurations and database records when applying the change fails.
2026-10-23 18:19:44 +08:00
Yuki Guo e7b4146e1b fix: operation log text (#14173) 2026-10-23 18:19:30 +08:00
Yuki Guo ec998552b9 feat: ca website upload (#14148) 2026-10-23 18:12:57 +08:00
Woolsen 1af237944d fix(file): reject rename when the target already exists (#13946) (#14168) 2026-10-23 14:05:48 +08:00
CityFun f9f9169a4d fix(build): upgrade Go to 1.27.3 (#14161) 2026-10-22 15:41:34 +08:00
zhengkunwang223 1e01761ba3 fix(appstore): refresh cached resources and PHP versions
Version official app package and compose downloads using the synchronized store revision. Refresh outdated local resource caches and use synchronized database parameters for official PHP version options.
2026-10-22 11:53:36 +08:00
CityFun 15283fde34 feat: update deepseek default model (#14146) 2026-10-21 18:37:22 +08:00
Octopusandocto-patch 49f8e7f040 feat(ai-agents): refresh MiniMax Flash models and defaults (#14136)
Co-authored-by: octo-patch <octo-patch@users.noreply.github.com>
2026-10-21 18:23:53 +08:00
Yuki Guo 98b27af6a0 fix: container list search (#14134) 2026-10-21 18:22:02 +08:00
ssongliu a393224729 feat(ai): support benchmark node options and report integration (#14145) 2026-10-21 18:07:39 +08:00
Woolsen b851c84290 fix(container): reset sort to default when cleared (#14140)
The container list opens with name ascending but shows no sort indicator. Clearing the name sort kept the previous descending order, so two no-indicator states represented different orders. Reset the request to the default name ascending when the sort is cleared.
2026-10-21 18:01:18 +08:00
Woolsen 372855157d feat(container): support sorting by creation time (#14141)
Add a creation time column to the container list. The column uses the backend createdAt order field and displays the existing createTime value. It follows the standard three-state sort cycle, starting with newest first.
2026-10-21 18:01:07 +08:00
CityFunandssongliu 577d771495 feat(agent): add DeepSeek Harness and OctoP support (#14138)
* feat(agent): add DeepSeek Harness and OctoP support

* fix: restore default core edition configuration

---------

Co-authored-by: ssongliu <sloooop1x@gmail.com>
2026-10-21 17:25:38 +08:00
Yuki Guo ca3e6511e5 feat: ssl push multiple directories (#14137) 2026-10-21 17:23:56 +08:00
Yuki Guo 8af14eae25 feat: add container catalog batch upload (#14133) 2026-10-21 17:09:01 +08:00
ssongliu 175fbaf3f2 fix: correct terminal node routing for containers (#14142) 2026-10-21 16:40:42 +08:00
Yuki Guo 13199efd62 feat: add overview more quick menu (#14132) 2026-10-21 16:21:07 +08:00
HynoRandCursor 04099ae2a2 feat(terminal): add session grace expiry tests and improve detach logic (#14127)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-19 17:54:05 +08:00
ssongliu c695a2a408 fix(firewall): prepare access whitelist before startup (#14114) 2026-10-16 17:27:48 +08:00
ssongliu 690cb48280 feat(vllm): refine monitor snapshots and historical request counts (#14109) 2026-10-16 13:36:29 +08:00
ssongliu f226fed8cd feat(firewall): support restoring rule backups on startup (#14108) 2026-10-16 10:50:07 +08:00
Yuki Guo 110c292856 chore: chore home default css (#14101) 2026-10-15 15:45:25 +08:00
Kadxyandssongliu 8e865c2c46 fix: stop translating the built-in Default group name (#14100)
* fix: stop translating the built-in Default group name

* fix: resolve default cronjob group by flag

---------

Co-authored-by: ssongliu <sloooop1x@gmail.com>
2026-10-15 15:42:18 +08:00
Kadxy 17b8f472e7 fix(cronjob): show Yes/No text instead of check icon in script library interactive column (#14099) 2026-10-15 15:25:03 +08:00
Yuki Guo d9dc5d7aa9 fix: alert config log text (#14086)
* fix: alert config log text

* fix: alert config log text
2026-10-15 09:33:23 +08:00
ssongliu 547f9775b0 fix(firewall): support legacy ip RA checks and link documentation (#14083) 2026-10-14 15:57:07 +08:00
ssongliu f37afb1a37 fix: sync core panel port whitelist on agent startup (#14080) 2026-10-14 11:28:46 +08:00
CityFun 3b89d83554 feat(hermes-agent): support version upgrades (#14079) 2026-10-14 11:28:32 +08:00
蘭 12bfe6a1bd fix: Fix Webhook Response Judgment and Optimize Configuration and Testing Experience (#14077) 2026-10-14 11:28:21 +08:00
ssongliu aaf70cfc41 fix(log): improve operation logs (#14076) 2026-10-13 18:28:34 +08:00
Yuki Guo 1ecd4f7f0c fix: container log text (#14066) 2026-10-13 17:56:52 +08:00
CityFun a8ccdcade7 fix(ai-gateway): support backup logic during app upgrades (#14072) 2026-10-13 17:28:42 +08:00
ssongliu 4a89c0c031 fix: prevent refresh loop after unbinding current node license (#14064) 2026-10-13 11:20:48 +08:00
ssongliu f21ebb1a36 chore(deps): consolidate dependency and security updates (#14063) 2026-10-13 11:20:43 +08:00
ssongliu 3011383b07 fix(compose): handle relative paths and preserve multi-file projects (#14062) 2026-10-13 11:03:17 +08:00
Yuki Guo fd12866897 fix: fix website files (#14042)
* refactor: refactor website files

* refactor: refactor website files
2026-10-13 10:56:30 +08:00
ssongliu bc6cf519ce fix: improve database file handling (#14043) 2026-10-13 09:39:27 +08:00
ssongliu f9c43989fe fix: improve firewall compatibility (#14046) 2026-10-13 09:39:18 +08:00
Yuki Guo e90b201001 refactor: remove user tooltip (#14041) 2026-10-12 18:18:44 +08:00
ssongliu f7394938f7 chore(deps): update remaining dependencies and VueUse 15 (#14040) 2026-10-12 16:05:35 +08:00
ssongliu dada7294dd fix(firewall): preserve fail2ban priority when binding iptables chains (#14039)
Refs #14019
2026-10-12 15:54:55 +08:00
ssongliu afcb94f6ef feat(vllm): add request metrics and optimize monitor history (#14032) 2026-10-12 15:54:42 +08:00
ssongliu 5a2e430dff fix(home): calculate overview rates from actual sample intervals (#14033) 2026-10-12 15:52:34 +08:00
Yuki Guo f2ca4f56e6 feat: add Netcup DNS (#14038) 2026-10-12 15:03:07 +08:00
ssongliu b6f609aaee chore(deps): update dependencies and apply security patches (#14037) 2026-10-12 14:49:01 +08:00
Yuki Guo c960c0137d refactor: refactor user info (#14036) 2026-10-12 14:17:13 +08:00
Yuki Guo 09a901294e fix: local image upload package (#14031) 2026-10-12 10:08:07 +08:00
Yuki Guo 4d481a8f31 refactor: refactor website ssl delete text (#14024) 2026-10-12 10:07:58 +08:00
Yuki Guo f1e0aa3391 feat(container): support local image uploads and improve import task … (#14025)
* feat(container): support local image uploads and improve import task logs

* feat(container): support local image uploads and improve import task logs

* fix(container): remove duplicate image load response helper
2026-10-12 09:41:36 +08:00
Yuki Guo f475500543 fix(frontend): reset search filters and pagination on page visits (#14016) 2026-10-10 17:56:38 +08:00
Yuki Guo 8415a1ce28 refactor: refactor user info (#14021) 2026-10-10 17:50:05 +08:00
Yuki Guo faf4933a02 refactor: refactor website ssl keyType default value (#14023) 2026-10-10 17:49:14 +08:00
Yuki Guo c692a9f5e3 refactor: refactor website upload css (#14022)
* refactor: refactor website upload css

* refactor: refactor website upload css
2026-10-10 17:48:57 +08:00
CityFun e7f0c21715 fix(vllm): resolve stuck startup state blocking operations (#14017) 2026-10-10 11:13:17 +08:00
ssongliu da7e8f1dbf fix: Remove duplicate TaskScopeVm (#14020) 2026-10-10 10:59:22 +08:00
Yuki Guoandssongliu 486cd5d0ad feat(redis): enable remote terminal through tracked background tasks (#14012)
* feat(redis): enable remote terminal through tracked background tasks

* feat(redis): enable remote terminal through tracked background tasks

* refactor(redis): remove redundant CLI status tracking

* refactor(redis): retain existing task execution flow

---------

Co-authored-by: ssongliu <sloooop1x@gmail.com>
2026-10-09 18:20:51 +08:00
ssongliu 00ce5a7b36 fix(firewall): preserve IPv6 RA protection and simplify error messages (#14015) 2026-10-09 17:45:57 +08:00
ssongliu c17f055e06 fix: preserve VM task timeout cleanup and terminate command groups (#14008) 2026-10-09 17:45:41 +08:00
王贺 f22df4b29c fix(file): stabilize closable tab width to avoid layout jitter (#14013)
The closable card tabs animated the close icon width between active and inactive tabs, so each tab switch reflowed the tab bar and caused the page to jitter. Reserve the close icon slot and toggle it with opacity instead, matching the terminal tab implementation.
2026-10-09 17:16:10 +08:00
Yuki Guoandssongliu b9bab90a14 fix: fix clean network (#14009)
* fix: fix clean network

* refactor(container): simplify network cleanup

---------

Co-authored-by: ssongliu <sloooop1x@gmail.com>
2026-10-09 17:14:50 +08:00
ssongliu ce14a104cc feat(vm): move shared VM support to agent (#14007) 2026-10-09 14:09:53 +08:00
CN_CoreStebandssongliu 2eaabccf94 fix(dashboard): prevent overlapping df collection and share disk queries (#13971)
* fix(dashboard): keep disk info working when a mount is stale or broken

The dashboard, the file manager mount list and the alert disk list
enumerated mounts with df. df calls statfs on every mount, so one bad
mount took the whole disk list down: the request hung until the
frontend timed out, or the list came back empty.

- enumerate mounts from /proc/self/mountinfo instead of running df
- read each mount through a guard with a 3s timeout that keeps at most
  one call per mount in flight
- fail closed: a mount that cannot be read stays in the list with
  errorType and error, and an unreadable mount table is an error
  rather than an empty list
- show failed mounts and their reasons on the dashboard and in the
  file manager
- cmd: after killing a timed-out command, wait at most 5s for it
  instead of forever

* fix(dashboard): distinguish remounted and hidden filesystems

* fix(disk): simplify disk queries and bound timeout handling

* fix(disk): prevent overlapping df collection tasks

---------

Co-authored-by: ssongliu <sloooop1x@gmail.com>
2026-10-09 10:14:16 +08:00
ssongliu e119239d3d refactor: manage firewall rules through runtime state and file backups (#13998)
Replace database rule synchronization with native rule operations and file-based backup, import, and recovery. Update firewall management views, descriptions, whitelist handling, and translations.

Include terminal reconnect fixes and xpack build tags for Darwin build targets. Remove temporary regression test files before committing.
2026-10-08 18:19:30 +08:00
ssongliu 14a57af4e9 feat(vm): clarify network modes and add macvtap translations (#13996) 2026-10-08 18:18:17 +08:00
Yuki Guoandssongliu 2701af9054 refactor: select bind (#13993)
* refactor: select bind

* refactor: simplify table selection state handling

---------

Co-authored-by: ssongliu <sloooop1x@gmail.com>
2026-10-08 17:11:28 +08:00
Yuki Guoandssongliu 4954067736 refactor: search bind (#13992)
* refactor: search bind

* refactor: simplify search persistence

---------

Co-authored-by: ssongliu <sloooop1x@gmail.com>
2026-10-08 16:19:29 +08:00
ssongliu b962144e7d fix(ssh): validate imported key pairs and cancel stale file reads (#13991) 2026-10-08 14:06:40 +08:00
Yuki Guo 8f1be42b6b fix: fix log text (#13990) 2026-10-08 11:32:09 +08:00
Yuki Guo c6969f231b fix: fix operation log (#13989) 2026-10-08 11:08:04 +08:00
4kerccand4kercc 3571fb8d86 fix: speed up SFTP backup upload with concurrent writes (#13976)
The SFTP uploader used io.Copy(dstFile, srcFile). Since the source is an
*os.File it implements io.WriterTo, so io.Copy took the os.File.WriteTo
branch and issued one synchronous SFTP WRITE per packet, waiting for the
server reply every time. Throughput was therefore bounded by packetSize/RTT
instead of the link capacity: on a ~68ms RTT link a 440MB backup reached
only ~0.25 MB/s, while rclone / OpenSSH sftp reach 16-19 MB/s on the very
same link and target.

pkg/sftp already provides a pipelined write path (File.ReadFrom, enabled by
UseConcurrentWrites and bounded by MaxConcurrentRequestsPerFile, default 64).
Use it for uploads, and truncate the remote file to the number of bytes
actually written when the concurrent write fails, to avoid leaving holes.

Measured against the same 68ms RTT target: a 96MiB upload went from 6m40s
(~0.25 MB/s) to ~6s (~16 MB/s); a 950MB three-site backup job from ~2h40m to
~2m04s.

Co-authored-by: 4kercc <13767585+4kercc@users.noreply.github.com>
2026-10-08 09:45:51 +08:00
ssongliu d21288788e fix: harden WebSocket origin validation (#13953) 2026-09-30 15:21:21 +08:00
ssongliu f65e4b06ab feat(vm): add VNC keyboard control translations (#13951)
* feat(vm): add VNC keyboard control translations

* feat(vm): add Windows VirtIO driver guidance translations

* feat(vm): add disk expansion translations and operation logs
2026-09-30 15:20:19 +08:00
ssongliu d26bc9a3a4 chore(deps): update dependencies and address security advisories (#13945)
Update pending Go and frontend dependencies, including gRPC, OpenTelemetry
and SVGO security fixes. Raise the minimum Go version to 1.26.6 and update
x/image and x/mod to patched versions.

Use moby/go-archive directly for image build contexts so its vulnerable
v0.1.0 replacement can be removed and v0.3.0 used. Regenerate Go module
checksums with go mod tidy and refresh the npm lockfile.

Validation: Linux amd64 builds, existing Go tests and go mod verify pass
for core and agent; frontend production build passes; npm audit reports
zero vulnerabilities. Frontend type-check reports the same nine errors
with the original and updated lockfiles.

Go scanning still flags two advisories in the legacy Docker SDK, which
has no patched release on its current module path. A separate SDK
migration and host Docker daemon updates remain necessary.
2026-09-29 21:36:19 +08:00
ssongliu 7088a903fe fix(vllm): sample realtime metrics independently of history (#13944) 2026-09-29 17:29:29 +08:00
ssongliu 864ebeed82 fix: load domestic release notes from new docs (#13943) 2026-09-29 17:29:16 +08:00
CN_CoreStebandssongliu c1b2b92708 feat(monitor): add monitor data export with csv, json and xlsx formats (#13875)
- new export tab between monitor and settings with time range, metric,
  device/interface/gpu selection and format choice
- files are generated in the browser from existing monitor apis; no new
  backend endpoints
- csv with utf-8 bom, escaping and formula sanitization; multiple metrics
  are zipped; xlsx uses one worksheet per metric with real date cells and
  row-limit warnings; json emits flat row objects
- share downloadBlob in utils/file with delayed url revocation
- use the 60s monitor timeout and node header for gpu queries

Co-authored-by: ssongliu <sloooop1x@gmail.com>
2026-09-29 15:50:00 +08:00
CN_CoreStebandssongliu 2189d9229a perf(monitor): improve monitor query and chart performance for large ranges (#13874)
* perf(monitor): improve monitor query and chart performance for large ranges

- enable LTTB sampling in the shared line chart to bound rendered points
- empty io/network in monitor search now means no name filter, returning
  all device rows in one query instead of fanning out per device
- merge device names recorded in the monitor db into io/network/gpu
  options so removed devices stay selectable; move logic to service layer
- add composite indexes (name, created_at) on monitor_ios/monitor_networks
  and (product_name, created_at) on monitor_gpus
- stop migrating the unused MonitorGPU model into MonitorDB
- fix the memory param which was compared against "mem" and never matched

* chore(monitor): remove monitor test fixtures

---------

Co-authored-by: ssongliu <sloooop1x@gmail.com>
2026-09-29 15:34:16 +08:00
ssongliu d0183f460e feat: add vLLM monitoring (#13942) 2026-09-29 15:19:30 +08:00
ssongliu d27d6db1ea feat: enhance native GPU NPU and XPU monitoring (#13939) 2026-09-28 18:32:12 +08:00
蘭 d2bb3813d9 fix: enhance SSH login alert by adding local IP addresses to success login whitelist (#13920) 2026-09-24 18:23:59 +08:00
蘭 cac73e6d45 fix: add success alert message for cron jobs and enhance alert handling (#13919) 2026-09-24 18:23:39 +08:00
ssongliu 4712ceaf2c fix: allow read-only firewall APIs in demo mode (#13914) 2026-09-24 14:01:05 +08:00
ssongliu 65243c68c4 fix: allow licensed community restore progress route (#13913) 2026-09-24 10:18:01 +08:00
ssongliu 256e79ca81 fix: remove minimum upgrade backup copies limit (#13907) 2026-09-23 18:17:32 +08:00
ssongliu 4861eb69cb fix: separate firewalld batches by native option (#13896) 2026-09-22 18:20:18 +08:00
蘭 fb8cf15537 fix: Fix the automatic renewal certificate alert issue (#13895) 2026-09-22 15:06:48 +08:00
ssongliu c4a6791271 fix: allow updated SSH ports through firewall before restart (#13894) 2026-09-22 14:51:54 +08:00
蘭 f58e147636 fix: enhance file rename functionality (#13891) 2026-09-22 12:49:05 +08:00
ssongliu 387e9fbeed fix: correct firewall port allowances and whitelist sync reporting (#13892) 2026-09-22 12:48:40 +08:00
ssongliu 4eb627bc79 fix(vm): display additional virtual machine states (#13890) 2026-09-22 10:27:49 +08:00
ssongliu 850c86229c fix: log dashboard disk command failures (#13889) 2026-09-22 10:16:54 +08:00
CityFun f984917a66 fix(upgrade): resolve application upgrade failures (#13888) 2026-09-22 10:11:13 +08:00
A_Words 8588217fbf fix: prioritize HTTP-to-HTTPS redirects over website redirects (#13847) 2026-09-22 10:05:53 +08:00
ssongliu a2307c5f64 fix(firewall): improve batch operations and task log completion (#13886) 2026-09-22 09:46:46 +08:00
蘭 5923290de8 ref: add file task messages for copy, move, compress and decompress (#13885) 2026-09-21 17:32:14 +08:00
蘭 1c994fba4a ref: update documentation URL for the panel (#13884) 2026-09-21 17:28:23 +08:00
蘭 415ab96aab ref:improve the panel API key (#13883) 2026-09-21 17:18:15 +08:00
ssongliu 19bb823b05 fix(firewall): guard IPv6 forwarding against RA disruption (#13882) 2026-09-21 17:12:17 +08:00
ssongliu 3a5371652e refactor(firewall): simplify state reads and batch verification (#13881) 2026-09-21 15:09:13 +08:00
蘭 a267b4148a ref:Optimize and improve the panel API key (#13880) 2026-09-21 12:49:45 +08:00
蘭 36a01eb60d feat: The panel API key supports creating multiple tokens and enhance terminal capabilities (#13872) 2026-09-20 18:32:54 +08:00
王贺 65f6fdd045 fix(ssh): display SSH login logs in server timezone (#13869)
The SSH log page formatted dates with the browser timezone, so logs looked shifted when the client timezone differed from the server. Format the timestamp with the offset returned by the API so the page matches auth.log, the terminal and the CSV export. Refs #13860.
2026-09-20 18:02:46 +08:00
ssongliu 75b60b32e4 feat(vm): update multi-CD-ROM guidance translations (#13857) 2026-09-18 16:38:31 +08:00
671 changed files with 60265 additions and 24345 deletions
+20
View File
@@ -826,6 +826,26 @@ func (b *BaseApi) UpdateAgentFeishuConfig(c *gin.Context) {
helper.Success(c) helper.Success(c)
} }
// @Tags AI
// @Summary Switch to the bundled OpenClaw Feishu plugin
// @Accept json
// @Param request body dto.AgentFeishuSwitchReq true "request"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /ai/agents/channel/feishu/switch [post]
func (b *BaseApi) SwitchAgentFeishuPlugin(c *gin.Context) {
var req dto.AgentFeishuSwitchReq
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if err := agentService.SwitchFeishuPlugin(req); err != nil {
helper.BadRequest(c, err)
return
}
helper.Success(c)
}
// @Tags AI // @Tags AI
// @Summary Get Agent Telegram channel config // @Summary Get Agent Telegram channel config
// @Accept json // @Accept json
+12 -16
View File
@@ -72,22 +72,6 @@ func (b *BaseApi) CloseOllamaModel(c *gin.Context) {
helper.SuccessWithData(c, nil) helper.SuccessWithData(c, nil)
} }
// @Tags AI
// @Summary Sync Ollama model list
// @Success 200 {array} dto.OllamaModelDropList
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /ai/ollama/model/sync [post]
// @x-panel-log {"bodyKeys":[],"paramKeys":[],"BeforeFunctions":[],"formatZH":"同步 Ollama 模型列表","formatEN":"sync Ollama model list"}
func (b *BaseApi) SyncOllamaModel(c *gin.Context) {
list, err := aiToolService.Sync()
if err != nil {
helper.BadRequest(c, err)
return
}
helper.SuccessWithData(c, list)
}
// @Tags AI // @Tags AI
// @Summary Page Ollama models // @Summary Page Ollama models
// @Accept json // @Accept json
@@ -114,6 +98,18 @@ func (b *BaseApi) SearchOllamaModel(c *gin.Context) {
}) })
} }
func (b *BaseApi) SyncOllamaModelAccount(c *gin.Context) {
var req dto.OllamaModelAccountSync
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if err := aiToolService.SyncAccount(req); err != nil {
helper.BadRequest(c, err)
return
}
helper.Success(c)
}
// @Tags AI // @Tags AI
// @Summary Page Ollama models // @Summary Page Ollama models
// @Accept json // @Accept json
+16
View File
@@ -0,0 +1,16 @@
package v2
import (
"github.com/1Panel-dev/1Panel/agent/app/api/v2/helper"
"github.com/1Panel-dev/1Panel/agent/app/service"
"github.com/gin-gonic/gin"
)
func (b *BaseApi) AIBenchmarkSources(c *gin.Context) {
sources, err := service.AIBenchmarkSources(c.Query("kind"), c.Query("selected"))
if err != nil {
helper.BadRequest(c, err)
return
}
helper.SuccessWithData(c, sources)
}
+28 -8
View File
@@ -10,6 +10,9 @@ import (
"github.com/1Panel-dev/1Panel/agent/app/dto" "github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/app/repo" "github.com/1Panel-dev/1Panel/agent/app/repo"
"github.com/1Panel-dev/1Panel/agent/constant" "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/webhook_sender"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
@@ -290,7 +293,7 @@ func (b *BaseApi) PageAlertConfig(c *gin.Context) {
// @Security ApiKeyAuth // @Security ApiKeyAuth
// @Security Timestamp // @Security Timestamp
// @Router /alert/config/update [post] // @Router /alert/config/update [post]
// @x-panel-log {"bodyKeys":["id","displayName"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"更新告警配置 [id][displayName]","formatEN":"update alert config [id][displayName]"} // @x-panel-log {"bodyKeys":["displayName"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"更新告警配置 [displayName]","formatEN":"update alert config [displayName]"}
func (b *BaseApi) UpdateAlertConfig(c *gin.Context) { func (b *BaseApi) UpdateAlertConfig(c *gin.Context) {
var req dto.AlertConfigUpdate var req dto.AlertConfigUpdate
if err := helper.CheckBindAndValidate(&req, c); err != nil { if err := helper.CheckBindAndValidate(&req, c); err != nil {
@@ -318,13 +321,15 @@ func (b *BaseApi) UpdateAlertConfig(c *gin.Context) {
// @Security ApiKeyAuth // @Security ApiKeyAuth
// @Security Timestamp // @Security Timestamp
// @Router /alert/config/status [post] // @Router /alert/config/status [post]
// @x-panel-log {"bodyKeys":["id","status"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"更新告警配置状态 [id][status]","formatEN":"update alert config status [id][status]"} // @x-panel-log {"bodyKeys":["status"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"更新告警配置状态 [name][status]","formatEN":"update alert config status [name][status]"}
func (b *BaseApi) UpdateAlertConfigStatus(c *gin.Context) { func (b *BaseApi) UpdateAlertConfigStatus(c *gin.Context) {
var req dto.AlertConfigStatusUpdate var req dto.AlertConfigStatusUpdate
if err := helper.CheckBindAndValidate(&req, c); err != nil { if err := helper.CheckBindAndValidate(&req, c); err != nil {
return return
} }
if err := alertService.UpdateAlertConfigStatus(req, loadAuditUser(c)); err != nil { name, err := alertService.UpdateAlertConfigStatus(req, loadAuditUser(c))
helper.SetOperationLogValues(c, map[string]string{"name": name})
if err != nil {
helper.InternalServer(c, err) helper.InternalServer(c, err)
return return
} }
@@ -350,13 +355,14 @@ func loadAuditUser(c *gin.Context) string {
// @Security ApiKeyAuth // @Security ApiKeyAuth
// @Security Timestamp // @Security Timestamp
// @Router /alert/config/del [post] // @Router /alert/config/del [post]
// @x-panel-log {"bodyKeys":["id"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"删除告警配置 [id]","formatEN":"delete alert config [id]"} // @x-panel-log {"bodyKeys":[],"paramKeys":[],"BeforeFunctions":[],"formatZH":"删除告警配置 [name]","formatEN":"delete alert config [name]"}
func (b *BaseApi) DeleteAlertConfig(c *gin.Context) { func (b *BaseApi) DeleteAlertConfig(c *gin.Context) {
var req dto.DeleteRequest var req dto.DeleteRequest
if err := helper.CheckBindAndValidate(&req, c); err != nil { if err := helper.CheckBindAndValidate(&req, c); err != nil {
return return
} }
err := alertService.DeleteAlertConfig(req.ID) name, err := alertService.DeleteAlertConfig(req.ID)
helper.SetOperationLogValues(c, map[string]string{"name": name})
if err != nil { if err != nil {
helper.InternalServer(c, err) helper.InternalServer(c, err)
return return
@@ -378,10 +384,24 @@ func (b *BaseApi) TestAlertConfig(c *gin.Context) {
return return
} }
if req.Type == constant.Custom { if req.Type == constant.Custom {
result, err := alertService.TestCustomAlertConfig(req) lang := strings.TrimSpace(c.GetHeader("Accept-Language"))
if lang == "" {
lang = i18n.GetLanguageFromDB()
}
result, err := alertService.TestCustomAlertConfig(req, lang)
if err != nil { if err != nil {
helper.InternalServer(c, err) if global.LOG != nil {
return global.LOG.Warnf("webhook test preparation failed: configID=%d error=%q", req.ID,
webhook_sender.DiagnosticText(err.Error(), webhook_sender.Request{
Format: webhook_sender.BodyJSON, Body: []byte(req.Config),
}))
}
result = dto.AlertConfigTestResult{Success: false}
}
result.Response = ""
result.Message = ""
if !result.Success {
result.Message = i18n.GetMsgByKeyAndLang(lang, "ErrWebhookTestFailed")
} }
helper.SuccessWithData(c, result) helper.SuccessWithData(c, result)
return return
+9 -3
View File
@@ -28,7 +28,7 @@ func (b *BaseApi) SearchContainer(c *gin.Context) {
return return
} }
total, list, err := containerService.Page(req) total, list, err := containerService.Page(c.Request.Context(), req)
if err != nil { if err != nil {
helper.InternalServer(c, err) helper.InternalServer(c, err)
return return
@@ -249,9 +249,10 @@ func (b *BaseApi) ListContainerByImage(c *gin.Context) {
// @Success 200 {object} dto.ContainerStatus // @Success 200 {object} dto.ContainerStatus
// @Security ApiKeyAuth // @Security ApiKeyAuth
// @Security Timestamp // @Security Timestamp
// @Param containersOnly query boolean false "Only count containers"
// @Router /containers/status [get] // @Router /containers/status [get]
func (b *BaseApi) LoadContainerStatus(c *gin.Context) { func (b *BaseApi) LoadContainerStatus(c *gin.Context) {
data, err := containerService.LoadStatus() data, err := containerService.LoadStatus(c.Request.Context(), c.Query("containersOnly") == "true")
if err != nil { if err != nil {
helper.InternalServer(c, err) helper.InternalServer(c, err)
return return
@@ -415,9 +416,14 @@ func (b *BaseApi) LoadResourceLimit(c *gin.Context) {
// @Success 200 {array} dto.ContainerListStats // @Success 200 {array} dto.ContainerListStats
// @Security ApiKeyAuth // @Security ApiKeyAuth
// @Security Timestamp // @Security Timestamp
// @Param ids query string false "Comma-separated container IDs; omitted selects all containers"
// @Router /containers/list/stats [get] // @Router /containers/list/stats [get]
func (b *BaseApi) ContainerListStats(c *gin.Context) { func (b *BaseApi) ContainerListStats(c *gin.Context) {
data, err := containerService.ContainerListStats() var ids []string
if _, supplied := c.Request.URL.Query()["ids"]; supplied {
ids = strings.FieldsFunc(c.Query("ids"), func(r rune) bool { return r == ',' })
}
data, err := containerService.ContainerListStats(c.Request.Context(), ids)
if err != nil { if err != nil {
helper.InternalServer(c, err) helper.InternalServer(c, err)
return return
-1
View File
@@ -32,7 +32,6 @@ func (b *BaseApi) CreateMysql(c *gin.Context) {
} }
req.Password = string(password) req.Password = string(password)
} }
if _, err := mysqlService.Create(context.Background(), req); err != nil { if _, err := mysqlService.Create(context.Background(), req); err != nil {
helper.InternalServer(c, err) helper.InternalServer(c, err)
return return
+10 -4
View File
@@ -86,17 +86,23 @@ func (b *BaseApi) CheckHasCli(c *gin.Context) {
// @Tags Database Redis // @Tags Database Redis
// @Summary Install redis-cli // @Summary Install redis-cli
// @Success 200 // @Accept json
// @Param request body dto.RedisCliInstall true "request"
// @Success 200 {string} string
// @Security ApiKeyAuth // @Security ApiKeyAuth
// @Security Timestamp // @Security Timestamp
// @Router /databases/redis/install/cli [post] // @Router /databases/redis/install/cli [post]
func (b *BaseApi) InstallCli(c *gin.Context) { func (b *BaseApi) InstallCli(c *gin.Context) {
if err := redisService.InstallCli(); err != nil { var req dto.RedisCliInstall
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
data, err := redisService.InstallCli(req)
if err != nil {
helper.InternalServer(c, err) helper.InternalServer(c, err)
return return
} }
helper.SuccessWithData(c, data)
helper.Success(c)
} }
// @Tags Database Redis // @Tags Database Redis
+109 -158
View File
@@ -2,14 +2,14 @@ package v2
import ( import (
"errors" "errors"
"github.com/1Panel-dev/1Panel/agent/buserr"
"net/http" "net/http"
"strings"
"github.com/1Panel-dev/1Panel/agent/app/api/v2/helper" "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"
"github.com/1Panel-dev/1Panel/agent/app/repo"
"github.com/1Panel-dev/1Panel/agent/app/service"
"github.com/1Panel-dev/1Panel/agent/global" "github.com/1Panel-dev/1Panel/agent/global"
"github.com/1Panel-dev/1Panel/agent/utils/docker"
"github.com/1Panel-dev/1Panel/agent/utils/firewall/filter" "github.com/1Panel-dev/1Panel/agent/utils/firewall/filter"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
@@ -88,7 +88,7 @@ func (b *BaseApi) OperateFirewall(c *gin.Context) {
// @Security Timestamp // @Security Timestamp
// @Router /hosts/firewall/forward/base [post] // @Router /hosts/firewall/forward/base [post]
func (b *BaseApi) LoadForwardingBaseInfo(c *gin.Context) { func (b *BaseApi) LoadForwardingBaseInfo(c *gin.Context) {
data, err := forwardingService.LoadBaseInfo() data, err := forwardingService.LoadBaseInfo(c.Request.Context())
if err != nil { if err != nil {
helper.InternalServer(c, err) helper.InternalServer(c, err)
return return
@@ -109,7 +109,7 @@ func (b *BaseApi) SearchForwardingRules(c *gin.Context) {
if err := helper.CheckBindAndValidate(&request, c); err != nil { if err := helper.CheckBindAndValidate(&request, c); err != nil {
return return
} }
total, items, err := forwardingService.SearchRules(request) total, items, err := forwardingService.SearchRules(c.Request.Context(), request)
if err != nil { if err != nil {
helper.InternalServer(c, err) helper.InternalServer(c, err)
return return
@@ -260,28 +260,6 @@ func (b *BaseApi) LoadFirewallNativeDetail(c *gin.Context) {
helper.SuccessWithData(c, info) helper.SuccessWithData(c, info)
} }
// @Tags Firewall
// @Summary Adopt an external firewall rule
// @Accept json
// @Param request body dto.FirewallRuleAdopt true "request"
// @Success 200
// @Failure 400 {object} dto.Response
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /hosts/firewall/rules/adopt [post]
// @x-panel-log {"bodyKeys":[],"paramKeys":[],"BeforeFunctions":[],"formatZH":"纳管防火墙规则","formatEN":"adopt firewall rule"}
func (b *BaseApi) AdoptFirewallRule(c *gin.Context) {
var request dto.FirewallRuleAdopt
if err := helper.CheckBindAndValidate(&request, c); err != nil {
return
}
if err := firewallService.Adopt(c.Request.Context(), request); err != nil {
handleFirewallRuleError(c, err)
return
}
helper.Success(c)
}
// @Tags Firewall // @Tags Firewall
// @Summary Queue firewall rule creation // @Summary Queue firewall rule creation
// @Description Creation and import return a taskID immediately; validation and execution results are written to the task log. // @Description Creation and import return a taskID immediately; validation and execution results are written to the task log.
@@ -307,69 +285,9 @@ func (b *BaseApi) CreateFirewallRules(c *gin.Context) {
helper.SuccessWithData(c, result) helper.SuccessWithData(c, result)
} }
// @Tags Firewall
// @Summary Preview firewall rule synchronization
// @Accept json
// @Param request body dto.FirewallRuleSyncRequest true "request"
// @Success 200 {object} dto.FirewallRuleSyncPreview
// @Failure 400 {object} dto.Response
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /hosts/firewall/rules/sync/preview [post]
func (b *BaseApi) PreviewFirewallRuleSync(c *gin.Context) {
var request dto.FirewallRuleSyncRequest
if err := helper.CheckBindAndValidate(&request, c); err != nil {
return
}
result, err := firewallService.PreviewRuleSync(c.Request.Context(), c.ClientIP(), request)
if err != nil {
handleFirewallRuleError(c, err)
return
}
helper.SuccessWithData(c, result)
}
// @Tags Firewall
// @Summary Load the currently executing firewall rule synchronization task
// @Success 200 {object} dto.FirewallRuleSyncTask
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /hosts/firewall/rules/sync/task [get]
func (b *BaseApi) LoadFirewallRuleSyncTask(c *gin.Context) {
result, err := firewallService.CurrentRuleSyncTask()
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, result)
}
// @Tags Firewall
// @Summary Synchronize firewall rules to a target backend
// @Accept json
// @Param request body dto.FirewallRuleSyncRequest true "request"
// @Success 200 {object} dto.FirewallRuleSyncResult
// @Failure 400 {object} dto.Response
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /hosts/firewall/rules/sync [post]
// @x-panel-log {"bodyKeys":["subsystem","sourceProvider","targetProvider"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"同步 [subsystem] 防火墙规则到 [targetProvider]","formatEN":"sync [subsystem] firewall rules to [targetProvider]"}
func (b *BaseApi) SyncFirewallRules(c *gin.Context) {
var request dto.FirewallRuleSyncRequest
if err := helper.CheckBindAndValidate(&request, c); err != nil {
return
}
result, err := firewallService.SyncRules(c.Request.Context(), c.ClientIP(), request)
if err != nil {
handleFirewallRuleError(c, err)
return
}
helper.SuccessWithData(c, result)
}
// @Tags Firewall // @Tags Firewall
// @Summary Queue firewall rule deletion // @Summary Queue firewall rule deletion
// @Description Deletes managed rules by UUID or unprotected before-chain rules by instance key. Returns a taskID immediately; results are written to the task log. // @Description Deletes non-whitelist rules by scope and instance key. Returns a taskID immediately; results are written to the task log.
// @Accept json // @Accept json
// @Param request body dto.FirewallRuleDelete true "request" // @Param request body dto.FirewallRuleDelete true "request"
// @Success 200 {object} dto.FirewallRuleDeleteResponse // @Success 200 {object} dto.FirewallRuleDeleteResponse
@@ -392,7 +310,7 @@ func (b *BaseApi) DeleteFirewallRules(c *gin.Context) {
} }
// @Tags Firewall // @Tags Firewall
// @Summary Update a managed unified firewall v2 rule // @Summary Update a firewall rule
// @Accept json // @Accept json
// @Param request body dto.FirewallRuleUpdate true "request" // @Param request body dto.FirewallRuleUpdate true "request"
// @Success 200 // @Success 200
@@ -400,16 +318,13 @@ func (b *BaseApi) DeleteFirewallRules(c *gin.Context) {
// @Security ApiKeyAuth // @Security ApiKeyAuth
// @Security Timestamp // @Security Timestamp
// @Router /hosts/firewall/rules/update [post] // @Router /hosts/firewall/rules/update [post]
// @x-panel-log {"bodyKeys":["uuid"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"更新防火墙规则 [uuid]","formatEN":"update firewall rule [uuid]"} // @x-panel-log {"bodyKeys":[],"paramKeys":[],"BeforeFunctions":[],"formatZH":"更新防火墙规则","formatEN":"update firewall rule"}
func (b *BaseApi) UpdateFirewallRule(c *gin.Context) { func (b *BaseApi) UpdateFirewallRule(c *gin.Context) {
var request dto.FirewallRuleUpdate var request dto.FirewallRuleUpdate
if err := helper.CheckBindAndValidate(&request, c); err != nil { if err := helper.CheckBindAndValidate(&request, c); err != nil {
return return
} }
if !normalizeFirewallRuleUUID(c, &request.UUID) { if err := firewallService.Update(c.Request.Context(), request); err != nil {
return
}
if err := firewallService.Update(c.Request.Context(), c.ClientIP(), request); err != nil {
handleFirewallRuleError(c, err) handleFirewallRuleError(c, err)
return return
} }
@@ -417,7 +332,7 @@ func (b *BaseApi) UpdateFirewallRule(c *gin.Context) {
} }
// @Tags Firewall // @Tags Firewall
// @Summary Reorder a managed unified firewall v2 rule // @Summary Reorder a firewall rule
// @Accept json // @Accept json
// @Param request body dto.FirewallRuleReorder true "request" // @Param request body dto.FirewallRuleReorder true "request"
// @Success 200 // @Success 200
@@ -425,53 +340,41 @@ func (b *BaseApi) UpdateFirewallRule(c *gin.Context) {
// @Security ApiKeyAuth // @Security ApiKeyAuth
// @Security Timestamp // @Security Timestamp
// @Router /hosts/firewall/rules/reorder [post] // @Router /hosts/firewall/rules/reorder [post]
// @x-panel-log {"bodyKeys":["uuid"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"调整防火墙规则顺序 [uuid]","formatEN":"reorder firewall rule [uuid]"} // @x-panel-log {"bodyKeys":[],"paramKeys":[],"BeforeFunctions":[],"formatZH":"调整防火墙规则顺序","formatEN":"reorder firewall rule"}
func (b *BaseApi) ReorderFirewallRule(c *gin.Context) { func (b *BaseApi) ReorderFirewallRule(c *gin.Context) {
var request dto.FirewallRuleReorder var request dto.FirewallRuleReorder
if err := helper.CheckBindAndValidate(&request, c); err != nil { if err := helper.CheckBindAndValidate(&request, c); err != nil {
return return
} }
if !normalizeFirewallRuleUUID(c, &request.UUID) { if err := firewallService.Reorder(c.Request.Context(), request); err != nil {
return
}
if err := firewallService.Reorder(c.Request.Context(), c.ClientIP(), request); err != nil {
handleFirewallRuleError(c, err) handleFirewallRuleError(c, err)
return return
} }
helper.Success(c) helper.Success(c)
} }
func normalizeFirewallRuleUUID(c *gin.Context, value *string) bool {
if value == nil {
helper.BadRequest(c, repo.ErrFirewallPersistenceInvalid)
return false
}
*value = strings.TrimSpace(*value)
if *value == "" {
helper.BadRequest(c, repo.ErrFirewallPersistenceInvalid)
return false
}
return true
}
func handleFirewallRuleError(c *gin.Context, err error) { func handleFirewallRuleError(c *gin.Context, err error) {
var businessErr buserr.BusinessError
isBusinessError := errors.As(err, &businessErr)
switch { switch {
case errors.Is(err, filter.ErrProtectedRule): case errors.Is(err, filter.ErrProtectedRule):
helper.ErrorWithBusinessCode(c, http.StatusBadRequest, "FW_LOCKOUT_RISK", "ErrInvalidParams", err) helper.ErrorWithBusinessCode(c, http.StatusBadRequest, "FW_LOCKOUT_RISK", "ErrInvalidParams", err)
case errors.Is(err, filter.ErrRuleStale): case errors.Is(err, filter.ErrRuleStale):
helper.ErrorWithBusinessCode(c, http.StatusConflict, "FW_RULE_STALE", "ErrInvalidParams", err) helper.ErrorWithBusinessCode(c, http.StatusConflict, "FW_RULE_STALE", "ErrInvalidParams", err)
case errors.Is(err, repo.ErrFirewallRuleRevisionConflict):
helper.ErrorWithBusinessCode(c, http.StatusConflict, "FW_RULE_REVISION_CONFLICT", "ErrInvalidParams", err)
case errors.Is(err, filter.ErrManagedScopeChange):
helper.ErrorWithBusinessCode(c, http.StatusBadRequest, "FW_SCOPE_UNSUPPORTED", "ErrFirewallRuleScopeChange", err)
case errors.Is(err, filter.ErrUnsupportedScope), errors.Is(err, filter.ErrInvalidScope), case errors.Is(err, filter.ErrUnsupportedScope), errors.Is(err, filter.ErrInvalidScope),
errors.Is(err, filter.ErrProviderUnavailable), errors.Is(err, filter.ErrAdapterUnavailable): errors.Is(err, filter.ErrProviderUnavailable), errors.Is(err, filter.ErrAdapterUnavailable):
helper.ErrorWithBusinessCode(c, http.StatusBadRequest, "FW_SCOPE_UNSUPPORTED", "ErrInvalidParams", err) helper.ErrorWithBusinessCode(c, http.StatusBadRequest, "FW_SCOPE_UNSUPPORTED", "ErrInvalidParams", err)
case errors.Is(err, filter.ErrInvalidRule), errors.Is(err, filter.ErrRuleOperation), errors.Is(err, filter.ErrRuleConflict), case errors.Is(err, filter.ErrInvalidRule), errors.Is(err, filter.ErrRuleOperation):
errors.Is(err, repo.ErrFirewallPersistenceInvalid):
helper.ErrorWithBusinessCode(c, http.StatusBadRequest, "FW_RULE_UNSUPPORTED", "ErrInvalidParams", err) helper.ErrorWithBusinessCode(c, http.StatusBadRequest, "FW_RULE_UNSUPPORTED", "ErrInvalidParams", err)
case errors.Is(err, filter.ErrVerificationFailed): case isBusinessError && businessErr.Msg == "ErrRecordExist":
helper.ErrorWithBusinessCode(c, http.StatusInternalServerError, "FW_VERIFY_FAILED", "ErrInternalServer", err) c.JSON(http.StatusOK, dto.Response{Code: http.StatusConflict, ErrorCode: "FW_RULE_DUPLICATE", Message: err.Error()})
c.Abort()
case isBusinessError && businessErr.Msg == "ErrFirewallRuleConflict":
c.JSON(http.StatusOK, dto.Response{Code: http.StatusConflict, ErrorCode: "FW_RULE_CONFLICT", Message: err.Error()})
c.Abort()
case isBusinessError && businessErr.Msg == "ErrInvalidParams":
c.JSON(http.StatusOK, dto.Response{Code: http.StatusBadRequest, ErrorCode: "FW_RULE_UNSUPPORTED", Message: err.Error()})
c.Abort()
default: default:
helper.ErrorWithBusinessCode(c, http.StatusInternalServerError, "FW_APPLY_FAILED", "ErrInternalServer", err) helper.ErrorWithBusinessCode(c, http.StatusInternalServerError, "FW_APPLY_FAILED", "ErrInternalServer", err)
} }
@@ -494,7 +397,7 @@ func (b *BaseApi) LoadFirewallSettings(c *gin.Context) {
// @Tags Firewall // @Tags Firewall
// @Summary Create firewall port whitelist rules // @Summary Create firewall port whitelist rules
// @Description Saves whitelist configuration only. Missing rules are added on startup, restart, initialization, or synchronization; existing rules are not removed. // @Description Saves whitelist configuration and applies missing allowances; existing rules are not removed.
// @Accept json // @Accept json
// @Param request body dto.FirewallPortWhitelistCreate true "request" // @Param request body dto.FirewallPortWhitelistCreate true "request"
// @Success 200 // @Success 200
@@ -516,7 +419,7 @@ func (b *BaseApi) CreateFirewallPortWhitelist(c *gin.Context) {
// @Tags Firewall // @Tags Firewall
// @Summary Update firewall port whitelist rules // @Summary Update firewall port whitelist rules
// @Description Saves whitelist configuration only. Missing rules are added on startup, restart, initialization, or synchronization; existing rules are not removed. // @Description Saves whitelist configuration and applies missing allowances; existing rules are not removed.
// @Accept json // @Accept json
// @Param request body dto.FirewallPortWhitelistUpdate true "request" // @Param request body dto.FirewallPortWhitelistUpdate true "request"
// @Success 200 // @Success 200
@@ -538,7 +441,7 @@ func (b *BaseApi) UpdateFirewallPortWhitelist(c *gin.Context) {
// @Tags Firewall // @Tags Firewall
// @Summary Delete firewall port whitelist rules // @Summary Delete firewall port whitelist rules
// @Description Saves whitelist configuration only. Missing rules are added on startup, restart, initialization, or synchronization; existing rules are not removed. // @Description Removes whitelist configuration; existing firewall rules are not removed.
// @Accept json // @Accept json
// @Param request body dto.FirewallPortWhitelistDelete true "request" // @Param request body dto.FirewallPortWhitelistDelete true "request"
// @Success 200 // @Success 200
@@ -573,14 +476,10 @@ func (b *BaseApi) OperateFirewallBackend(c *gin.Context) {
return return
} }
if err := firewallSettingService.Operate(c.Request.Context(), request); err != nil { if err := firewallSettingService.Operate(c.Request.Context(), request); err != nil {
if errors.Is(err, service.ErrFirewallBackendCleanupRequired) { var businessErr buserr.BusinessError
helper.ErrorWithBusinessCode( if errors.As(err, &businessErr) && businessErr.Msg == "ErrFirewallBackendCleanupRequired" {
c, c.JSON(http.StatusOK, dto.Response{Code: http.StatusConflict, ErrorCode: "FW_BACKEND_CLEANUP_REQUIRED", Message: err.Error()})
http.StatusConflict, c.Abort()
"FW_BACKEND_CLEANUP_REQUIRED",
"ErrInvalidParams",
err,
)
return return
} }
helper.InternalServer(c, err) helper.InternalServer(c, err)
@@ -619,21 +518,6 @@ func (b *BaseApi) ListDockerPublishedPorts(c *gin.Context) {
helper.SuccessWithData(c, data) helper.SuccessWithData(c, data)
} }
// @Tags Firewall
// @Summary Sync Docker port guard rules
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /hosts/firewall/docker/sync [post]
// @x-panel-log {"bodyKeys":[],"paramKeys":[],"BeforeFunctions":[],"formatZH":"同步 Docker 端口防护规则","formatEN":"sync Docker port guard rules"}
func (b *BaseApi) SyncDockerPortGuard(c *gin.Context) {
if err := dockerPortGuardService.Reconcile(c.Request.Context()); err != nil {
handleDockerPortGuardError(c, err)
return
}
helper.Success(c)
}
// @Tags Firewall // @Tags Firewall
// @Summary Operate Docker port guard // @Summary Operate Docker port guard
// @Accept json // @Accept json
@@ -672,7 +556,7 @@ func (b *BaseApi) OperateDockerPortGuard(c *gin.Context) {
// @Security ApiKeyAuth // @Security ApiKeyAuth
// @Security Timestamp // @Security Timestamp
// @Router /hosts/firewall/docker/policies/delete/batch [post] // @Router /hosts/firewall/docker/policies/delete/batch [post]
// @x-panel-log {"bodyKeys":["uuids"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"删除 Docker 端口防护策略 [uuids]","formatEN":"delete Docker port guard policies [uuids]"} // @x-panel-log {"bodyKeys":[],"paramKeys":[],"BeforeFunctions":[],"formatZH":"删除 Docker 端口防护策略","formatEN":"delete Docker port guard policies"}
func (b *BaseApi) DeleteDockerPortGuardPolicies(c *gin.Context) { func (b *BaseApi) DeleteDockerPortGuardPolicies(c *gin.Context) {
var request dto.DockerPortGuardPolicyBatchDelete var request dto.DockerPortGuardPolicyBatchDelete
if err := helper.CheckBindAndValidate(&request, c); err != nil { if err := helper.CheckBindAndValidate(&request, c); err != nil {
@@ -709,21 +593,88 @@ func (b *BaseApi) UpsertDockerPortGuardPolicies(c *gin.Context) {
} }
func handleDockerPortGuardError(c *gin.Context, err error) { func handleDockerPortGuardError(c *gin.Context, err error) {
if errors.Is(err, service.ErrDockerIptablesChainUnavailable) { var businessErr buserr.BusinessError
helper.ErrorWithBusinessCode(c, http.StatusServiceUnavailable, "FW_DOCKER_IPTABLES_CHAIN_UNAVAILABLE", "ErrDockerIptablesChainUnavailable", err) if errors.As(err, &businessErr) {
code, errorCode := http.StatusInternalServerError, ""
switch businessErr.Msg {
case "ErrDockerIptablesChainUnavailable":
code, errorCode = http.StatusServiceUnavailable, "FW_DOCKER_IPTABLES_CHAIN_UNAVAILABLE"
case "ErrDockerNftablesChainUnavailable":
code, errorCode = http.StatusServiceUnavailable, "FW_DOCKER_NFTABLES_CHAIN_UNAVAILABLE"
case "ErrInvalidParams":
code, errorCode = http.StatusBadRequest, "FW_DOCKER_GUARD_INVALID"
case "ErrDockerFailed":
code, errorCode = http.StatusServiceUnavailable, "FW_DOCKER_UNAVAILABLE"
}
if errorCode != "" {
c.JSON(http.StatusOK, dto.Response{Code: code, ErrorCode: errorCode, Message: err.Error()})
c.Abort()
return return
} }
if errors.Is(err, service.ErrDockerNftablesChainUnavailable) {
helper.ErrorWithBusinessCode(c, http.StatusServiceUnavailable, "FW_DOCKER_NFTABLES_CHAIN_UNAVAILABLE", "ErrDockerNftablesChainUnavailable", err)
return
} }
if errors.Is(err, service.ErrDockerGuardInvalid) { if errors.Is(err, docker.ErrUnavailable) {
helper.ErrorWithBusinessCode(c, http.StatusBadRequest, "FW_DOCKER_GUARD_INVALID", "ErrInvalidParams", err)
return
}
if errors.Is(err, service.ErrDockerUnavailable) {
helper.ErrorWithBusinessCode(c, http.StatusServiceUnavailable, "FW_DOCKER_UNAVAILABLE", "ErrDockerFailed", err) helper.ErrorWithBusinessCode(c, http.StatusServiceUnavailable, "FW_DOCKER_UNAVAILABLE", "ErrDockerFailed", err)
return return
} }
helper.ErrorWithBusinessCode(c, http.StatusInternalServerError, "FW_DOCKER_GUARD_FAILED", "ErrInternalServer", err) helper.ErrorWithBusinessCode(c, http.StatusInternalServerError, "FW_DOCKER_GUARD_FAILED", "ErrInternalServer", err)
} }
// @Tags Firewall
// @Summary List firewall rule backups
// @Param subsystem query string false "Firewall subsystem" Enums(system,forwarding,docker) default(system)
// @Success 200 {object} dto.FirewallRuleBackups
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /hosts/firewall/rules/backups [get]
func (b *BaseApi) ListFirewallRuleBackups(c *gin.Context) {
result, err := firewallService.ListRuleBackups(c.Request.Context(), c.DefaultQuery("subsystem", "system"))
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, result)
}
// @Tags Firewall
// @Summary Initialize, repair or bind one firewall address family
// @Accept json
// @Param request body dto.FirewallFamilyOperation true "request"
// @Success 200 {object} dto.FilterChainOperationResponse
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /hosts/firewall/family/operate [post]
// @x-panel-log {"bodyKeys":["subsystem","family","operation"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"[operation] [subsystem] [family] 防火墙链","formatEN":"[operation] [subsystem] [family] firewall chains"}
func (b *BaseApi) OperateFirewallFamily(c *gin.Context) {
var request dto.FirewallFamilyOperation
if err := helper.CheckBindAndValidate(&request, c); err != nil {
return
}
result, err := firewallSettingService.OperateFamily(request)
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, result)
}
// @Tags Firewall
// @Summary Update firewall IPv6 support
// @Accept json
// @Param request body dto.FirewallIPv6Operation true "request"
// @Success 200 {object} dto.FilterChainOperationResponse
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /hosts/firewall/settings/ipv6 [post]
// @x-panel-log {"bodyKeys":["status"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"设置防火墙 IPv6 支持为 [status]","formatEN":"Set firewall IPv6 support to [status]"}
func (b *BaseApi) OperateFirewallIPv6(c *gin.Context) {
var request dto.FirewallIPv6Operation
if err := helper.CheckBindAndValidate(&request, c); err != nil {
return
}
result, err := firewallSettingService.OperateIPv6(request)
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, result)
}
+25
View File
@@ -3,6 +3,7 @@ package helper
import ( import (
"compress/gzip" "compress/gzip"
"context" "context"
"encoding/base64"
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http" "net/http"
@@ -11,6 +12,7 @@ import (
"sync" "sync"
"github.com/1Panel-dev/1Panel/agent/global" "github.com/1Panel-dev/1Panel/agent/global"
"github.com/1Panel-dev/1Panel/agent/utils/mcpapi"
"gorm.io/gorm" "gorm.io/gorm"
"github.com/1Panel-dev/1Panel/agent/app/dto" "github.com/1Panel-dev/1Panel/agent/app/dto"
@@ -21,6 +23,7 @@ import (
) )
func ErrorWithDetail(ctx *gin.Context, code int, msgKey string, err error) { func ErrorWithDetail(ctx *gin.Context, code int, msgKey string, err error) {
mcpapi.SetBusinessErrorHeader(ctx.Request, ctx.Writer.Header(), err)
res := dto.Response{ res := dto.Response{
Code: code, Code: code,
Message: "", Message: "",
@@ -31,6 +34,7 @@ func ErrorWithDetail(ctx *gin.Context, code int, msgKey string, err error) {
} }
func ErrorWithBusinessCode(ctx *gin.Context, code int, businessCode, msgKey string, err error) { func ErrorWithBusinessCode(ctx *gin.Context, code int, businessCode, msgKey string, err error) {
mcpapi.SetBusinessErrorHeader(ctx.Request, ctx.Writer.Header(), err)
res := dto.Response{ res := dto.Response{
Code: code, Code: code,
ErrorCode: businessCode, ErrorCode: businessCode,
@@ -41,6 +45,7 @@ func ErrorWithBusinessCode(ctx *gin.Context, code int, businessCode, msgKey stri
} }
func ErrorWithDetailAndData(ctx *gin.Context, code int, msgKey string, err error, data interface{}) { func ErrorWithDetailAndData(ctx *gin.Context, code int, msgKey string, err error, data interface{}) {
mcpapi.SetBusinessErrorHeader(ctx.Request, ctx.Writer.Header(), err)
res := dto.Response{ res := dto.Response{
Code: code, Code: code,
Data: data, Data: data,
@@ -184,3 +189,23 @@ func GetParamInt32(paramName string, c *gin.Context) (int32, error) {
intNum, _ := strconv.Atoi(idParam) intNum, _ := strconv.Atoi(idParam)
return int32(intNum), nil return int32(intNum), nil
} }
// SetOperationLogValues supplies server-resolved values to Core's operation log.
// Call before writing the response. Only pass public audit fields, never credentials.
func SetOperationLogValues(c *gin.Context, values map[string]string) {
const header = "X-Op-Resolved"
resolved := make(map[string]string)
if data, err := base64.RawURLEncoding.DecodeString(c.Writer.Header().Get(header)); err == nil {
_ = json.Unmarshal(data, &resolved)
}
if resolved == nil {
resolved = make(map[string]string)
}
for key, value := range values {
resolved[key] = value
}
data, err := json.Marshal(resolved)
if err == nil {
c.Header(header, base64.RawURLEncoding.EncodeToString(data))
}
}
+137
View File
@@ -0,0 +1,137 @@
package v2
import (
"encoding/base64"
"net/http"
"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/app/model"
"github.com/1Panel-dev/1Panel/agent/app/service"
"github.com/1Panel-dev/1Panel/agent/utils/mcpapi"
"github.com/gin-gonic/gin"
)
type mcpBusinessAPI struct {
installApp func(request.AppInstallCreate, bool) (*model.AppInstall, error)
}
func MCPBusinessDispatch() gin.HandlerFunc {
return (&mcpBusinessAPI{installApp: (&service.AppService{}).InstallForMCP}).dispatch()
}
func (api *mcpBusinessAPI) dispatch() gin.HandlerFunc {
return func(c *gin.Context) {
var handler gin.HandlerFunc
switch c.Request.Method + " " + c.FullPath() {
case "POST /api/v2/databases":
handler = api.createMysql
case "POST /api/v2/databases/status":
handler = api.mysqlStatus
case "GET /api/v2/databases/db/list/:type":
handler = api.databases
case "POST /api/v2/apps/install":
handler = api.install
case "GET /api/v2/apps/installed/info/:appInstallId":
handler = api.appInfo
default:
c.Next()
return
}
present, trusted := mcpapi.Check(c.Request)
if !present {
c.Next()
return
}
if !trusted {
c.AbortWithStatus(http.StatusForbidden)
return
}
handler(c)
c.Abort()
}
}
func (api *mcpBusinessAPI) createMysql(c *gin.Context) {
var req dto.MysqlDBCreate
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if len(req.Password) != 0 {
password, err := base64.StdEncoding.DecodeString(req.Password)
if err != nil {
helper.BadRequest(c, err)
return
}
req.Password = string(password)
}
created, err := (&service.MysqlService{}).CreateForMCP(c.Request.Context(), req)
if err != nil {
helper.InternalServer(c, err)
return
}
nodeID, _ := mcpapi.NodeID(c.Request)
helper.SuccessWithData(c, map[string]any{"id": created.ID, "database": created.MysqlName, "name": created.Name, "nodeId": nodeID})
}
func (api *mcpBusinessAPI) mysqlStatus(c *gin.Context) {
var req dto.OperationWithNameAndType
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
data, err := (&service.MysqlService{}).LoadStatusForMCP(c.Request.Context(), req)
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, data)
}
func (api *mcpBusinessAPI) databases(c *gin.Context) {
dbType, err := helper.GetStrParamByKey(c, "type")
if err != nil {
helper.BadRequest(c, err)
return
}
list, err := (&service.DatabaseService{}).ListForMCP(c.Request.Context(), dbType)
if err != nil {
helper.BadRequest(c, err)
return
}
helper.SuccessWithData(c, list)
}
func (api *mcpBusinessAPI) install(c *gin.Context) {
var req request.AppInstallCreate
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if value, exists := req.Params["WEBSITE_DIR"]; exists {
directory, ok := value.(string)
if !ok || directory == "" || directory != settingService.GetWebsiteDir() {
c.AbortWithStatus(http.StatusForbidden)
return
}
}
installed, err := api.installApp(req, true)
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, installed)
}
func (api *mcpBusinessAPI) appInfo(c *gin.Context) {
id, err := helper.GetIntParamByKey(c, "appInstallId")
if err != nil {
helper.BadRequest(c, err)
return
}
info, err := (&service.AppInstallService{}).GetAppInstallInfoForMCP(c.Request.Context(), id)
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, info)
}
+29
View File
@@ -0,0 +1,29 @@
package v2
import (
"net"
"net/http"
"github.com/1Panel-dev/1Panel/agent/constant"
"github.com/1Panel-dev/1Panel/agent/global"
"github.com/1Panel-dev/1Panel/agent/utils/taskscope"
"github.com/gin-gonic/gin"
)
func PanelMCPTaskAccess() gin.HandlerFunc {
return func(c *gin.Context) {
if hasPanelMCPTaskAccess(c.Request) {
c.Request = c.Request.WithContext(taskscope.WithGrants(c.Request.Context(), constant.TaskScopePanelMCP))
}
c.Next()
}
}
func hasPanelMCPTaskAccess(req *http.Request) bool {
if !global.IsMaster {
return false
}
local, ok := req.Context().Value(http.LocalAddrContextKey).(net.Addr)
values := req.Header.Values("X-Panel-MCP-Task-Access")
return ok && local.Network() == "unix" && len(values) == 1 && values[0] == "1"
}
+10 -23
View File
@@ -1,13 +1,9 @@
package v2 package v2
import ( import (
"sort"
"github.com/1Panel-dev/1Panel/agent/app/api/v2/helper" "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"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/shirou/gopsutil/v4/disk"
"github.com/shirou/gopsutil/v4/net"
) )
// @Tags Monitor // @Tags Monitor
@@ -32,14 +28,19 @@ func (b *BaseApi) LoadMonitor(c *gin.Context) {
} }
// @Tags Monitor // @Tags Monitor
// @Summary Clean monitor data // @Summary Clean host or GPU monitor data
// @Param request body dto.MonitorClean true "request"
// @Success 200 // @Success 200
// @Security ApiKeyAuth // @Security ApiKeyAuth
// @Security Timestamp // @Security Timestamp
// @Router /hosts/monitor/clean [post] // @Router /hosts/monitor/clean [post]
// @x-panel-log {"bodyKeys":[],"paramKeys":[],"BeforeFunctions":[],"formatZH":"清空监控数据","formatEN":"clean monitor datas"} // @x-panel-log {"bodyKeys":["type"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"清空 [type] 监控数据","formatEN":"clean [type] monitoring data"}
func (b *BaseApi) CleanMonitor(c *gin.Context) { func (b *BaseApi) CleanMonitor(c *gin.Context) {
if err := monitorService.CleanData(); err != nil { var req dto.MonitorClean
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if err := monitorService.CleanData(req.Type); err != nil {
helper.InternalServer(c, err) helper.InternalServer(c, err)
return return
} }
@@ -91,14 +92,7 @@ func (b *BaseApi) UpdateMonitorSetting(c *gin.Context) {
// @Security Timestamp // @Security Timestamp
// @Router /hosts/monitor/netoptions [get] // @Router /hosts/monitor/netoptions [get]
func (b *BaseApi) GetNetworkOptions(c *gin.Context) { func (b *BaseApi) GetNetworkOptions(c *gin.Context) {
netStat, _ := net.IOCounters(true) helper.SuccessWithData(c, monitorService.LoadNetworkOptions())
var options []string
options = append(options, "all")
for _, net := range netStat {
options = append(options, net.Name)
}
sort.Strings(options)
helper.SuccessWithData(c, options)
} }
// @Tags Monitor // @Tags Monitor
@@ -108,12 +102,5 @@ func (b *BaseApi) GetNetworkOptions(c *gin.Context) {
// @Security Timestamp // @Security Timestamp
// @Router /hosts/monitor/iooptions [get] // @Router /hosts/monitor/iooptions [get]
func (b *BaseApi) GetIOOptions(c *gin.Context) { func (b *BaseApi) GetIOOptions(c *gin.Context) {
diskStat, _ := disk.IOCounters() helper.SuccessWithData(c, monitorService.LoadIOOptions())
var options []string
options = append(options, "all")
for _, net := range diskStat {
options = append(options, net.Name)
}
sort.Strings(options)
helper.SuccessWithData(c, options)
} }
+45
View File
@@ -0,0 +1,45 @@
package v2
import (
"github.com/1Panel-dev/1Panel/agent/app/api/v2/helper"
"github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/gin-gonic/gin"
)
func (b *BaseApi) LoadVLLMMonitor(c *gin.Context) {
var req dto.MonitorVLLMSearch
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
data, err := monitorService.LoadVLLMMonitorData(c.Request.Context(), req)
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, data)
}
func (b *BaseApi) LoadVLLMCurrent(c *gin.Context) {
var req dto.MonitorVLLMCurrent
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
data, err := monitorService.LoadVLLMCurrent(c.Request.Context(), req)
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, data)
}
func (b *BaseApi) CleanVLLMMonitor(c *gin.Context) {
var req dto.MonitorVLLMClean
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if err := monitorService.CleanVLLMMonitor(req); err != nil {
helper.InternalServer(c, err)
return
}
helper.Success(c)
}
+84
View File
@@ -0,0 +1,84 @@
package v2
import (
"github.com/1Panel-dev/1Panel/agent/app/api/v2/helper"
"github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/gin-gonic/gin"
)
// @Tags Dashboard
// @Summary List quick directories
// @Success 200 {array} model.QuickDirectory
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /dashboard/quick/directory [get]
func (b *BaseApi) ListQuickDirectories(c *gin.Context) {
items, err := dashboardService.ListQuickDirectories()
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, items)
}
// @Tags Dashboard
// @Summary Create quick directory
// @Accept json
// @Param request body dto.QuickDirectoryCreate true "request"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /dashboard/quick/directory [post]
func (b *BaseApi) CreateQuickDirectory(c *gin.Context) {
var req dto.QuickDirectoryCreate
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
item, err := dashboardService.CreateQuickDirectory(req)
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, item)
}
// @Tags Dashboard
// @Summary Update quick directory
// @Accept json
// @Param request body dto.QuickDirectoryUpdate true "request"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /dashboard/quick/directory/update [post]
func (b *BaseApi) UpdateQuickDirectory(c *gin.Context) {
var req dto.QuickDirectoryUpdate
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
item, err := dashboardService.UpdateQuickDirectory(req)
if err != nil {
helper.InternalServer(c, err)
return
}
helper.SuccessWithData(c, item)
}
// @Tags Dashboard
// @Summary Delete quick directory
// @Accept json
// @Param request body dto.QuickDirectoryDelete true "request"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /dashboard/quick/directory/del [post]
func (b *BaseApi) DeleteQuickDirectory(c *gin.Context) {
var req dto.QuickDirectoryDelete
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if err := dashboardService.DeleteQuickDirectory(req); err != nil {
helper.InternalServer(c, err)
return
}
helper.Success(c)
}
+4 -3
View File
@@ -61,13 +61,14 @@ func (b *BaseApi) CreateRuntime(c *gin.Context) {
// @Security ApiKeyAuth // @Security ApiKeyAuth
// @Security Timestamp // @Security Timestamp
// @Router /runtimes/del [post] // @Router /runtimes/del [post]
// @x-panel-log {"bodyKeys":["id"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"删除运行环境 [id]","formatEN":"Delete runtime [id]"} // @x-panel-log {"bodyKeys":[],"paramKeys":[],"BeforeFunctions":[],"formatZH":"删除运行环境 [name]","formatEN":"Delete runtime [name]"}
func (b *BaseApi) DeleteRuntime(c *gin.Context) { func (b *BaseApi) DeleteRuntime(c *gin.Context) {
var req request.RuntimeDelete var req request.RuntimeDelete
if err := helper.CheckBindAndValidate(&req, c); err != nil { if err := helper.CheckBindAndValidate(&req, c); err != nil {
return return
} }
err := runtimeService.Delete(req) name, err := runtimeService.Delete(req)
helper.SetOperationLogValues(c, map[string]string{"name": name})
if err != nil { if err != nil {
helper.InternalServer(c, err) helper.InternalServer(c, err)
return return
@@ -169,7 +170,7 @@ func (b *BaseApi) GetNodePackageRunScript(c *gin.Context) {
// @Security ApiKeyAuth // @Security ApiKeyAuth
// @Security Timestamp // @Security Timestamp
// @Router /runtimes/operate [post] // @Router /runtimes/operate [post]
// @x-panel-log {"bodyKeys":["id"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"操作运行环境 [id]","formatEN":"Operate runtime [id]"} // @x-panel-log {"bodyKeys":["ID"],"paramKeys":[],"BeforeFunctions":[{"input_column":"id","input_value":"ID","isList":false,"db":"runtimes","output_column":"name","output_value":"name"}],"formatZH":"操作运行环境 [name]","formatEN":"Operate runtime [name]"}
func (b *BaseApi) OperateRuntime(c *gin.Context) { func (b *BaseApi) OperateRuntime(c *gin.Context) {
var req request.RuntimeOperate var req request.RuntimeOperate
if err := helper.CheckBindAndValidate(&req, c); err != nil { if err := helper.CheckBindAndValidate(&req, c); err != nil {
+2
View File
@@ -83,6 +83,7 @@ func (b *BaseApi) CreateRootCert(c *gin.Context) {
} }
if err := loadCertAfterDecrypt(&req); err != nil { if err := loadCertAfterDecrypt(&req); err != nil {
helper.BadRequest(c, err) helper.BadRequest(c, err)
return
} }
if err := sshService.CreateRootCert(req); err != nil { if err := sshService.CreateRootCert(req); err != nil {
helper.InternalServer(c, err) helper.InternalServer(c, err)
@@ -107,6 +108,7 @@ func (b *BaseApi) EditRootCert(c *gin.Context) {
} }
if err := loadCertAfterDecrypt(&req); err != nil { if err := loadCertAfterDecrypt(&req); err != nil {
helper.BadRequest(c, err) helper.BadRequest(c, err)
return
} }
if err := sshService.EditRootCert(req); err != nil { if err := sshService.EditRootCert(req); err != nil {
helper.InternalServer(c, err) helper.InternalServer(c, err)
+12 -3
View File
@@ -5,6 +5,8 @@ import (
"github.com/1Panel-dev/1Panel/agent/app/dto" "github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/app/dto/request" "github.com/1Panel-dev/1Panel/agent/app/dto/request"
"github.com/1Panel-dev/1Panel/agent/app/dto/response" "github.com/1Panel-dev/1Panel/agent/app/dto/response"
"github.com/1Panel-dev/1Panel/agent/app/service"
"github.com/1Panel-dev/1Panel/agent/utils/taskscope"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
@@ -21,7 +23,7 @@ func (b *BaseApi) PageTasks(c *gin.Context) {
if err := helper.CheckBindAndValidate(&req, c); err != nil { if err := helper.CheckBindAndValidate(&req, c); err != nil {
return return
} }
total, list, err := taskService.Page(req) total, list, err := taskServiceForRequest(c).Page(req)
if err != nil { if err != nil {
helper.InternalServer(c, err) helper.InternalServer(c, err)
return return
@@ -45,7 +47,7 @@ func (b *BaseApi) ReadTaskLogByLine(c *gin.Context) {
return return
} }
var res *response.FileLineContent var res *response.FileLineContent
res, err := taskService.ReadByLine(req) res, err := taskServiceForRequest(c).ReadByLine(req)
if err != nil { if err != nil {
helper.InternalServer(c, err) helper.InternalServer(c, err)
return return
@@ -64,10 +66,17 @@ func (b *BaseApi) ReadTaskLogByLine(c *gin.Context) {
// @Security Timestamp // @Security Timestamp
// @Router /logs/tasks/executing/count [get] // @Router /logs/tasks/executing/count [get]
func (b *BaseApi) CountExecutingTasks(c *gin.Context) { func (b *BaseApi) CountExecutingTasks(c *gin.Context) {
count, err := taskService.CountExecutingTask() count, err := taskServiceForRequest(c).CountExecutingTask()
if err != nil { if err != nil {
helper.InternalServer(c, err) helper.InternalServer(c, err)
return return
} }
helper.SuccessWithData(c, count) helper.SuccessWithData(c, count)
} }
func taskServiceForRequest(c *gin.Context) service.ITaskLogService {
if granted := taskscope.Grants(c.Request.Context()); len(granted) != 0 {
return service.NewITaskService(granted...)
}
return taskService
}
+10
View File
@@ -243,6 +243,16 @@ func loadTerminalIdentity(c *gin.Context) (terminal.Identity, bool) {
UserID: strings.TrimSpace(c.GetHeader(terminal.HeaderUserID)), UserID: strings.TrimSpace(c.GetHeader(terminal.HeaderUserID)),
AuthSessionID: strings.TrimSpace(c.GetHeader(terminal.HeaderAuthSessionID)), AuthSessionID: strings.TrimSpace(c.GetHeader(terminal.HeaderAuthSessionID)),
} }
if value := c.GetHeader(terminal.HeaderAuthLeaseUntil); value != "" {
millis, err := strconv.ParseInt(value, 10, 64)
if err != nil || millis <= 0 {
return terminal.Identity{}, false
}
identity.AuthLeaseUntil = time.UnixMilli(millis)
if maximum := time.Now().Add(90 * time.Second); identity.AuthLeaseUntil.After(maximum) {
identity.AuthLeaseUntil = maximum
}
}
return identity, identity.Valid() return identity, identity.Valid()
} }
+10
View File
@@ -0,0 +1,10 @@
package v2
import (
"github.com/1Panel-dev/1Panel/agent/app/api/v2/helper"
"github.com/gin-gonic/gin"
)
func (b *BaseApi) TerminalCapabilities(c *gin.Context) {
helper.SuccessWithData(c, gin.H{"apiKeyLeaseVersion": 1})
}
+1 -1
View File
@@ -402,7 +402,7 @@ func (b *BaseApi) ChangeDefaultServer(c *gin.Context) {
// @Security ApiKeyAuth // @Security ApiKeyAuth
// @Security Timestamp // @Security Timestamp
// @Router /websites/php/version [post] // @Router /websites/php/version [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":"php 版本变更 [domain]","formatEN":"php version update [domain]"} // @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":"php 版本变更 [domain]","formatEN":"php version update [domain]"}
func (b *BaseApi) ChangePHPVersion(c *gin.Context) { func (b *BaseApi) ChangePHPVersion(c *gin.Context) {
var req request.WebsitePHPVersionReq var req request.WebsitePHPVersionReq
if err := helper.CheckBindAndValidate(&req, c); err != nil { if err := helper.CheckBindAndValidate(&req, c); err != nil {
+64
View File
@@ -1,13 +1,16 @@
package v2 package v2
import ( import (
"io"
"net/http" "net/http"
"net/url" "net/url"
"strconv" "strconv"
"strings"
"github.com/1Panel-dev/1Panel/agent/app/api/v2/helper" "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"
"github.com/1Panel-dev/1Panel/agent/app/dto/request" "github.com/1Panel-dev/1Panel/agent/app/dto/request"
"github.com/1Panel-dev/1Panel/agent/buserr"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
@@ -176,3 +179,64 @@ func (b *BaseApi) DownloadCAFile(c *gin.Context) {
c.Header("Content-Disposition", "attachment; filename*=utf-8''"+url.PathEscape(info.Name())) c.Header("Content-Disposition", "attachment; filename*=utf-8''"+url.PathEscape(info.Name()))
http.ServeContent(c.Writer, c.Request, info.Name(), info.ModTime(), file) http.ServeContent(c.Writer, c.Request, info.Name(), info.ModTime(), file)
} }
// @Tags Website CA
// @Summary Upload website CA archive
// @Accept multipart/form-data
// @Param file formData file true "ZIP containing ca.crt and ca.key"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /websites/ca/upload [post]
func (b *BaseApi) UploadWebsiteCA(c *gin.Context) {
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 11<<20)
if err := c.Request.ParseMultipartForm(11 << 20); err != nil {
helper.InternalServer(c, buserr.New("ErrCAUpload"))
return
}
if c.Request.MultipartForm != nil {
defer c.Request.MultipartForm.RemoveAll()
}
file, err := c.FormFile("file")
if err != nil || file.Size > 10<<20 || !strings.HasSuffix(strings.ToLower(file.Filename), ".zip") {
helper.InternalServer(c, buserr.New("ErrCAUpload"))
return
}
stream, err := file.Open()
if err != nil {
helper.InternalServer(c, err)
return
}
defer stream.Close()
data, err := io.ReadAll(io.LimitReader(stream, (10<<20)+1))
if err != nil {
helper.InternalServer(c, err)
return
}
if err = websiteCAService.Upload(file.Filename[:len(file.Filename)-len(".zip")], data); err != nil {
helper.InternalServer(c, err)
return
}
helper.Success(c)
}
// @Tags Website CA
// @Summary Import website CA from PEM or server files
// @Accept json
// @Param request body request.WebsiteCAImport true "request"
// @Success 200
// @Security ApiKeyAuth
// @Security Timestamp
// @Router /websites/ca/import [post]
func (b *BaseApi) ImportWebsiteCA(c *gin.Context) {
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 3<<20)
var req request.WebsiteCAImport
if err := helper.CheckBindAndValidate(&req, c); err != nil {
return
}
if err := websiteCAService.Import(req); err != nil {
helper.InternalServer(c, err)
return
}
helper.Success(c)
}
+25 -1
View File
@@ -9,7 +9,8 @@ type AgentCreateReq struct {
WebUIPort int `json:"webUIPort" validate:"required,min=1,max=65535"` WebUIPort int `json:"webUIPort" validate:"required,min=1,max=65535"`
BridgePort int `json:"bridgePort"` BridgePort int `json:"bridgePort"`
AllowedOrigins []string `json:"allowedOrigins"` AllowedOrigins []string `json:"allowedOrigins"`
AgentType string `json:"agentType" validate:"required,oneof=openclaw copaw hermes-agent"` AgentType string `json:"agentType" validate:"required,oneof=openclaw copaw hermes-agent deepseek-harness octop"`
AccessHost string `json:"accessHost"`
Model string `json:"model"` Model string `json:"model"`
AccountID uint `json:"accountId"` AccountID uint `json:"accountId"`
Token string `json:"token"` Token string `json:"token"`
@@ -110,6 +111,7 @@ type AgentBatchOperateResult struct {
} }
type AgentItem struct { type AgentItem struct {
AccessHost string `json:"accessHost"`
ID uint `json:"id"` ID uint `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Remark string `json:"remark"` Remark string `json:"remark"`
@@ -384,6 +386,7 @@ type AgentAccountProviderCountReq struct {
} }
type AgentAccountInfo struct { type AgentAccountInfo struct {
AppInstallID uint `json:"appInstallId"`
ID uint `json:"id"` ID uint `json:"id"`
MasterAccountID uint `json:"masterAccountId"` MasterAccountID uint `json:"masterAccountId"`
Provider string `json:"provider"` Provider string `json:"provider"`
@@ -430,7 +433,23 @@ type AgentFeishuConfigReq struct {
AgentID uint `json:"agentId" validate:"required"` AgentID uint `json:"agentId" validate:"required"`
} }
type AgentFeishuTransition struct {
Confirmed bool `json:"confirmed"`
Streaming bool `json:"streaming"`
GroupSessionScope string `json:"groupSessionScope"`
RequireMention string `json:"requireMention"`
}
type AgentFeishuSwitchReq struct {
AgentID uint `json:"agentId" validate:"required"`
TaskID string `json:"taskID" validate:"required"`
Streaming bool `json:"streaming"`
GroupSessionScope string `json:"groupSessionScope" validate:"required,oneof=group group_sender group_topic group_topic_sender"`
RequireMention string `json:"requireMention" validate:"required,oneof=true false"`
}
type AgentFeishuConfigUpdateReq struct { type AgentFeishuConfigUpdateReq struct {
GroupSessionScope string `json:"groupSessionScope" validate:"omitempty,oneof=group group_sender group_topic group_topic_sender"`
AgentID uint `json:"agentId" validate:"required"` AgentID uint `json:"agentId" validate:"required"`
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
ThreadSession bool `json:"threadSession"` ThreadSession bool `json:"threadSession"`
@@ -445,6 +464,11 @@ type AgentFeishuConfigUpdateReq struct {
} }
type AgentFeishuConfig struct { type AgentFeishuConfig struct {
BuiltinManaged bool `json:"builtinManaged"`
LegacyConfigured bool `json:"legacyConfigured"`
GroupSessionScope string `json:"groupSessionScope"`
BuiltinAvailable bool `json:"builtinAvailable"`
Legacy bool `json:"legacy"`
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
ThreadSession bool `json:"threadSession"` ThreadSession bool `json:"threadSession"`
ReplyMode string `json:"replyMode"` ReplyMode string `json:"replyMode"`
+6 -5
View File
@@ -14,16 +14,17 @@ type OllamaModelInfo struct {
CreatedAt time.Time `json:"createdAt"` CreatedAt time.Time `json:"createdAt"`
} }
type OllamaModelDropList struct {
ID uint `json:"id"`
Name string `json:"name"`
}
type OllamaModelName struct { type OllamaModelName struct {
Name string `json:"name"` Name string `json:"name"`
TaskID string `json:"taskID"` TaskID string `json:"taskID"`
} }
type OllamaModelAccountSync struct {
ID uint `json:"id" validate:"required"`
ModelAccountBaseURLType string `json:"modelAccountBaseURLType" validate:"required,oneof=container localhost systemIP custom"`
ModelAccountBaseURL string `json:"modelAccountBaseURL"`
}
type OllamaBindDomain struct { type OllamaBindDomain struct {
Domain string `json:"domain" validate:"required"` Domain string `json:"domain" validate:"required"`
AppInstallID uint `json:"appInstallID" validate:"required"` AppInstallID uint `json:"appInstallID" validate:"required"`
+13
View File
@@ -0,0 +1,13 @@
package dto
type AIBenchmarkSource struct {
ID string `json:"id"`
Kind string `json:"kind"`
Name string `json:"name"`
Status string `json:"status"`
BaseURL string `json:"baseUrl"`
Endpoint string `json:"endpoint"`
Models []string `json:"models"`
Tokenizer string `json:"tokenizer"`
Message string `json:"message"`
}
+2
View File
@@ -21,6 +21,7 @@ type AlertBase struct {
} }
type PushAlert struct { type PushAlert struct {
Result string `json:"result,omitempty"`
TaskName string `json:"taskName"` TaskName string `json:"taskName"`
AlertType string `json:"alertType"` AlertType string `json:"alertType"`
EntryID uint `json:"entryID"` EntryID uint `json:"entryID"`
@@ -53,6 +54,7 @@ type AlertDTO struct {
Method string `json:"method"` Method string `json:"method"`
Title string `json:"title"` Title string `json:"title"`
Project string `json:"project"` Project string `json:"project"`
TaskName string `json:"taskName,omitempty"`
Status string `json:"status"` Status string `json:"status"`
SendCount uint `json:"sendCount"` SendCount uint `json:"sendCount"`
AdvancedParams string `json:"advancedParams"` AdvancedParams string `json:"advancedParams"`
+2
View File
@@ -43,6 +43,8 @@ type AppOssConfig struct {
} }
type AppVersion struct { type AppVersion struct {
FeishuLegacy bool `json:"feishuLegacy,omitempty"`
FeishuConfigured bool `json:"feishuConfigured,omitempty"`
Version string `json:"version"` Version string `json:"version"`
DetailId uint `json:"detailId"` DetailId uint `json:"detailId"`
DockerCompose string `json:"dockerCompose"` DockerCompose string `json:"dockerCompose"`
+1
View File
@@ -172,6 +172,7 @@ type ContainerItemStats struct {
} }
type ContainerListStats struct { type ContainerListStats struct {
ContainerID string `json:"containerID"` ContainerID string `json:"containerID"`
ContainerName string `json:"containerName"`
CPUTotalUsage uint64 `json:"cpuTotalUsage"` CPUTotalUsage uint64 `json:"cpuTotalUsage"`
SystemUsage uint64 `json:"systemUsage"` SystemUsage uint64 `json:"systemUsage"`
+3
View File
@@ -54,6 +54,7 @@ type CronjobOperate struct {
AlertCount uint `json:"alertCount"` AlertCount uint `json:"alertCount"`
AlertTitle string `json:"alertTitle"` AlertTitle string `json:"alertTitle"`
AlertMethod string `json:"alertMethod"` AlertMethod string `json:"alertMethod"`
AlertTriggerMode string `json:"alertTriggerMode" validate:"omitempty,oneof=failed success both"`
CleanLogConfig CleanLogConfig
} }
@@ -127,6 +128,7 @@ type CronjobInfo struct {
Args string `json:"args"` Args string `json:"args"`
AlertCount uint `json:"alertCount"` AlertCount uint `json:"alertCount"`
AlertTriggerMode string `json:"alertTriggerMode"`
} }
type CronjobImport struct { type CronjobImport struct {
@@ -172,6 +174,7 @@ type CronjobTrans struct {
AlertCount uint `json:"alertCount"` AlertCount uint `json:"alertCount"`
AlertTitle string `json:"alertTitle"` AlertTitle string `json:"alertTitle"`
AlertMethod string `json:"alertMethod"` AlertMethod string `json:"alertMethod"`
AlertTriggerMode string `json:"alertTriggerMode" validate:"omitempty,oneof=failed success both"`
} }
type TransHelper struct { type TransHelper struct {
Name string `json:"name"` Name string `json:"name"`
+21 -1
View File
@@ -31,7 +31,27 @@ type DashboardBase struct {
} }
type ChangeQuicks struct { type ChangeQuicks struct {
Quicks []QuickJump `json:"quicks"` Quicks []QuickVisibility `json:"quicks" validate:"required,dive"`
}
type QuickVisibility struct {
ID uint `json:"id" validate:"required"`
IsShow bool `json:"isShow"`
}
type QuickDirectoryCreate struct {
Path string `json:"path" validate:"required"`
Alias string `json:"alias"`
}
type QuickDirectoryDelete struct {
ID uint `json:"id" validate:"required"`
Legacy bool `json:"legacy"`
}
type QuickDirectoryUpdate struct {
QuickDirectoryDelete
QuickDirectoryCreate
} }
type QuickJump struct { type QuickJump struct {
+4
View File
@@ -22,6 +22,10 @@ type DBBaseInfo struct {
Port int64 `json:"port"` Port int64 `json:"port"`
} }
type RedisCliInstall struct {
TaskID string `json:"taskID" validate:"omitempty,uuid"`
}
// mysql // mysql
type MysqlDBSearch struct { type MysqlDBSearch struct {
PageInfo PageInfo
+65 -96
View File
@@ -2,11 +2,13 @@ package dto
import ( import (
"github.com/1Panel-dev/1Panel/agent/utils/firewall" "github.com/1Panel-dev/1Panel/agent/utils/firewall"
dockerfirewall "github.com/1Panel-dev/1Panel/agent/utils/firewall/docker_guard"
"github.com/1Panel-dev/1Panel/agent/utils/firewall/filter" "github.com/1Panel-dev/1Panel/agent/utils/firewall/filter"
firewallsync "github.com/1Panel-dev/1Panel/agent/utils/firewall/sync" "github.com/1Panel-dev/1Panel/agent/utils/firewall/forwarding"
) )
type FirewallSubsystemStatus struct { type FirewallSubsystemStatus struct {
IPv6Enabled bool `json:"ipv6Enabled"`
Name string `json:"name"` Name string `json:"name"`
Backend string `json:"backend"` Backend string `json:"backend"`
ConflictBackend string `json:"conflictBackend,omitempty"` ConflictBackend string `json:"conflictBackend,omitempty"`
@@ -18,13 +20,13 @@ type FirewallSubsystemStatus struct {
PingStatus string `json:"pingStatus"` PingStatus string `json:"pingStatus"`
Message string `json:"message,omitempty"` Message string `json:"message,omitempty"`
Reason string `json:"reason,omitempty"` Reason string `json:"reason,omitempty"`
SyncError string `json:"syncError,omitempty"`
LifecycleTaskID string `json:"lifecycleTaskID,omitempty"` LifecycleTaskID string `json:"lifecycleTaskID,omitempty"`
IPv4 FirewallBackendFamilyStatus `json:"ipv4"` IPv4 FirewallBackendFamilyStatus `json:"ipv4"`
IPv6 FirewallBackendFamilyStatus `json:"ipv6"` IPv6 FirewallBackendFamilyStatus `json:"ipv6"`
} }
type FirewallLifecycleOperation struct { type FirewallLifecycleOperation struct {
BackupFile string `json:"backupFile,omitempty" validate:"omitempty,max=255"`
Operation string `json:"operation" validate:"required,oneof=start stop restart disableBanPing enableBanPing"` Operation string `json:"operation" validate:"required,oneof=start stop restart disableBanPing enableBanPing"`
WithDockerRestart bool `json:"withDockerRestart"` WithDockerRestart bool `json:"withDockerRestart"`
} }
@@ -49,10 +51,12 @@ type FirewallBackendOption struct {
} }
type FirewallBackendFamilyStatus struct { type FirewallBackendFamilyStatus struct {
Partial bool `json:"partial"`
Available bool `json:"available"` Available bool `json:"available"`
Initialized bool `json:"initialized"` Initialized bool `json:"initialized"`
Bound bool `json:"bound"` Bound bool `json:"bound"`
Reason string `json:"reason,omitempty"` Reason string `json:"reason,omitempty"`
RAInterfaces []string `json:"raInterfaces,omitempty"`
} }
type FirewallBackendGroup struct { type FirewallBackendGroup struct {
@@ -62,6 +66,7 @@ type FirewallBackendGroup struct {
} }
type FirewallSettings struct { type FirewallSettings struct {
IPv6Enabled bool `json:"ipv6Enabled"`
System FirewallBackendGroup `json:"system"` System FirewallBackendGroup `json:"system"`
Forwarding FirewallBackendGroup `json:"forwarding"` Forwarding FirewallBackendGroup `json:"forwarding"`
Docker FirewallBackendGroup `json:"docker"` Docker FirewallBackendGroup `json:"docker"`
@@ -90,6 +95,17 @@ type FirewallBackendOperation struct {
Operation string `json:"operation" validate:"required,oneof=select initialize cleanup"` Operation string `json:"operation" validate:"required,oneof=select initialize cleanup"`
} }
type FirewallIPv6Operation struct {
Status string `json:"status" validate:"required,oneof=Enable Disable"`
}
type FirewallFamilyOperation struct {
Subsystem string `json:"subsystem" validate:"required,oneof=system forwarding docker"`
Backend string `json:"backend" validate:"required,oneof=iptables nftables"`
Family string `json:"family" validate:"required,oneof=ipv4 ipv6"`
Operation string `json:"operation" validate:"required,oneof=initialize repair bind"`
}
type FilterChainOperation struct { type FilterChainOperation struct {
Name string `json:"name" validate:"required,eq=1PANEL_BASIC"` Name string `json:"name" validate:"required,eq=1PANEL_BASIC"`
Operate string `json:"operate" validate:"required,oneof=init-base bind-base unbind-base"` Operate string `json:"operate" validate:"required,oneof=init-base bind-base unbind-base"`
@@ -102,6 +118,7 @@ type FilterChainOperationResponse struct {
} }
type FirewallInitializationTask struct { type FirewallInitializationTask struct {
BackupFile string `json:"backupFile,omitempty" validate:"omitempty,max=255"`
TaskID string `json:"taskID,omitempty" validate:"omitempty,max=64"` TaskID string `json:"taskID,omitempty" validate:"omitempty,max=64"`
} }
@@ -112,23 +129,36 @@ type FirewallRuleInventoryResponse struct {
IPv6Range filter.PositionRange `json:"ipv6Range"` IPv6Range filter.PositionRange `json:"ipv6Range"`
Total int64 `json:"total"` Total int64 `json:"total"`
AllTotal int64 `json:"allTotal"` AllTotal int64 `json:"allTotal"`
ManagedTotal int64 `json:"managedTotal"`
Items []filter.InventoryItem `json:"items"` Items []filter.InventoryItem `json:"items"`
Notices []filter.ScopeNotice `json:"notices,omitempty"` Notices []filter.ScopeNotice `json:"notices,omitempty"`
} }
type FirewallRuleBackup struct {
Name string `json:"name"`
Provider filter.Provider `json:"provider"`
RuleCount int `json:"ruleCount"`
ModifiedAt int64 `json:"modifiedAt"`
}
type FirewallRuleBackups struct {
Directory string `json:"directory"`
Files []FirewallRuleBackup `json:"files"`
}
type FirewallRuleResetResponse struct { type FirewallRuleResetResponse struct {
BackupPath string `json:"backupPath"`
Removed int `json:"removed"` Removed int `json:"removed"`
Disabled bool `json:"disabled"` Disabled bool `json:"disabled"`
} }
type FirewallRuleReset struct { type FirewallRuleReset struct {
Subsystem string `json:"subsystem,omitempty" validate:"omitempty,oneof=system forwarding docker"`
Backup *bool `json:"backup,omitempty" default:"true"`
Provider filter.Provider `json:"provider,omitempty" validate:"omitempty,oneof=firewalld ufw iptables nftables"` Provider filter.Provider `json:"provider,omitempty" validate:"omitempty,oneof=firewalld ufw iptables nftables"`
WithDockerRestart bool `json:"withDockerRestart"` WithDockerRestart bool `json:"withDockerRestart"`
} }
type FirewallRuleInventory struct { type FirewallRuleInventory struct {
Refresh bool `json:"refresh,omitempty"`
PageInfo PageInfo
Scope filter.Scope `json:"scope,omitempty"` Scope filter.Scope `json:"scope,omitempty"`
Scopes []filter.Scope `json:"scopes,omitempty" validate:"max=16"` Scopes []filter.Scope `json:"scopes,omitempty" validate:"max=16"`
@@ -136,7 +166,6 @@ type FirewallRuleInventory struct {
Info string `json:"info"` Info string `json:"info"`
Families []filter.Family `json:"families,omitempty" validate:"omitempty,dive,oneof=ipv4 ipv6"` Families []filter.Family `json:"families,omitempty" validate:"omitempty,dive,oneof=ipv4 ipv6"`
Actions []string `json:"actions,omitempty" validate:"omitempty,dive,oneof=accept deny"` Actions []string `json:"actions,omitempty" validate:"omitempty,dive,oneof=accept deny"`
States []filter.InventoryState `json:"states,omitempty" validate:"omitempty,dive,oneof=managed adopted external drifted protected"`
ExcludeChains []string `json:"excludeChains,omitempty" validate:"omitempty,dive,oneof=1PANEL_BASIC_BEFORE 1PANEL_BASIC 1PANEL_BASIC_AFTER"` ExcludeChains []string `json:"excludeChains,omitempty" validate:"omitempty,dive,oneof=1PANEL_BASIC_BEFORE 1PANEL_BASIC 1PANEL_BASIC_AFTER"`
} }
@@ -148,6 +177,7 @@ type FirewallNativeDetail struct {
} }
type DockerPortGuardBase struct { type DockerPortGuardBase struct {
IPv6Enabled bool `json:"ipv6Enabled"`
Name string `json:"name"` Name string `json:"name"`
Version string `json:"version"` Version string `json:"version"`
IsExist bool `json:"isExist"` IsExist bool `json:"isExist"`
@@ -160,6 +190,7 @@ type DockerPortGuardBase struct {
} }
type DockerPortGuardFamilyStatus struct { type DockerPortGuardFamilyStatus struct {
Partial bool `json:"partial"`
State string `json:"state"` State string `json:"state"`
Reason string `json:"reason,omitempty"` Reason string `json:"reason,omitempty"`
Initialized bool `json:"initialized"` Initialized bool `json:"initialized"`
@@ -180,11 +211,8 @@ type DockerPortGuardEndpoint struct {
Application string `json:"application,omitempty"` Application string `json:"application,omitempty"`
PolicyUUID string `json:"policyUUID,omitempty"` PolicyUUID string `json:"policyUUID,omitempty"`
Mode string `json:"mode,omitempty"` Mode string `json:"mode,omitempty"`
NativeAction string `json:"nativeAction,omitempty"`
ReadOnly bool `json:"readOnly,omitempty"`
Sources []string `json:"sources"` Sources []string `json:"sources"`
Effective bool `json:"effective"` Effective bool `json:"effective"`
Description string `json:"description,omitempty"`
TrafficPath string `json:"trafficPath"` TrafficPath string `json:"trafficPath"`
ManagementTarget string `json:"managementTarget"` ManagementTarget string `json:"managementTarget"`
ManagementReason string `json:"managementReason,omitempty"` ManagementReason string `json:"managementReason,omitempty"`
@@ -221,6 +249,7 @@ type DockerPortGuardEndpointIdentity struct {
type DockerPortGuardPolicyBatch struct { type DockerPortGuardPolicyBatch struct {
Policies []DockerPortGuardPolicy `json:"policies" validate:"required,min=1,dive"` Policies []DockerPortGuardPolicy `json:"policies" validate:"required,min=1,dive"`
Import bool `json:"import"`
} }
type DockerPortGuardPolicyBatchDelete struct { type DockerPortGuardPolicyBatchDelete struct {
@@ -229,29 +258,27 @@ type DockerPortGuardPolicyBatchDelete struct {
type DockerPortGuardPolicy struct { type DockerPortGuardPolicy struct {
DockerPortGuardEndpointIdentity DockerPortGuardEndpointIdentity
Mode string `json:"mode" validate:"required,oneof=deny_sources allow_sources deny_all"` Mode string `json:"mode" validate:"required,oneof=deny_sources allow_sources deny_all accept_sources accept_all"`
Sources []string `json:"sources" validate:"dive,required,max=64"` Sources []string `json:"sources" validate:"dive,required,max=64"`
Description string `json:"description" validate:"max=256"`
} }
type DockerPortGuardOperation struct { type DockerPortGuardOperation struct {
BackupFile string `json:"backupFile,omitempty" validate:"omitempty,max=255"`
Operation string `json:"operation" validate:"required,oneof=initialize bind unbind"` Operation string `json:"operation" validate:"required,oneof=initialize bind unbind"`
TaskID string `json:"taskID,omitempty" validate:"omitempty,max=64"` TaskID string `json:"taskID,omitempty" validate:"omitempty,max=64"`
} }
type FirewallRuleAdopt struct {
Scope filter.Scope `json:"scope" validate:"required"`
InstanceKey string `json:"instanceKey" validate:"required,max=128"`
}
type FirewallRuleCreateItem struct { type FirewallRuleCreateItem struct {
Raw string `json:"raw,omitempty"`
ParseStatus filter.ParseStatus `json:"parseStatus,omitempty"`
Rule filter.FirewallRule `json:"rule" validate:"required"` Rule filter.FirewallRule `json:"rule" validate:"required"`
SourceKind string `json:"sourceKind" validate:"omitempty,oneof=user imported"` SourceKind string `json:"sourceKind" validate:"omitempty,oneof=user imported"`
SourceID string `json:"sourceID"`
} }
type FirewallRuleCreate struct { type FirewallRuleCreate struct {
Items []FirewallRuleCreateItem `json:"items" validate:"required,min=1,dive"` BackupFile string `json:"backupFile,omitempty" validate:"omitempty,max=255"`
Initialize bool `json:"initialize"`
Items []FirewallRuleCreateItem `json:"items" validate:"dive"`
} }
type FirewallRuleCreateResponse struct { type FirewallRuleCreateResponse struct {
@@ -270,66 +297,13 @@ type FirewallRuleCreateFailure struct {
Error string `json:"error,omitempty"` Error string `json:"error,omitempty"`
} }
type FirewallRuleSyncRequest struct {
Subsystem string `json:"subsystem" validate:"omitempty,oneof=system forwarding docker"`
SourceProvider filter.Provider `json:"sourceProvider,omitempty" validate:"omitempty,oneof=firewalld ufw iptables nftables"`
TargetProvider filter.Provider `json:"targetProvider" validate:"required,oneof=firewalld ufw iptables nftables"`
ResetSource bool `json:"resetSource"`
TaskID string `json:"taskID,omitempty" validate:"omitempty,max=64"`
}
type FirewallRuleSyncItem struct {
SourceUUID string `json:"sourceUUID"`
Rule *filter.FirewallRule `json:"rule,omitempty"`
ForwardRule *ForwardRule `json:"forwardRule,omitempty"`
DockerRule *DockerPortGuardEndpoint `json:"dockerRule,omitempty"`
Status firewallsync.Status `json:"status"`
ReasonCode firewallsync.ReasonCode `json:"reasonCode,omitempty"`
Reason string `json:"reason,omitempty"`
}
type FirewallRuleSyncPreview struct {
Subsystem string `json:"subsystem"`
SourceProvider filter.Provider `json:"sourceProvider,omitempty"`
TargetProvider filter.Provider `json:"targetProvider"`
Total int `json:"total"`
Ready int `json:"ready"`
Existing int `json:"existing"`
Removed int `json:"removed"`
Blocked int `json:"blocked"`
Items []FirewallRuleSyncItem `json:"items"`
}
type FirewallRuleSyncResult struct {
Subsystem string `json:"subsystem"`
SourceProvider filter.Provider `json:"sourceProvider,omitempty"`
TargetProvider filter.Provider `json:"targetProvider"`
Total int `json:"total"`
Succeeded int `json:"succeeded"`
Skipped int `json:"skipped"`
Removed int `json:"removed"`
Failed int `json:"failed"`
Errors []FirewallRuleSyncFailure `json:"errors,omitempty"`
TaskID string `json:"taskID,omitempty"`
Queued bool `json:"queued,omitempty"`
}
type FirewallRuleSyncTask struct {
TaskID string `json:"taskID,omitempty"`
Executing bool `json:"executing"`
}
type FirewallRuleSyncFailure struct {
SourceUUID string `json:"sourceUUID"`
Rule *filter.FirewallRule `json:"rule,omitempty"`
ForwardRule *ForwardRule `json:"forwardRule,omitempty"`
DockerRule *DockerPortGuardEndpoint `json:"dockerRule,omitempty"`
Error string `json:"error"`
}
type FirewallRuleDelete struct { type FirewallRuleDelete struct {
UUIDs []string `json:"uuids" validate:"omitempty,dive,required,max=64"` Targets []FirewallRuleDeleteItem `json:"targets" validate:"required,min=1,dive"`
BeforeRules []FirewallRuleDeleteTarget `json:"beforeRules,omitempty" validate:"omitempty,dive"` }
type FirewallRuleDeleteItem struct {
FirewallRuleDeleteTarget
Observed filter.ObservedRule `json:"observed" validate:"required"`
} }
type FirewallRuleDeleteTarget struct { type FirewallRuleDeleteTarget struct {
@@ -347,12 +321,12 @@ type FirewallRuleDeleteResponse struct {
type FirewallRuleDeleteFailure struct { type FirewallRuleDeleteFailure struct {
Index int `json:"index"` Index int `json:"index"`
UUID string `json:"uuid"` InstanceKey string `json:"instanceKey"`
Error string `json:"error"` Error string `json:"error"`
} }
type FirewallRuleUpdate struct { type FirewallRuleUpdate struct {
UUID string `json:"uuid" validate:"required,max=64"` FirewallRuleDeleteTarget
Rule *filter.FirewallRule `json:"rule,omitempty" validate:"required_without_all=Description OrderIndex Priority,excluded_with=Description OrderIndex Priority"` Rule *filter.FirewallRule `json:"rule,omitempty" validate:"required_without_all=Description OrderIndex Priority,excluded_with=Description OrderIndex Priority"`
Description *string `json:"description,omitempty" validate:"excluded_with=Rule"` Description *string `json:"description,omitempty" validate:"excluded_with=Rule"`
OrderIndex *int64 `json:"orderIndex,omitempty" validate:"excluded_with=Rule Priority"` OrderIndex *int64 `json:"orderIndex,omitempty" validate:"excluded_with=Rule Priority"`
@@ -360,26 +334,21 @@ type FirewallRuleUpdate struct {
} }
type FirewallRuleReorder struct { type FirewallRuleReorder struct {
UUID string `json:"uuid" validate:"required,max=64"` FirewallRuleDeleteTarget
TargetPosition *int64 `json:"targetPosition"` TargetPosition *int64 `json:"targetPosition"`
Priority *int `json:"priority"` Priority *int `json:"priority"`
} }
func (p *FirewallRuleSyncPreview) Add(item FirewallRuleSyncItem) { type FirewallRuleExportItem struct {
p.Items = append(p.Items, item) filter.FirewallRule
switch item.Status { Raw string `json:"raw,omitempty"`
case firewallsync.StatusReady: ParseStatus filter.ParseStatus `json:"parseStatus,omitempty"`
p.Ready++ }
p.Total++
case firewallsync.StatusExisting: type FirewallSubsystemBackup struct {
p.Existing++ Families []string `json:"families,omitempty"`
p.Total++ Subsystem string `json:"subsystem"`
case firewallsync.StatusRemove: Provider filter.Provider `json:"provider"`
p.Removed++ Forwarding []forwarding.Rule `json:"forwarding"`
case firewallsync.StatusBlocked: Docker *dockerfirewall.PolicyInventory `json:"docker,omitempty"`
p.Blocked++
if item.ReasonCode != firewallsync.ReasonReadOnlyRule {
p.Total++
}
}
} }
+1 -5
View File
@@ -24,14 +24,10 @@ type ForwardRule struct {
UsedStatus string `json:"usedStatus"` UsedStatus string `json:"usedStatus"`
Description string `json:"description"` Description string `json:"description"`
IsDesired bool `json:"isDesired"`
IsRuntime bool `json:"isRuntime"`
SyncStatus string `json:"syncStatus"`
} }
type ForwardRuleOperate struct { type ForwardRuleOperate struct {
ForceDelete bool `json:"forceDelete"` Import bool `json:"import"`
Rules []ForwardRuleOperation `json:"rules" validate:"required,min=1,dive"` Rules []ForwardRuleOperation `json:"rules" validate:"required,min=1,dive"`
} }
+89 -13
View File
@@ -1,6 +1,11 @@
package dto package dto
import "time" import (
"encoding/json"
"time"
"github.com/1Panel-dev/1Panel/agent/app/model"
)
type MonitorSearch struct { type MonitorSearch struct {
Param string `json:"param" validate:"required,oneof=all cpu memory load io network"` Param string `json:"param" validate:"required,oneof=all cpu memory load io network"`
@@ -26,6 +31,12 @@ type Process struct {
} }
type MonitorSetting struct { type MonitorSetting struct {
GPUMonitorStatus string `json:"gpuMonitorStatus"`
GPUMonitorStoreDays string `json:"gpuMonitorStoreDays"`
GPUMonitorInterval string `json:"gpuMonitorInterval"`
VLLMMonitorStatus string `json:"vllmMonitorStatus"`
VLLMMonitorStoreDays string `json:"vllmMonitorStoreDays"`
VLLMMonitorInterval string `json:"vllmMonitorInterval"`
MonitorStatus string `json:"monitorStatus"` MonitorStatus string `json:"monitorStatus"`
MonitorStoreDays string `json:"monitorStoreDays"` MonitorStoreDays string `json:"monitorStoreDays"`
MonitorInterval string `json:"monitorInterval"` MonitorInterval string `json:"monitorInterval"`
@@ -34,16 +45,19 @@ type MonitorSetting struct {
} }
type MonitorSettingUpdate struct { type MonitorSettingUpdate struct {
Key string `json:"key" validate:"required,oneof=MonitorStatus MonitorStoreDays MonitorInterval DefaultNetwork DefaultIO"` Key string `json:"key" validate:"required,oneof=MonitorStatus MonitorStoreDays MonitorInterval GPUMonitorStatus GPUMonitorStoreDays GPUMonitorInterval VLLMMonitorStatus VLLMMonitorStoreDays VLLMMonitorInterval DefaultNetwork DefaultIO"`
Value string `json:"value"` Value string `json:"value"`
} }
type MonitorGPUOptions struct { type MonitorGPUOptions struct {
Supported bool `json:"supported"`
GPUType string `json:"gpuType"` GPUType string `json:"gpuType"`
ChartHide []GPUChartHide `json:"chartHide"` ChartHide []GPUChartHide `json:"chartHide"`
Options []string `json:"options"` Options []string `json:"options"`
} }
type GPUChartHide struct { type GPUChartHide struct {
DeviceID string `json:"deviceID"`
Legacy bool `json:"legacy"`
ProductName string `json:"productName"` ProductName string `json:"productName"`
Type string `json:"type"` Type string `json:"type"`
Process bool `json:"process"` Process bool `json:"process"`
@@ -55,23 +69,54 @@ type GPUChartHide struct {
Speed bool `json:"speed"` Speed bool `json:"speed"`
} }
type MonitorGPUSearch struct { type MonitorGPUSearch struct {
Aggregation string `json:"aggregation" validate:"omitempty,oneof=avg max"`
DeviceID string `json:"deviceID"`
Legacy bool `json:"legacy"`
ProductName string `json:"productName"` ProductName string `json:"productName"`
StartTime time.Time `json:"startTime"` StartTime time.Time `json:"startTime"`
EndTime time.Time `json:"endTime"` EndTime time.Time `json:"endTime"`
} }
type MonitorGPUData struct { type MonitorGPUData struct {
Date []time.Time `json:"date"` MemoryActivity []*float64 `json:"memoryActivity"`
GPUValue []float64 `json:"gpuValue"` EncoderUtil []*float64 `json:"encoderUtil"`
TemperatureValue []float64 `json:"temperatureValue"` DecoderUtil []*float64 `json:"decoderUtil"`
PowerTotal []float64 `json:"powerTotal"` JPEGUtil []*float64 `json:"jpegUtil"`
PowerUsed []float64 `json:"powerUsed"` OFAUtil []*float64 `json:"ofaUtil"`
PowerPercent []float64 `json:"powerPercent"` MediaUtil []*float64 `json:"mediaUtil"`
MemoryTotal []float64 `json:"memoryTotal"` ComputeUtil []*float64 `json:"computeUtil"`
MemoryUsed []float64 `json:"memoryUsed"` CopyUtil []*float64 `json:"copyUtil"`
MemoryPercent []float64 `json:"memoryPercent"` HotspotTemperature []*float64 `json:"hotspotTemperature"`
SpeedValue []int `json:"speedValue"` FanRPM []*float64 `json:"fanRPM"`
AICPUUtil []*float64 `json:"aiCPUUtil"`
CtrlCPUUtil []*float64 `json:"ctrlCPUUtil"`
DDRUsed []*float64 `json:"ddrUsed"`
DDRTotal []*float64 `json:"ddrTotal"`
HBMUsed []*float64 `json:"hbmUsed"`
HBMTotal []*float64 `json:"hbmTotal"`
DDRBandwidth []*float64 `json:"ddrBandwidth"`
HBMBandwidth []*float64 `json:"hbmBandwidth"`
MemoryBandwidth []*float64 `json:"memoryBandwidth"`
MediaFrequency []*float64 `json:"mediaFrequency"`
HugepagesUsed []*float64 `json:"hugepagesUsed"`
HugepagesTotal []*float64 `json:"hugepagesTotal"`
ProcessCount []int `json:"processCount"` BucketSeconds int64 `json:"bucketSeconds"`
SampleCount int64 `json:"sampleCount"`
MemoryTemperatureValue []*float64 `json:"memoryTemperatureValue"`
FrequencyValue []*float64 `json:"frequencyValue"`
MemoryFrequencyValue []*float64 `json:"memoryFrequencyValue"`
Date []time.Time `json:"date"`
GPUValue []*float64 `json:"gpuValue"`
TemperatureValue []*float64 `json:"temperatureValue"`
PowerTotal []*float64 `json:"powerTotal"`
PowerUsed []*float64 `json:"powerUsed"`
PowerPercent []*float64 `json:"powerPercent"`
MemoryTotal []*float64 `json:"memoryTotal"`
MemoryUsed []*float64 `json:"memoryUsed"`
MemoryPercent []*float64 `json:"memoryPercent"`
SpeedValue []*float64 `json:"speedValue"`
ProcessCount []*float64 `json:"processCount"`
GPUProcesses [][]GPUProcess `json:"gpuProcesses"` GPUProcesses [][]GPUProcess `json:"gpuProcesses"`
} }
@@ -81,3 +126,34 @@ type GPUProcess struct {
ProcessName string `json:"processName"` ProcessName string `json:"processName"`
UsedMemory string `json:"usedMemory"` UsedMemory string `json:"usedMemory"`
} }
type MonitorVLLMSearch struct {
Group string `json:"group" validate:"omitempty,oneof=overview latency requests"`
AppInstallID uint `json:"appInstallID" validate:"required"`
StartTime time.Time `json:"startTime" validate:"required"`
EndTime time.Time `json:"endTime" validate:"required"`
Aggregation string `json:"aggregation" validate:"omitempty,oneof=avg max"`
}
type MonitorVLLMData struct {
SampleCount int64 `json:"sampleCount"`
BucketSeconds int64 `json:"bucketSeconds"`
Points []model.MonitorVLLM `json:"points"`
}
type MonitorVLLMCurrent struct {
AppInstallID uint `json:"appInstallID" validate:"required"`
}
type MonitorVLLMCurrentData struct {
model.MonitorVLLM
Metrics map[string]json.RawMessage `json:"metrics,omitempty"`
}
type MonitorVLLMClean struct {
AppInstallID uint `json:"appInstallID" validate:"required"`
}
type MonitorClean struct {
Type string `json:"type" validate:"required,oneof=host gpu"`
}
+9
View File
@@ -23,6 +23,10 @@ type AppInstallCreate struct {
Services map[string]string `json:"services"` Services map[string]string `json:"services"`
TaskID string `json:"taskID"` TaskID string `json:"taskID"`
SyncModelAccount *bool `json:"syncModelAccount"`
ModelAccountBaseURLType string `json:"modelAccountBaseURLType" validate:"omitempty,oneof=container localhost systemIP custom"`
ModelAccountBaseURL string `json:"modelAccountBaseURL"`
AppContainerConfig AppContainerConfig
NodePushConfig NodePushConfig
} }
@@ -83,6 +87,7 @@ type AppBackupDelete struct {
} }
type AppInstalledOperate struct { type AppInstalledOperate struct {
FeishuTransition *dto.AgentFeishuTransition `json:"feishuTransition,omitempty"`
InstallId uint `json:"installId" validate:"required"` InstallId uint `json:"installId" validate:"required"`
BackupId uint `json:"backupId"` BackupId uint `json:"backupId"`
DetailId uint `json:"detailId"` DetailId uint `json:"detailId"`
@@ -101,6 +106,7 @@ type AppInstalledOperate struct {
} }
type AppInstallUpgrade struct { type AppInstallUpgrade struct {
FeishuTransition *dto.AgentFeishuTransition `json:"feishuTransition,omitempty"`
InstallID uint `json:"installId"` InstallID uint `json:"installId"`
DetailID uint `json:"detailId"` DetailID uint `json:"detailId"`
Backup bool `json:"backup"` Backup bool `json:"backup"`
@@ -122,6 +128,9 @@ type AppInstallDelete struct {
} }
type AppInstalledUpdate struct { type AppInstalledUpdate struct {
SyncModelAccount *bool `json:"syncModelAccount"`
ModelAccountBaseURLType string `json:"modelAccountBaseURLType" validate:"omitempty,oneof=container localhost systemIP custom"`
ModelAccountBaseURL string `json:"modelAccountBaseURL"`
InstallId uint `json:"installId" validate:"required"` InstallId uint `json:"installId" validate:"required"`
Params map[string]interface{} `json:"params" validate:"required"` Params map[string]interface{} `json:"params" validate:"required"`
TaskID string `json:"-"` TaskID string `json:"-"`
+14
View File
@@ -0,0 +1,14 @@
package request
type MCPWebsiteBinding struct {
Operation string `json:"operation"`
RequestID string `json:"requestId"`
BindingID string `json:"bindingId"`
WebsiteID uint `json:"websiteId"`
Domain string `json:"domain"`
EnableSSL bool `json:"enableSSL"`
SSLID uint `json:"sslID"`
Upstream string `json:"upstream"`
CoreHost string `json:"coreHost"`
ProxyKey string `json:"proxyKey"`
}
+9
View File
@@ -182,3 +182,12 @@ type WebsiteSSLFileUpload struct {
PushNode bool `json:"pushNode"` PushNode bool `json:"pushNode"`
Nodes string `json:"nodes"` Nodes string `json:"nodes"`
} }
type WebsiteCAImport struct {
Name string `json:"name" validate:"required,max=128"`
Type string `json:"type" validate:"required,oneof=paste local"`
Certificate string `json:"certificate" validate:"required_if=Type paste"`
PrivateKey string `json:"privateKey" validate:"required_if=Type paste"`
CertificatePath string `json:"certificatePath" validate:"required_if=Type local"`
PrivateKeyPath string `json:"privateKeyPath" validate:"required_if=Type local"`
}
+8
View File
@@ -50,6 +50,8 @@ type TagDTO struct {
} }
type AppInstalledCheck struct { type AppInstalledCheck struct {
ModelAccountBaseURLType string `json:"modelAccountBaseURLType"`
ModelAccountBaseURL string `json:"modelAccountBaseURL"`
IsExist bool `json:"isExist"` IsExist bool `json:"isExist"`
Name string `json:"name"` Name string `json:"name"`
App string `json:"app"` App string `json:"app"`
@@ -184,6 +186,12 @@ type AppParam struct {
} }
type AppConfig struct { type AppConfig struct {
AppKey string `json:"appKey"`
Name string `json:"name"`
HttpPort int `json:"httpPort"`
SyncModelAccount bool `json:"syncModelAccount"`
ModelAccountBaseURLType string `json:"modelAccountBaseURLType"`
ModelAccountBaseURL string `json:"modelAccountBaseURL"`
Params []AppParam `json:"params"` Params []AppParam `json:"params"`
RawCompose string `json:"rawCompose"` RawCompose string `json:"rawCompose"`
request.AppContainerConfig request.AppContainerConfig
+17
View File
@@ -0,0 +1,17 @@
package response
type MCPWebsiteBinding struct {
State string `json:"state"`
WebsiteID uint `json:"websiteId"`
BindingID string `json:"bindingId"`
RequestID string `json:"requestId"`
Domain string `json:"domain"`
Endpoint string `json:"endpoint"`
EnableSSL bool `json:"enableSSL"`
SSLID uint `json:"sslID"`
WebsiteStatus string `json:"websiteStatus"`
Reason string `json:"reason"`
Code string `json:"code,omitempty"`
Error string `json:"error,omitempty"`
Definitive bool `json:"definitive,omitempty"`
}
+1 -1
View File
@@ -25,7 +25,7 @@ type RootCertOperate struct {
ID uint `json:"id"` ID uint `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Mode string `json:"mode"` Mode string `json:"mode"`
EncryptionMode string `json:"encryptionMode" validate:"required,oneof=rsa ed25519 ecdsa dsa"` EncryptionMode string `json:"encryptionMode"`
PassPhrase string `json:"passPhrase"` PassPhrase string `json:"passPhrase"`
PublicKey string `json:"publicKey"` PublicKey string `json:"publicKey"`
PrivateKey string `json:"privateKey"` PrivateKey string `json:"privateKey"`
+1
View File
@@ -2,6 +2,7 @@ package model
type AgentAccount struct { type AgentAccount struct {
BaseModel BaseModel
AppInstallID uint `json:"appInstallId"`
Provider string `json:"provider"` Provider string `json:"provider"`
Name string `json:"name"` Name string `json:"name"`
APIKey string `json:"apiKey"` APIKey string `json:"apiKey"`
+4
View File
@@ -28,6 +28,10 @@ type AppInstall struct {
Favorite bool `json:"favorite"` Favorite bool `json:"favorite"`
SortOrder int `json:"sortOrder" gorm:"default:0"` SortOrder int `json:"sortOrder" gorm:"default:0"`
SyncModelAccount bool `json:"syncModelAccount"`
ModelAccountBaseURLType string `json:"modelAccountBaseURLType"`
ModelAccountBaseURL string `json:"modelAccountBaseURL"`
App App `json:"app" gorm:"-:migration"` App App `json:"app" gorm:"-:migration"`
} }
+7
View File
@@ -5,6 +5,13 @@ type AppLauncher struct {
Key string `json:"key"` Key string `json:"key"`
} }
type QuickDirectory struct {
BaseModel
Path string `json:"path"`
Alias string `json:"alias"`
Legacy bool `json:"legacy" gorm:"-"`
}
type QuickJump struct { type QuickJump struct {
BaseModel BaseModel
Name string `json:"name"` Name string `json:"name"`
-222
View File
@@ -1,222 +0,0 @@
package model
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"sort"
"strings"
"github.com/1Panel-dev/1Panel/agent/utils/firewall/filter"
)
const FirewallRuleSequenceStep int64 = 1 << 32
type DockerPortGuardPolicy struct {
BaseModel
UUID string `gorm:"size:64;not null;uniqueIndex" json:"uuid"`
ReadOnly bool `gorm:"not null;default:false;uniqueIndex:idx_docker_port_guard_endpoint" json:"-"`
Family string `gorm:"size:16;not null;uniqueIndex:idx_docker_port_guard_endpoint" json:"family"`
HostIP string `gorm:"size:64;not null;uniqueIndex:idx_docker_port_guard_endpoint" json:"hostIP"`
HostPort uint16 `gorm:"not null;uniqueIndex:idx_docker_port_guard_endpoint" json:"hostPort"`
Protocol string `gorm:"size:8;not null;uniqueIndex:idx_docker_port_guard_endpoint" json:"protocol"`
Mode string `gorm:"size:32;not null" json:"mode"`
Sources string `gorm:"type:text" json:"-"`
Description string `gorm:"type:text" json:"description"`
NativeAction string `gorm:"size:32;not null;default:''" json:"-"`
NativeRules string `gorm:"type:text" json:"-"`
Sequence int64 `gorm:"not null;default:0" json:"-"`
}
type ForwardingRule struct {
BaseModel
Family string `gorm:"size:16;not null;uniqueIndex:idx_forwarding_rule_identity" json:"family"`
Protocol string `gorm:"size:8;not null;uniqueIndex:idx_forwarding_rule_identity" json:"protocol"`
Port string `gorm:"size:32;not null;uniqueIndex:idx_forwarding_rule_identity" json:"port"`
TargetIP string `gorm:"size:64;not null;uniqueIndex:idx_forwarding_rule_identity" json:"targetIP"`
TargetPort string `gorm:"size:32;not null;uniqueIndex:idx_forwarding_rule_identity" json:"targetPort"`
Interface string `gorm:"size:32;not null;default:'';uniqueIndex:idx_forwarding_rule_identity" json:"interface"`
}
type FirewallRule struct {
UUID string `gorm:"size:64;primaryKey" json:"uuid"`
Family string `gorm:"size:16;not null" json:"family"`
Protocol string `gorm:"size:32;not null" json:"protocol"`
SourceAddress string `gorm:"size:255" json:"sourceAddress"`
SourcePort string `gorm:"size:64" json:"sourcePort"`
DestinationAddress string `gorm:"size:255" json:"destinationAddress"`
DestinationPort string `gorm:"size:64" json:"destinationPort"`
Interface string `gorm:"size:128" json:"interface"`
ConnectionStates string `gorm:"type:text" json:"connectionStates"`
Action string `gorm:"size:32;not null" json:"action"`
Description string `gorm:"type:text" json:"description"`
CompatibilityError string `gorm:"type:text" json:"compatibilityError,omitempty"`
Priority *int `json:"priority,omitempty"`
Sequence *int64 `gorm:"index" json:"sequence,omitempty"`
Origin string `gorm:"size:32;not null" json:"origin"`
Owner string `gorm:"size:320;not null" json:"owner"`
Revision uint `gorm:"not null;default:1" json:"revision"`
}
func FirewallRuleOwner(sourceKind, sourceID string) string {
sourceKind = strings.TrimSpace(sourceKind)
sourceID = strings.TrimSpace(sourceID)
if sourceID == "" {
return sourceKind
}
return sourceKind + ":" + sourceID
}
func FirewallRuleFromDomain(rule filter.FirewallRule) (FirewallRule, error) {
normalized, err := filter.NormalizeRule(rule)
if err != nil {
return FirewallRule{}, err
}
switch normalized.NativeKind {
case "", filter.NativeKindRule, filter.NativeKindZonePort, filter.NativeKindRichRule, filter.NativeKindUFWRule:
default:
return FirewallRule{}, fmt.Errorf("%w: native rule %q cannot be stored as a provider-neutral policy", filter.ErrUnsupportedScope, normalized.NativeKind)
}
record := FirewallRule{
Family: string(normalized.Scope.Family),
Protocol: normalized.Protocol,
SourceAddress: normalized.SourceAddress,
SourcePort: normalized.SourcePort,
DestinationAddress: normalized.DestinationAddress,
DestinationPort: normalized.DestinationPort,
Interface: normalized.Interface,
ConnectionStates: strings.Join(normalized.ConnectionStates, ","),
Action: string(normalized.Action),
Description: normalized.Description,
}
if normalized.Scope.Provider == filter.ProviderFirewalld {
record.Priority = normalized.Priority
}
return record, nil
}
func (rule FirewallRule) PolicyKey() string {
payload, _ := json.Marshal(struct {
Family string `json:"family"`
Protocol string `json:"protocol"`
SourceAddress string `json:"sourceAddress,omitempty"`
SourcePort string `json:"sourcePort,omitempty"`
DestinationAddress string `json:"destinationAddress,omitempty"`
DestinationPort string `json:"destinationPort,omitempty"`
Interface string `json:"interface,omitempty"`
ConnectionStates string `json:"connectionStates,omitempty"`
Action string `json:"action"`
}{
Family: rule.Family, Protocol: rule.Protocol,
SourceAddress: rule.SourceAddress, SourcePort: rule.SourcePort,
DestinationAddress: rule.DestinationAddress, DestinationPort: rule.DestinationPort,
Interface: rule.Interface, ConnectionStates: rule.ConnectionStates, Action: rule.Action,
})
sum := sha256.Sum256(payload)
return hex.EncodeToString(sum[:])
}
func (rule FirewallRule) RulesForProvider(provider filter.Provider) ([]filter.FirewallRule, error) {
if rule.CompatibilityError != "" {
return nil, fmt.Errorf("%w: %s", filter.ErrUnsupportedScope, rule.CompatibilityError)
}
connectionStates := make([]string, 0)
if rule.ConnectionStates != "" {
connectionStates = strings.Split(rule.ConnectionStates, ",")
}
base := filter.FirewallRule{
Protocol: rule.Protocol, SourceAddress: rule.SourceAddress, SourcePort: rule.SourcePort,
DestinationAddress: rule.DestinationAddress, DestinationPort: rule.DestinationPort,
Interface: rule.Interface, ConnectionStates: connectionStates,
Action: filter.Action(rule.Action), Description: rule.Description,
}
if provider != filter.ProviderUFW && strings.EqualFold(strings.TrimSpace(base.Protocol), "all") &&
strings.TrimSpace(base.SourcePort) == "" && strings.TrimSpace(base.DestinationPort) != "" {
base.Protocol = "tcp/udp"
}
if provider == filter.ProviderFirewalld {
base.Priority = rule.Priority
}
families := []filter.Family{filter.Family(rule.Family)}
if provider != filter.ProviderFirewalld && families[0] == filter.FamilyInet {
hasIPv4, hasIPv6 := ruleAddressFamilies(base)
switch {
case hasIPv4 && hasIPv6:
return nil, fmt.Errorf("%w: inet policy contains both IPv4 and IPv6 addresses", filter.ErrUnsupportedScope)
case hasIPv6 || strings.EqualFold(base.Protocol, "icmpv6"):
families = []filter.Family{filter.FamilyIPv6}
case hasIPv4:
families = []filter.Family{filter.FamilyIPv4}
default:
families = []filter.Family{filter.FamilyIPv4, filter.FamilyIPv6}
}
}
result := make([]filter.FirewallRule, 0, len(families))
for _, family := range families {
compiled := base
compiled.Scope = filter.Scope{Provider: provider, Family: family, Direction: filter.DirectionInput}
switch provider {
case filter.ProviderIptables, filter.ProviderNftables:
compiled.Scope.Table, compiled.Scope.Chain = "filter", filter.IptablesInputChain
case filter.ProviderFirewalld:
compiled.Scope.Zone = filter.FirewalldInputZone
case filter.ProviderUFW:
compiled.Scope.Chain = filter.UFWInputChain
default:
return nil, fmt.Errorf("%w: unsupported firewall provider %q", filter.ErrProviderUnavailable, provider)
}
expanded, err := filter.ExpandAtomicRules(compiled)
if err != nil {
return nil, err
}
result = append(result, expanded...)
}
return result, nil
}
func SortFirewallRules(rules []FirewallRule, provider filter.Provider) {
sort.SliceStable(rules, func(i, j int) bool {
left, right := rules[i], rules[j]
if provider == filter.ProviderFirewalld {
switch {
case left.Priority == nil && right.Priority != nil:
return false
case left.Priority != nil && right.Priority == nil:
return true
case left.Priority != nil && right.Priority != nil && *left.Priority != *right.Priority:
return *left.Priority < *right.Priority
}
} else {
switch {
case left.Sequence == nil && right.Sequence != nil:
return false
case left.Sequence != nil && right.Sequence == nil:
return true
case left.Sequence != nil && right.Sequence != nil && *left.Sequence != *right.Sequence:
return *left.Sequence < *right.Sequence
}
}
return left.UUID < right.UUID
})
}
func ruleAddressFamilies(rule filter.FirewallRule) (bool, bool) {
hasIPv4, hasIPv6 := false, false
for _, address := range []string{rule.SourceAddress, rule.DestinationAddress} {
address = strings.TrimSpace(address)
if address == "" {
continue
}
if strings.Contains(address, ":") {
hasIPv6 = true
} else {
hasIPv4 = true
}
}
return hasIPv4, hasIPv6
}
+83 -7
View File
@@ -1,5 +1,7 @@
package model package model
import "time"
type MonitorBase struct { type MonitorBase struct {
BaseModel BaseModel
Cpu float64 `json:"cpu"` Cpu float64 `json:"cpu"`
@@ -33,14 +35,88 @@ type MonitorNetwork struct {
} }
type MonitorGPU struct { type MonitorGPU struct {
MemoryUtil *float64 `json:"memoryUtil"`
MemoryActivity *float64 `json:"memoryActivity"`
EncoderUtil *float64 `json:"encoderUtil"`
DecoderUtil *float64 `json:"decoderUtil"`
JPEGUtil *float64 `json:"jpegUtil"`
OFAUtil *float64 `json:"ofaUtil"`
MediaUtil *float64 `json:"mediaUtil"`
ComputeUtil *float64 `json:"computeUtil"`
CopyUtil *float64 `json:"copyUtil"`
HotspotTemperature *float64 `json:"hotspotTemperature"`
FanRPM *float64 `json:"fanRPM"`
AICPUUtil *float64 `json:"aiCPUUtil"`
CtrlCPUUtil *float64 `json:"ctrlCPUUtil"`
DDRUsed *float64 `json:"ddrUsed"`
DDRTotal *float64 `json:"ddrTotal"`
HBMUsed *float64 `json:"hbmUsed"`
HBMTotal *float64 `json:"hbmTotal"`
DDRBandwidth *float64 `json:"ddrBandwidth"`
HBMBandwidth *float64 `json:"hbmBandwidth"`
MemoryBandwidth *float64 `json:"memoryBandwidth"`
MediaFrequency *float64 `json:"mediaFrequency"`
HugepagesUsed *float64 `json:"hugepagesUsed"`
HugepagesTotal *float64 `json:"hugepagesTotal"`
MemoryTemperature *float64 `json:"memoryTemperature"`
DeviceID string `json:"deviceID"`
DeviceType string `json:"deviceType"`
ProcessStatus string `json:"processStatus"`
Frequency *float64 `json:"frequency"`
MemoryFrequency *float64 `json:"memoryFrequency"`
IntervalSeconds int `json:"intervalSeconds"`
BaseModel BaseModel
ProductName string `json:"productName"` ProductName string `json:"productName"`
GPUUtil float64 `json:"gpuUtil"` GPUUtil *float64 `json:"gpuUtil"`
Temperature float64 `json:"temperature"` Temperature *float64 `json:"temperature"`
PowerDraw float64 `json:"powerDraw"` PowerDraw *float64 `json:"powerDraw"`
MaxPowerLimit float64 `json:"maxPowerLimit"` MaxPowerLimit *float64 `json:"maxPowerLimit"`
MemUsed float64 `json:"memUsed"` MemUsed *float64 `json:"memUsed"`
MemTotal float64 `json:"memTotal"` MemTotal *float64 `json:"memTotal"`
FanSpeed int `json:"fanSpeed"` FanSpeed *float64 `json:"fanSpeed"`
Processes string `json:"processes"` Processes string `json:"processes"`
} }
type MonitorVLLM struct {
ID uint `json:"-" gorm:"primarykey;autoIncrement"`
CreatedAt time.Time `json:"createdAt"`
AppInstallID uint `json:"appInstallID"`
Status string `json:"status"`
RawMetrics string `json:"-"`
HistogramDeltas string `json:"-"`
IntervalSeconds float64 `json:"intervalSeconds" gorm:"-"`
SampleIntervalSeconds int `json:"-"`
RequestDeltas string `json:"-"`
CompletedRequests *float64 `json:"completedRequests" gorm:"-"`
QueueTime *float64 `json:"queueTime"`
AverageGenerationTokens *float64 `json:"averageGenerationTokens"`
PrefixCacheHitRate *float64 `json:"prefixCacheHitRate"`
FinishReasons map[string]float64 `json:"finishReasons" gorm:"-"`
PromptDistribution map[string]float64 `json:"promptDistribution" gorm:"-"`
GenerationDistribution map[string]float64 `json:"generationDistribution" gorm:"-"`
Running *float64 `json:"running"`
Waiting *float64 `json:"waiting"`
CacheUsage *float64 `json:"cacheUsage"`
PromptThroughput *float64 `json:"promptThroughput"`
GenerationThroughput *float64 `json:"generationThroughput"`
RequestThroughput *float64 `json:"requestThroughput"`
TimeToFirstToken *float64 `json:"timeToFirstToken"`
TimePerOutputToken *float64 `json:"timePerOutputToken"`
RequestLatency *float64 `json:"requestLatency"`
PrefillTime *float64 `json:"prefillTime"`
DecodeTime *float64 `json:"decodeTime"`
TimeToFirstTokenP50 *float64 `json:"timeToFirstTokenP50"`
TimeToFirstTokenP90 *float64 `json:"timeToFirstTokenP90"`
TimeToFirstTokenP95 *float64 `json:"timeToFirstTokenP95"`
TimeToFirstTokenP99 *float64 `json:"timeToFirstTokenP99"`
TimePerOutputTokenP50 *float64 `json:"timePerOutputTokenP50"`
TimePerOutputTokenP90 *float64 `json:"timePerOutputTokenP90"`
TimePerOutputTokenP95 *float64 `json:"timePerOutputTokenP95"`
TimePerOutputTokenP99 *float64 `json:"timePerOutputTokenP99"`
RequestLatencyP50 *float64 `json:"requestLatencyP50"`
RequestLatencyP90 *float64 `json:"requestLatencyP90"`
RequestLatencyP95 *float64 `json:"requestLatencyP95"`
RequestLatencyP99 *float64 `json:"requestLatencyP99"`
}
+2 -3
View File
@@ -66,7 +66,7 @@ var catalog = map[string]Meta{
{APIType: "openai-responses", BaseURL: "https://api.deepseek.com"}, {APIType: "openai-responses", BaseURL: "https://api.deepseek.com"},
anthropicAPIConfig("https://api.deepseek.com/anthropic", AuthModeXAPIKey), anthropicAPIConfig("https://api.deepseek.com/anthropic", AuthModeXAPIKey),
}, },
Models: []Model{{ID: "deepseek-v4-flash", Name: "deepseek-v4-flash"}, {ID: "deepseek-v4-pro", Name: "deepseek-v4-pro"}}, Models: []Model{{ID: "deepseek-flash", Name: "deepseek-flash"}, {ID: "deepseek-v4-pro", Name: "deepseek-v4-pro"}},
}, },
"bailian-coding-plan": { "bailian-coding-plan": {
Key: "bailian-coding-plan", DisplayNameKey: "AIProviderBailianCodingPlan", Sort: 30, DefaultAPIType: "openai-completions", EnvKey: "QWEN_API_KEY", Key: "bailian-coding-plan", DisplayNameKey: "AIProviderBailianCodingPlan", Sort: 30, DefaultAPIType: "openai-completions", EnvKey: "QWEN_API_KEY",
@@ -82,7 +82,6 @@ var catalog = map[string]Meta{
{ID: "kimi-k2.5", Name: "Kimi K2.5"}, {ID: "kimi-k2.5", Name: "Kimi K2.5"},
{ID: "qwen3.5-plus", Name: "Qwen3.5-Plus"}, {ID: "qwen3.5-plus", Name: "Qwen3.5-Plus"},
{ID: "glm-5", Name: "GLM-5"}, {ID: "glm-5", Name: "GLM-5"},
{ID: "MiniMax-M2.5", Name: "MiniMax M2.5"},
{ID: "qwen3.6-plus", Name: "Qwen3.6-Plus"}, {ID: "qwen3.6-plus", Name: "Qwen3.6-Plus"},
{ID: "qwen3.7-plus", Name: "Qwen3.7-Plus"}, {ID: "qwen3.7-plus", Name: "Qwen3.7-Plus"},
}, },
@@ -114,7 +113,7 @@ var catalog = map[string]Meta{
{APIType: "openai-completions", BaseURL: "https://api.minimaxi.com/v1"}, {APIType: "openai-completions", BaseURL: "https://api.minimaxi.com/v1"},
{APIType: "minimax-images", BaseURL: "https://api.minimaxi.com"}, {APIType: "minimax-images", BaseURL: "https://api.minimaxi.com"},
}, },
Models: []Model{{ID: "MiniMax-M3", Name: "MiniMax M3"}, {ID: "MiniMax-M2.7", Name: "MiniMax M2.7"}, {ID: "MiniMax-M2.7-highspeed", Name: "MiniMax M2.7 highspeed"}}, Models: []Model{{ID: "MiniMax-M3.1-Flash", Name: "MiniMax M3.1 Flash"}, {ID: "MiniMax-M3", Name: "MiniMax M3"}},
}, },
"xiaomi": { "xiaomi": {
Key: "xiaomi", DisplayName: "Xiaomi", Sort: 46, DefaultAPIType: "openai-completions", EnvKey: "XIAOMI_API_KEY", Key: "xiaomi", DisplayName: "Xiaomi", Sort: 46, DefaultAPIType: "openai-completions", EnvKey: "XIAOMI_API_KEY",
+100
View File
@@ -0,0 +1,100 @@
package provider
import (
"encoding/json"
"net/http"
"net/http/httptest"
"reflect"
"strings"
"testing"
)
func TestMiniMaxCatalog(t *testing.T) {
meta, ok := Get("minimax")
if !ok || !reflect.DeepEqual(meta.Models, []Model{{ID: "MiniMax-M3.1-Flash", Name: "MiniMax M3.1 Flash"}, {ID: "MiniMax-M3", Name: "MiniMax M3"}}) {
t.Fatalf("unexpected MiniMax models: %+v", meta.Models)
}
if meta.DefaultAPIType != "anthropic-messages" {
t.Fatalf("changed default protocol: %s", meta.DefaultAPIType)
}
for _, provider := range All() {
for _, model := range provider.Models {
if strings.Contains(strings.ToLower(model.ID), "minimax") && model.ID != "MiniMax-M3.1-Flash" && model.ID != "MiniMax-M3" {
t.Errorf("legacy suggestion in %s: %s", provider.Key, model.ID)
}
}
}
}
func TestMiniMaxVerificationRequests(t *testing.T) {
for _, region := range []string{"https://api.minimaxi.com", "https://api.minimax.io"} {
for _, protocol := range []string{"anthropic-messages", "openai-completions"} {
for _, auth := range []string{AuthModeXAPIKey, AuthModeBearer} {
for _, model := range []string{"MiniMax-M3.1-Flash", "MiniMax-M3", "MiniMax-M2.7", "MiniMax-M2.7-highspeed", "custom-model"} {
t.Run(region+"/"+protocol+"/"+auth+"/"+model, func(t *testing.T) {
base, endpoint := region+"/v1", "/chat/completions"
if protocol == "anthropic-messages" {
base, endpoint = region+"/anthropic", "/v1/messages"
}
built := BuildVerifyRequest("minimax", protocol, auth, base, "test-key", model)
if built.URL != base+endpoint {
t.Fatalf("URL = %s", built.URL)
}
var captured map[string]interface{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != strings.TrimPrefix(base, region)+endpoint {
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
}
if protocol == "anthropic-messages" && auth == AuthModeXAPIKey {
if r.Header.Get("x-api-key") != "test-key" || r.Header.Get("Authorization") != "" {
t.Error("incorrect API key authentication")
}
} else if r.Header.Get("Authorization") != "Bearer test-key" {
t.Error("incorrect bearer authentication")
}
if err := json.NewDecoder(r.Body).Decode(&captured); err != nil {
t.Error(err)
}
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
if err := VerifyAccount("minimax", protocol, auth, server.URL+strings.TrimPrefix(base, region), "test-key", model); err != nil {
t.Fatal(err)
}
if captured["model"] != model || captured["stream"] != false || captured["max_tokens"] != float64(defaultVerifyMaxTokens) {
t.Fatalf("unexpected serialized body: %#v", captured)
}
if model == "MiniMax-M3.1-Flash" {
if protocol == "anthropic-messages" {
if !reflect.DeepEqual(captured["thinking"], map[string]interface{}{"type": "adaptive"}) || !reflect.DeepEqual(captured["output_config"], map[string]interface{}{"effort": "max"}) || captured["reasoning_effort"] != nil {
t.Fatalf("invalid Anthropic Flash reasoning: %#v", captured)
}
} else if captured["reasoning_effort"] != "max" || captured["thinking"] != nil || captured["output_config"] != nil {
t.Fatalf("invalid OpenAI Flash reasoning: %#v", captured)
}
} else if captured["reasoning_effort"] != nil || captured["thinking"] != nil || captured["output_config"] != nil {
t.Fatalf("changed legacy/custom reasoning: %#v", captured)
}
})
}
}
}
}
}
func TestMiniMaxExplicitGatewayAndCustomModels(t *testing.T) {
for _, provider := range []string{"bailian-coding-plan", "custom", "openrouter"} {
for _, protocol := range []string{"openai-completions", "anthropic-messages"} {
for _, model := range []string{"MiniMax-M2.5", "MiniMax-M3.1-Flash", "my-custom-model"} {
request := BuildVerifyRequest(provider, protocol, AuthModeBearer, "https://example.test/v1", "test-key", model)
var body map[string]interface{}
if err := json.Unmarshal(request.Body, &body); err != nil {
t.Fatal(err)
}
if body["model"] != model || body["reasoning_effort"] != nil || body["output_config"] != nil || body["thinking"] != nil {
t.Fatalf("changed explicit %s request: %#v", provider, body)
}
}
}
}
}
+13 -4
View File
@@ -118,10 +118,15 @@ func BuildVerifyRequest(provider, apiType, authMode, baseURL, apiKey, model stri
headers["x-api-key"] = apiKey headers["x-api-key"] = apiKey
} }
headers["anthropic-version"] = "2023-06-01" headers["anthropic-version"] = "2023-06-01"
request.Body = mustJSON(map[string]interface{}{ body := map[string]interface{}{
"model": model, "max_tokens": defaultVerifyMaxTokens, "stream": false, "model": model, "max_tokens": defaultVerifyMaxTokens, "stream": false,
"messages": []map[string]interface{}{{"role": "user", "content": []map[string]string{{"type": "text", "text": "test"}}}}, "messages": []map[string]interface{}{{"role": "user", "content": []map[string]string{{"type": "text", "text": "test"}}}},
}) }
if provider == "minimax" && model == "MiniMax-M3.1-Flash" {
body["thinking"] = map[string]string{"type": "adaptive"}
body["output_config"] = map[string]string{"effort": "max"}
}
request.Body = mustJSON(body)
case "openai-responses": case "openai-responses":
request.URL = baseURL + "/responses" request.URL = baseURL + "/responses"
headers["Authorization"] = "Bearer " + apiKey headers["Authorization"] = "Bearer " + apiKey
@@ -131,9 +136,13 @@ func BuildVerifyRequest(provider, apiType, authMode, baseURL, apiKey, model stri
if (provider != "ollama" && provider != "llmman") || strings.TrimSpace(apiKey) != "" { if (provider != "ollama" && provider != "llmman") || strings.TrimSpace(apiKey) != "" {
headers["Authorization"] = "Bearer " + apiKey headers["Authorization"] = "Bearer " + apiKey
} }
request.Body = mustJSON(map[string]interface{}{ body := map[string]interface{}{
"model": model, "messages": []map[string]string{{"role": "user", "content": "test"}}, "max_tokens": defaultVerifyMaxTokens, "stream": false, "model": model, "messages": []map[string]string{{"role": "user", "content": "test"}}, "max_tokens": defaultVerifyMaxTokens, "stream": false,
}) }
if provider == "minimax" && model == "MiniMax-M3.1-Flash" {
body["reasoning_effort"] = "max"
}
request.Body = mustJSON(body)
} }
return request return request
} }
+21
View File
@@ -4,6 +4,7 @@ import (
"strings" "strings"
"github.com/1Panel-dev/1Panel/agent/app/model" "github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/global"
"gorm.io/gorm" "gorm.io/gorm"
) )
@@ -18,6 +19,7 @@ type IAgentAccountRepo interface {
DeleteByID(id uint) error DeleteByID(id uint) error
List(opts ...DBOption) ([]model.AgentAccount, error) List(opts ...DBOption) ([]model.AgentAccount, error)
CountTextByProviders(providers []string) (map[string]int64, error) CountTextByProviders(providers []string) (map[string]int64, error)
SaveLocal(account *model.AgentAccount, models []model.AgentAccountModel) error
} }
func NewIAgentAccountRepo() IAgentAccountRepo { func NewIAgentAccountRepo() IAgentAccountRepo {
@@ -97,6 +99,25 @@ func (a AgentAccountRepo) CountTextByProviders(providers []string) (map[string]i
return counts, nil return counts, nil
} }
func (a AgentAccountRepo) SaveLocal(account *model.AgentAccount, models []model.AgentAccountModel) error {
return global.DB.Transaction(func(tx *gorm.DB) error {
if err := tx.Save(account).Error; err != nil {
return err
}
if err := tx.Where("account_id = ?", account.ID).Delete(&model.AgentAccountModel{}).Error; err != nil {
return err
}
if len(models) == 0 {
return nil
}
for i := range models {
models[i].AccountID = account.ID
models[i].SortOrder = i + 1
}
return tx.Create(&models).Error
})
}
func normalizeProviders(providers []string) []string { func normalizeProviders(providers []string) []string {
seen := make(map[string]struct{}, len(providers)) seen := make(map[string]struct{}, len(providers))
result := make([]string, 0, len(providers)) result := make([]string, 0, len(providers))
+7
View File
@@ -1,6 +1,8 @@
package repo package repo
import ( import (
"context"
"github.com/1Panel-dev/1Panel/agent/app/model" "github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/global" "github.com/1Panel-dev/1Panel/agent/global"
) )
@@ -14,6 +16,7 @@ type IAiRepo interface {
Create(cronjob *model.OllamaModel) error Create(cronjob *model.OllamaModel) error
Update(id uint, vars map[string]interface{}) error Update(id uint, vars map[string]interface{}) error
Delete(opts ...DBOption) error Delete(opts ...DBOption) error
DeleteAll(ctx context.Context) error
} }
func NewIAiRepo() IAiRepo { func NewIAiRepo() IAiRepo {
@@ -67,3 +70,7 @@ func (u *AiRepo) Delete(opts ...DBOption) error {
} }
return db.Delete(&model.OllamaModel{}).Error return db.Delete(&model.OllamaModel{}).Error
} }
func (u *AiRepo) DeleteAll(ctx context.Context) error {
return getTx(ctx).Where("1 = 1").Delete(&model.OllamaModel{}).Error
}
+7
View File
@@ -38,6 +38,7 @@ type IAppInstallRepo interface {
LoadBaseInfo(key string, name string) (*RootInfo, error) LoadBaseInfo(key string, name string) (*RootInfo, error)
LoadInstallAppByKeyAndName(key string, name string) (*model.AppInstall, error) LoadInstallAppByKeyAndName(key string, name string) (*model.AppInstall, error)
GetFirstByCtx(ctx context.Context, opts ...DBOption) (model.AppInstall, error) GetFirstByCtx(ctx context.Context, opts ...DBOption) (model.AppInstall, error)
GetSnapshot(ctx context.Context, id uint) (model.AppInstall, error)
} }
func NewIAppInstallRepo() IAppInstallRepo { func NewIAppInstallRepo() IAppInstallRepo {
@@ -131,6 +132,12 @@ func (a *AppInstallRepo) GetFirstByCtx(ctx context.Context, opts ...DBOption) (m
return install, err return install, err
} }
func (a *AppInstallRepo) GetSnapshot(ctx context.Context, id uint) (model.AppInstall, error) {
var install model.AppInstall
err := getTx(ctx).WithContext(ctx).Preload("App").Where("id = ?", id).First(&install).Error
return install, err
}
func (a *AppInstallRepo) Create(ctx context.Context, install *model.AppInstall) error { func (a *AppInstallRepo) Create(ctx context.Context, install *model.AppInstall) error {
db := getTx(ctx).Model(&model.AppInstall{}) db := getTx(ctx).Model(&model.AppInstall{})
return db.Omit(clause.Associations).Create(&install).Error return db.Omit(clause.Associations).Create(&install).Error
+69 -3
View File
@@ -3,6 +3,7 @@ package repo
import ( import (
"github.com/1Panel-dev/1Panel/agent/app/model" "github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/global" "github.com/1Panel-dev/1Panel/agent/global"
"gorm.io/gorm"
) )
type LauncherRepo struct{} type LauncherRepo struct{}
@@ -17,6 +18,11 @@ type ILauncherRepo interface {
GetQuickJump(opts ...DBOption) (model.QuickJump, error) GetQuickJump(opts ...DBOption) (model.QuickJump, error)
ListQuickJump(withAll bool) []model.QuickJump ListQuickJump(withAll bool) []model.QuickJump
UpdateQuicks(quicks []model.QuickJump) error UpdateQuicks(quicks []model.QuickJump) error
ListQuickDirectories() ([]model.QuickDirectory, error)
CountQuickDirectories() (int64, error)
CreateQuickDirectory(item *model.QuickDirectory) error
UpdateQuickDirectory(item *model.QuickDirectory) error
DeleteQuickDirectory(id uint, legacy bool) error
} }
func NewILauncherRepo() ILauncherRepo { func NewILauncherRepo() ILauncherRepo {
@@ -95,13 +101,73 @@ func (u *LauncherRepo) UpdateQuicks(quicks []model.QuickJump) error {
for _, item := range quicks { for _, item := range quicks {
if err := tx.Model(&model.QuickJump{}).Where("id = ?", item.ID).Updates(map[string]interface{}{ if err := tx.Model(&model.QuickJump{}).Where("id = ?", item.ID).Updates(map[string]interface{}{
"is_show": item.IsShow, "is_show": item.IsShow,
"detail": item.Detail,
"alias": item.Alias,
}).Error; err != nil { }).Error; err != nil {
tx.Rollback() tx.Rollback()
return err return err
} }
} }
tx.Commit() return tx.Commit().Error
}
// Legacy entries remain in the overview configuration until explicitly deleted.
func legacyQuickDirectories(db *gorm.DB) *gorm.DB {
return db.Model(&model.QuickJump{}).Where("name = ? AND is_show = ? AND detail <> ?", "File", true, "")
}
func (u *LauncherRepo) ListQuickDirectories() ([]model.QuickDirectory, error) {
items := make([]model.QuickDirectory, 0)
if err := global.DB.Order("id ASC").Find(&items).Error; err != nil {
return nil, err
}
var quicks []model.QuickJump
if err := legacyQuickDirectories(global.DB).Order("id ASC").Find(&quicks).Error; err != nil {
return nil, err
}
for _, quick := range quicks {
items = append(items, model.QuickDirectory{BaseModel: quick.BaseModel, Path: quick.Detail, Alias: quick.Alias, Legacy: true})
}
return items, nil
}
func (u *LauncherRepo) CountQuickDirectories() (int64, error) {
var count, legacyCount int64
if err := global.DB.Model(&model.QuickDirectory{}).Count(&count).Error; err != nil {
return 0, err
}
if err := legacyQuickDirectories(global.DB).Count(&legacyCount).Error; err != nil {
return 0, err
}
return count + legacyCount, nil
}
func (u *LauncherRepo) CreateQuickDirectory(item *model.QuickDirectory) error {
return global.DB.Create(item).Error
}
func (u *LauncherRepo) UpdateQuickDirectory(item *model.QuickDirectory) error {
var result *gorm.DB
if item.Legacy {
result = legacyQuickDirectories(global.DB).Where("id = ?", item.ID).Updates(map[string]interface{}{"detail": item.Path, "alias": item.Alias})
} else {
result = global.DB.Model(&model.QuickDirectory{}).Where("id = ?", item.ID).Updates(map[string]interface{}{"path": item.Path, "alias": item.Alias})
}
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return gorm.ErrRecordNotFound
}
return nil
}
func (u *LauncherRepo) DeleteQuickDirectory(id uint, legacy bool) error {
var result *gorm.DB
if legacy {
result = legacyQuickDirectories(global.DB).Where("id = ?", id).Updates(map[string]interface{}{"detail": nil, "alias": nil})
} else {
result = global.DB.Delete(&model.QuickDirectory{}, id)
}
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return gorm.ErrRecordNotFound
}
return nil return nil
} }
+29
View File
@@ -6,6 +6,7 @@ import (
"strings" "strings"
"github.com/1Panel-dev/1Panel/agent/app/model" "github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/constant"
"github.com/1Panel-dev/1Panel/agent/global" "github.com/1Panel-dev/1Panel/agent/global"
"github.com/1Panel-dev/1Panel/agent/utils/encrypt" "github.com/1Panel-dev/1Panel/agent/utils/encrypt"
"gorm.io/gorm" "gorm.io/gorm"
@@ -20,6 +21,8 @@ type IDatabaseRepo interface {
Update(id uint, vars map[string]interface{}) error Update(id uint, vars map[string]interface{}) error
Delete(ctx context.Context, opts ...DBOption) error Delete(ctx context.Context, opts ...DBOption) error
Get(opts ...DBOption) (model.Database, error) Get(opts ...DBOption) (model.Database, error)
ListInstalledLocalMysql(ctx context.Context) ([]InstalledLocalMysql, error)
GetInstalledLocalMysql(ctx context.Context, name string) (InstalledLocalMysql, error)
WithoutByFrom(from string) DBOption WithoutByFrom(from string) DBOption
WithAppInstallID(appInstallID uint) DBOption WithAppInstallID(appInstallID uint) DBOption
WithTypeList(dbType string) DBOption WithTypeList(dbType string) DBOption
@@ -29,6 +32,32 @@ func NewIDatabaseRepo() IDatabaseRepo {
return &DatabaseRepo{} return &DatabaseRepo{}
} }
type InstalledLocalMysql struct {
ID uint
Type, From, Database string
ContainerName, Version, Env string
}
func installedLocalMysqlQuery(ctx context.Context) *gorm.DB {
return getTx(ctx).WithContext(ctx).Table("databases AS d").
Joins("JOIN app_installs AS ai ON ai.id = d.app_install_id AND ai.name = d.name").
Joins("JOIN apps AS a ON a.id = ai.app_id").
Where("d.type = ? AND d.\"from\" = ? AND a.key = ?", constant.AppMysql, "local", constant.AppMysql).
Where("ai.status <> ? AND ai.container_name <> ?", constant.StatusDeleted, "")
}
func (d *DatabaseRepo) ListInstalledLocalMysql(ctx context.Context) ([]InstalledLocalMysql, error) {
result := make([]InstalledLocalMysql, 0)
err := installedLocalMysqlQuery(ctx).Select("d.id, d.type, d.\"from\", d.name AS database, ai.version").Order("d.id").Scan(&result).Error
return result, err
}
func (d *DatabaseRepo) GetInstalledLocalMysql(ctx context.Context, name string) (InstalledLocalMysql, error) {
var result InstalledLocalMysql
err := installedLocalMysqlQuery(ctx).Where("d.name = ?", name).Select("d.id, ai.container_name, ai.version, ai.env").Take(&result).Error
return result, err
}
func (d *DatabaseRepo) Get(opts ...DBOption) (model.Database, error) { func (d *DatabaseRepo) Get(opts ...DBOption) (model.Database, error) {
var database model.Database var database model.Database
db := global.DB db := global.DB
+5
View File
@@ -18,6 +18,7 @@ type IMysqlRepo interface {
List(opts ...DBOption) ([]model.DatabaseMysql, error) List(opts ...DBOption) ([]model.DatabaseMysql, error)
Page(limit, offset int, opts ...DBOption) (int64, []model.DatabaseMysql, error) Page(limit, offset int, opts ...DBOption) (int64, []model.DatabaseMysql, error)
Create(ctx context.Context, mysql *model.DatabaseMysql) error Create(ctx context.Context, mysql *model.DatabaseMysql) error
CreateMetadata(ctx context.Context, mysql *model.DatabaseMysql) error
Delete(ctx context.Context, opts ...DBOption) error Delete(ctx context.Context, opts ...DBOption) error
Update(id uint, vars map[string]interface{}) error Update(id uint, vars map[string]interface{}) error
DeleteLocal(ctx context.Context) error DeleteLocal(ctx context.Context) error
@@ -94,6 +95,10 @@ func (u *MysqlRepo) Create(ctx context.Context, mysql *model.DatabaseMysql) erro
return getTx(ctx).Create(mysql).Error return getTx(ctx).Create(mysql).Error
} }
func (u *MysqlRepo) CreateMetadata(ctx context.Context, mysql *model.DatabaseMysql) error {
return getTx(ctx).WithContext(ctx).Create(mysql).Error
}
func (u *MysqlRepo) Delete(ctx context.Context, opts ...DBOption) error { func (u *MysqlRepo) Delete(ctx context.Context, opts ...DBOption) error {
return getTx(ctx, opts...).Delete(&model.DatabaseMysql{}).Error return getTx(ctx, opts...).Delete(&model.DatabaseMysql{}).Error
} }
+13
View File
@@ -14,8 +14,10 @@ type DatabaseUserRepo struct{}
type IDatabaseUserRepo interface { type IDatabaseUserRepo interface {
Get(opts ...DBOption) (model.DatabaseUser, error) Get(opts ...DBOption) (model.DatabaseUser, error)
GetEncryptedWithContext(ctx context.Context, opts ...DBOption) (model.DatabaseUser, error)
List(opts ...DBOption) ([]model.DatabaseUser, error) List(opts ...DBOption) ([]model.DatabaseUser, error)
Save(user *model.DatabaseUser) error Save(user *model.DatabaseUser) error
SaveEncryptedWithContext(ctx context.Context, user *model.DatabaseUser) error
Delete(opts ...DBOption) error Delete(opts ...DBOption) error
DeleteBy(ctx context.Context, opts ...DBOption) error DeleteBy(ctx context.Context, opts ...DBOption) error
Update(vars map[string]interface{}, opts ...DBOption) error Update(vars map[string]interface{}, opts ...DBOption) error
@@ -45,6 +47,13 @@ func (u *DatabaseUserRepo) Get(opts ...DBOption) (model.DatabaseUser, error) {
return user, nil return user, nil
} }
// GetEncryptedWithContext leaves credential transformation to the caller.
func (u *DatabaseUserRepo) GetEncryptedWithContext(ctx context.Context, opts ...DBOption) (model.DatabaseUser, error) {
var user model.DatabaseUser
err := getTx(ctx, opts...).WithContext(ctx).First(&user).Error
return user, err
}
func (u *DatabaseUserRepo) List(opts ...DBOption) ([]model.DatabaseUser, error) { func (u *DatabaseUserRepo) List(opts ...DBOption) ([]model.DatabaseUser, error) {
var users []model.DatabaseUser var users []model.DatabaseUser
db := global.DB.Model(&model.DatabaseUser{}) db := global.DB.Model(&model.DatabaseUser{})
@@ -75,6 +84,10 @@ func (u *DatabaseUserRepo) Save(user *model.DatabaseUser) error {
return global.DB.Save(user).Error return global.DB.Save(user).Error
} }
func (u *DatabaseUserRepo) SaveEncryptedWithContext(ctx context.Context, user *model.DatabaseUser) error {
return getTx(ctx).WithContext(ctx).Save(user).Error
}
func (u *DatabaseUserRepo) Delete(opts ...DBOption) error { func (u *DatabaseUserRepo) Delete(opts ...DBOption) error {
db := global.DB db := global.DB
for _, opt := range opts { for _, opt := range opts {
+7
View File
@@ -14,6 +14,7 @@ type IDatabaseUserGrantRepo interface {
Get(opts ...DBOption) (model.DatabaseUserGrant, error) Get(opts ...DBOption) (model.DatabaseUserGrant, error)
List(opts ...DBOption) ([]model.DatabaseUserGrant, error) List(opts ...DBOption) ([]model.DatabaseUserGrant, error)
Save(grant *model.DatabaseUserGrant) error Save(grant *model.DatabaseUserGrant) error
CreateIfAbsent(ctx context.Context, grant *model.DatabaseUserGrant) error
Replace(dbType, database string, grants []model.DatabaseUserGrant) error Replace(dbType, database string, grants []model.DatabaseUserGrant) error
Delete(opts ...DBOption) error Delete(opts ...DBOption) error
DeleteBy(ctx context.Context, opts ...DBOption) error DeleteBy(ctx context.Context, opts ...DBOption) error
@@ -52,6 +53,12 @@ func (u *DatabaseUserGrantRepo) Save(grant *model.DatabaseUserGrant) error {
return global.DB.Save(grant).Error return global.DB.Save(grant).Error
} }
func (u *DatabaseUserGrantRepo) CreateIfAbsent(ctx context.Context, grant *model.DatabaseUserGrant) error {
return getTx(ctx).WithContext(ctx).
Where("type = ? AND database = ? AND db_name = ? AND username = ? AND host = ?", grant.Type, grant.Database, grant.DBName, grant.Username, grant.Host).
FirstOrCreate(grant).Error
}
func (u *DatabaseUserGrantRepo) Replace(dbType, database string, grants []model.DatabaseUserGrant) error { func (u *DatabaseUserGrantRepo) Replace(dbType, database string, grants []model.DatabaseUserGrant) error {
return global.DB.Transaction(func(tx *gorm.DB) error { return global.DB.Transaction(func(tx *gorm.DB) error {
if err := tx.Where("`type` = ? AND database = ?", dbType, database).Delete(&model.DatabaseUserGrant{}).Error; err != nil { if err := tx.Where("`type` = ? AND database = ?", dbType, database).Delete(&model.DatabaseUserGrant{}).Error; err != nil {
-77
View File
@@ -1,77 +0,0 @@
package repo
import (
"context"
"github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/global"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type IDockerPortGuardRepo interface {
ListManaged(context.Context) ([]model.DockerPortGuardPolicy, error)
ListRuntimeReadOnly(context.Context) ([]model.DockerPortGuardPolicy, error)
DeleteBatch(context.Context, []string) error
UpsertBatch(context.Context, []model.DockerPortGuardPolicy) error
ReplaceRuntimeReadOnly(context.Context, []model.DockerPortGuardPolicy) error
}
type DockerPortGuardRepo struct{}
func NewIDockerPortGuardRepo() IDockerPortGuardRepo { return &DockerPortGuardRepo{} }
func (r *DockerPortGuardRepo) ListManaged(ctx context.Context) ([]model.DockerPortGuardPolicy, error) {
var policies []model.DockerPortGuardPolicy
err := global.DB.WithContext(ctx).
Where("read_only = ?", false).
Order("family, host_ip, host_port, protocol").
Find(&policies).Error
return policies, err
}
func (r *DockerPortGuardRepo) ListRuntimeReadOnly(ctx context.Context) ([]model.DockerPortGuardPolicy, error) {
var policies []model.DockerPortGuardPolicy
err := global.DB.WithContext(ctx).
Where("read_only = ?", true).
Order("family, sequence, host_ip, host_port, protocol").
Find(&policies).Error
return policies, err
}
func (r *DockerPortGuardRepo) DeleteBatch(ctx context.Context, uuids []string) error {
return global.DB.WithContext(ctx).
Where("read_only = ? AND uuid IN ?", false, uuids).
Delete(&model.DockerPortGuardPolicy{}).Error
}
func (r *DockerPortGuardRepo) UpsertBatch(ctx context.Context, policies []model.DockerPortGuardPolicy) error {
return global.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
for i := range policies {
policies[i].ReadOnly = false
if err := tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "read_only"}, {Name: "family"}, {Name: "host_ip"}, {Name: "host_port"}, {Name: "protocol"}},
DoUpdates: clause.AssignmentColumns([]string{"mode", "sources", "description", "updated_at"}),
}).Create(&policies[i]).Error; err != nil {
return err
}
}
return nil
})
}
func (r *DockerPortGuardRepo) ReplaceRuntimeReadOnly(ctx context.Context, policies []model.DockerPortGuardPolicy) error {
return global.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Where("read_only = ?", true).
Delete(&model.DockerPortGuardPolicy{}).Error; err != nil {
return err
}
if len(policies) == 0 {
return nil
}
for i := range policies {
policies[i].ReadOnly = true
}
return tx.Create(&policies).Error
})
}
-144
View File
@@ -1,144 +0,0 @@
package repo
import (
"context"
"errors"
"fmt"
"github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/constant"
"github.com/1Panel-dev/1Panel/agent/global"
"github.com/google/uuid"
"gorm.io/gorm"
)
var (
ErrFirewallRuleRevisionConflict = errors.New("firewall rule revision conflict")
ErrFirewallPersistenceInvalid = errors.New("invalid firewall persistence record")
)
type IFirewallRuleRepo interface {
Create(context.Context, *model.FirewallRule) error
GetByUUID(context.Context, string) (model.FirewallRule, error)
List(context.Context, ...DBOption) ([]model.FirewallRule, error)
UpdateWithRevision(context.Context, string, uint, map[string]interface{}) error
DeleteWithRevision(context.Context, string, uint) error
}
type FirewallRuleRepo struct {
db *gorm.DB
}
func NewIFirewallRuleRepo() IFirewallRuleRepo {
return &FirewallRuleRepo{}
}
func NewFirewallRuleRepo(db *gorm.DB) *FirewallRuleRepo {
return &FirewallRuleRepo{db: db}
}
func (r *FirewallRuleRepo) Create(ctx context.Context, rule *model.FirewallRule) error {
if err := prepareFirewallRule(rule); err != nil {
return err
}
return r.dbFor(ctx).Create(rule).Error
}
func (r *FirewallRuleRepo) GetByUUID(ctx context.Context, ruleUUID string) (model.FirewallRule, error) {
var rule model.FirewallRule
err := r.dbFor(ctx).Where("uuid = ?", ruleUUID).First(&rule).Error
return rule, err
}
func (r *FirewallRuleRepo) List(ctx context.Context, opts ...DBOption) ([]model.FirewallRule, error) {
var rules []model.FirewallRule
db := r.dbFor(ctx).Model(&model.FirewallRule{})
for _, opt := range opts {
db = opt(db)
}
return rules, db.Find(&rules).Error
}
func (r *FirewallRuleRepo) UpdateWithRevision(ctx context.Context, ruleUUID string, expectedRevision uint, updates map[string]interface{}) error {
updates = sanitizeRuleUpdates(updates)
updates["revision"] = gorm.Expr("revision + 1")
result := r.dbFor(ctx).Model(&model.FirewallRule{}).
Where("uuid = ? AND revision = ?", ruleUUID, expectedRevision).
Updates(updates)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return ErrFirewallRuleRevisionConflict
}
return nil
}
func (r *FirewallRuleRepo) DeleteWithRevision(ctx context.Context, ruleUUID string, expectedRevision uint) error {
result := r.dbFor(ctx).
Where("uuid = ? AND revision = ?", ruleUUID, expectedRevision).
Delete(&model.FirewallRule{})
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return ErrFirewallRuleRevisionConflict
}
return nil
}
func (r *FirewallRuleRepo) dbFor(ctx context.Context) *gorm.DB {
return firewallDB(ctx, r.db)
}
func firewallDB(ctx context.Context, fallback *gorm.DB) *gorm.DB {
if ctx == nil {
ctx = context.Background()
}
if tx, ok := ctx.Value(constant.DB).(*gorm.DB); ok && tx != nil {
return tx.WithContext(ctx)
}
if fallback == nil {
fallback = global.DB
}
return fallback.WithContext(ctx)
}
func prepareFirewallRule(rule *model.FirewallRule) error {
if rule == nil {
return fmt.Errorf("%w: rule is nil", ErrFirewallPersistenceInvalid)
}
if rule.Family == "" || rule.Protocol == "" || rule.Action == "" {
return fmt.Errorf("%w: atomic rule identity fields are required", ErrFirewallPersistenceInvalid)
}
if rule.UUID == "" {
rule.UUID = uuid.NewString()
}
if rule.Revision == 0 {
rule.Revision = 1
}
if rule.Origin == "" {
rule.Origin = constant.FirewallRuleOriginCreated
}
if rule.Owner == "" {
rule.Owner = constant.FirewallRuleSourceUser
}
return nil
}
func sanitizeRuleUpdates(updates map[string]interface{}) map[string]interface{} {
result := cloneUpdates(updates)
delete(result, "id")
delete(result, "uuid")
delete(result, "revision")
delete(result, "created_at")
return result
}
func cloneUpdates(updates map[string]interface{}) map[string]interface{} {
result := make(map[string]interface{}, len(updates)+1)
for key, value := range updates {
result[key] = value
}
return result
}
-36
View File
@@ -1,36 +0,0 @@
package repo
import (
"context"
"github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/global"
"gorm.io/gorm"
)
type IForwardingRuleRepo interface {
List(context.Context) ([]model.ForwardingRule, error)
ReplaceAll(context.Context, []model.ForwardingRule) error
}
type ForwardingRuleRepo struct{}
func NewIForwardingRuleRepo() IForwardingRuleRepo { return &ForwardingRuleRepo{} }
func (r *ForwardingRuleRepo) List(ctx context.Context) ([]model.ForwardingRule, error) {
var rules []model.ForwardingRule
err := global.DB.WithContext(ctx).Order("id ASC").Find(&rules).Error
return rules, err
}
func (r *ForwardingRuleRepo) ReplaceAll(ctx context.Context, rules []model.ForwardingRule) error {
return global.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&model.ForwardingRule{}).Error; err != nil {
return err
}
if len(rules) == 0 {
return nil
}
return tx.Create(&rules).Error
})
}
+103 -13
View File
@@ -1,6 +1,8 @@
package repo package repo
import ( import (
"fmt"
"strings"
"time" "time"
"github.com/1Panel-dev/1Panel/agent/app/model" "github.com/1Panel-dev/1Panel/agent/app/model"
@@ -10,11 +12,25 @@ import (
type MonitorRepo struct{} type MonitorRepo struct{}
type GPUHistoryPoint struct {
model.MonitorGPU
Bucket int64
PowerPercent *float64
MemoryPercent *float64
ProcessCount *float64
}
type IMonitorRepo interface { type IMonitorRepo interface {
CleanHost() error
CleanGPU() error
GetBase(opts ...DBOption) ([]model.MonitorBase, error) GetBase(opts ...DBOption) ([]model.MonitorBase, error)
GetGPU(opts ...DBOption) ([]model.MonitorGPU, error) CountGPU(opts ...DBOption) (int64, error)
GetGPUHistory(start time.Time, bucketSeconds int64, aggregation string, opts ...DBOption) ([]GPUHistoryPoint, error)
GetGPUDevices() ([]model.MonitorGPU, error)
GetIO(opts ...DBOption) ([]model.MonitorIO, error) GetIO(opts ...DBOption) ([]model.MonitorIO, error)
GetNetwork(opts ...DBOption) ([]model.MonitorNetwork, error) GetNetwork(opts ...DBOption) ([]model.MonitorNetwork, error)
GetIONames() ([]string, error)
GetNetworkNames() ([]string, error)
CreateMonitorBase(model model.MonitorBase) error CreateMonitorBase(model model.MonitorBase) error
BatchCreateMonitorGPU(list []model.MonitorGPU) error BatchCreateMonitorGPU(list []model.MonitorGPU) error
@@ -25,13 +41,26 @@ type IMonitorRepo interface {
DelMonitorIO(timeForDelete time.Time) error DelMonitorIO(timeForDelete time.Time) error
DelMonitorNet(timeForDelete time.Time) error DelMonitorNet(timeForDelete time.Time) error
WithByProductName(name string) DBOption WithByGPUDevice(deviceID, name string, legacy bool) DBOption
} }
func NewIMonitorRepo() IMonitorRepo { func NewIMonitorRepo() IMonitorRepo {
return &MonitorRepo{} return &MonitorRepo{}
} }
func (s *MonitorRepo) CleanHost() error {
for _, item := range []interface{}{&model.MonitorBase{}, &model.MonitorIO{}, &model.MonitorNetwork{}} {
if err := global.MonitorDB.Where("1 = 1").Delete(item).Error; err != nil {
return err
}
}
return nil
}
func (s *MonitorRepo) CleanGPU() error {
return global.GPUMonitorDB.Where("1 = 1").Delete(&model.MonitorGPU{}).Error
}
func (u *MonitorRepo) GetBase(opts ...DBOption) ([]model.MonitorBase, error) { func (u *MonitorRepo) GetBase(opts ...DBOption) ([]model.MonitorBase, error) {
var data []model.MonitorBase var data []model.MonitorBase
db := global.MonitorDB db := global.MonitorDB
@@ -59,14 +88,16 @@ func (u *MonitorRepo) GetNetwork(opts ...DBOption) ([]model.MonitorNetwork, erro
err := db.Find(&data).Error err := db.Find(&data).Error
return data, err return data, err
} }
func (u *MonitorRepo) GetGPU(opts ...DBOption) ([]model.MonitorGPU, error) { func (u *MonitorRepo) GetIONames() ([]string, error) {
var data []model.MonitorGPU var names []string
db := global.GPUMonitorDB err := global.MonitorDB.Model(&model.MonitorIO{}).Distinct().Pluck("name", &names).Error
for _, opt := range opts { return names, err
db = opt(db) }
}
err := db.Find(&data).Error func (u *MonitorRepo) GetNetworkNames() ([]string, error) {
return data, err var names []string
err := global.MonitorDB.Model(&model.MonitorNetwork{}).Distinct().Pluck("name", &names).Error
return names, err
} }
func (u *MonitorRepo) CreateMonitorBase(model model.MonitorBase) error { func (u *MonitorRepo) CreateMonitorBase(model model.MonitorBase) error {
@@ -97,8 +128,67 @@ func (s *MonitorRepo) DelMonitorGPU(timeForDelete time.Time) error {
return global.GPUMonitorDB.Where("created_at < ?", timeForDelete).Delete(&model.MonitorGPU{}).Error return global.GPUMonitorDB.Where("created_at < ?", timeForDelete).Delete(&model.MonitorGPU{}).Error
} }
func (s *MonitorRepo) WithByProductName(name string) DBOption { func (u *MonitorRepo) GetGPUDevices() ([]model.MonitorGPU, error) {
return func(g *gorm.DB) *gorm.DB { var data []model.MonitorGPU
return g.Where("product_name = ?", name) err := global.GPUMonitorDB.Model(&model.MonitorGPU{}).Select("device_id, product_name, device_type").Group("device_id, product_name, device_type").Order("product_name, device_id").Find(&data).Error
return data, err
}
func (u *MonitorRepo) WithByGPUDevice(deviceID, name string, legacy bool) DBOption {
return func(db *gorm.DB) *gorm.DB {
if deviceID != "" {
return db.Where("device_id = ?", deviceID)
}
db = db.Where("product_name = ?", name)
if legacy {
db = db.Where("device_id IS NULL OR device_id = ''")
}
return db
} }
} }
func (u *MonitorRepo) CountGPU(opts ...DBOption) (int64, error) {
db := global.GPUMonitorDB.Model(&model.MonitorGPU{})
for _, opt := range opts {
db = opt(db)
}
var count int64
err := db.Count(&count).Error
return count, err
}
func (u *MonitorRepo) GetGPUHistory(start time.Time, bucketSeconds int64, aggregation string, opts ...DBOption) ([]GPUHistoryPoint, error) {
db := global.GPUMonitorDB.Model(&model.MonitorGPU{})
for _, opt := range opts {
db = opt(db)
}
expressions := []string{
"CASE WHEN max_power_limit > 0 THEN 100.0 * power_draw / max_power_limit END",
"CASE WHEN mem_total > 0 AND mem_used IS NOT NULL THEN 100.0 * mem_used / mem_total ELSE memory_util END",
"CASE WHEN (process_status = 'ok' OR process_status IS NULL OR process_status = '') AND json_valid(processes) THEN CASE WHEN json_type(processes) = 'array' THEN json_array_length(processes) END END",
}
aliases := []string{"power_percent", "memory_percent", "process_count"}
columns := []string{"*"}
if bucketSeconds > 0 {
operation := "AVG"
if aggregation == "max" {
operation = "MAX"
}
columns = []string{fmt.Sprintf("(CAST(strftime('%%s', created_at) AS INTEGER) - %d) / %d AS bucket", start.Unix(), bucketSeconds)}
for _, column := range []string{"memory_activity", "encoder_util", "decoder_util", "jpeg_util", "ofa_util", "media_util", "compute_util", "copy_util", "hotspot_temperature", "fan_rpm", "ai_cpu_util", "ctrl_cpu_util", "ddr_used", "ddr_total", "hbm_used", "hbm_total", "ddr_bandwidth", "hbm_bandwidth", "memory_bandwidth", "media_frequency", "hugepages_used", "hugepages_total", "gpu_util", "temperature", "memory_temperature", "power_draw", "max_power_limit", "mem_used", "mem_total", "frequency", "memory_frequency", "fan_speed"} {
columns = append(columns, operation+"("+column+") AS "+column)
}
for i := range expressions {
expressions[i] = operation + "(" + expressions[i] + ")"
}
db = db.Group("bucket").Order("bucket ASC")
} else {
db = db.Order("created_at ASC, id ASC")
}
for i, expression := range expressions {
columns = append(columns, expression+" AS "+aliases[i])
}
var data []GPUHistoryPoint
err := db.Select(strings.Join(columns, ", ")).Scan(&data).Error
return data, err
}
+132
View File
@@ -0,0 +1,132 @@
package repo
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/global"
"gorm.io/gorm"
)
type VLLMMonitorRepo struct{}
type VLLMHistoryPoint struct {
model.MonitorVLLM
Bucket int64
CompletedRequests *float64
}
func (r *VLLMMonitorRepo) Create(point *model.MonitorVLLM) error {
return global.VLLMMonitorDB.Create(point).Error
}
func (r *VLLMMonitorRepo) Latest(id uint) (model.MonitorVLLM, error) {
var point model.MonitorVLLM
db := global.VLLMMonitorDB.Where("app_install_id = ?", id)
err := db.Order("created_at DESC, id DESC").First(&point).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return point, nil
}
return point, err
}
func (r *VLLMMonitorRepo) CleanTarget(id uint) error {
return global.VLLMMonitorDB.Where("app_install_id = ?", id).Delete(&model.MonitorVLLM{}).Error
}
func (r *VLLMMonitorRepo) DeleteBefore(before time.Time) error {
return global.VLLMMonitorDB.Where("created_at < ?", before).Delete(&model.MonitorVLLM{}).Error
}
func (r *VLLMMonitorRepo) Count(ctx context.Context, id uint, start, end time.Time) (int64, error) {
var count int64
db := global.VLLMMonitorDB.WithContext(ctx).Model(&model.MonitorVLLM{}).Where("app_install_id = ? AND created_at >= ? AND created_at <= ?", id, start, end)
err := db.Count(&count).Error
return count, err
}
func (r *VLLMMonitorRepo) History(ctx context.Context, id uint, start, end time.Time, seconds int64, aggregation, group string) ([]VLLMHistoryPoint, error) {
db := global.VLLMMonitorDB.WithContext(ctx).Model(&model.MonitorVLLM{}).Where("app_install_id = ? AND created_at >= ? AND created_at <= ?", id, start, end)
var metrics []string
if group == "" || group == "overview" {
metrics = append(metrics, "running", "waiting", "cache_usage", "prompt_throughput", "generation_throughput", "request_throughput")
if seconds <= 0 || aggregation == "max" {
metrics = append(metrics, "prefix_cache_hit_rate")
}
}
if seconds <= 0 || aggregation == "max" {
if group == "" || group == "latency" {
metrics = append(metrics, "queue_time", "time_to_first_token", "time_per_output_token", "request_latency", "prefill_time", "decode_time", "time_to_first_token_p50", "time_to_first_token_p90", "time_to_first_token_p95", "time_to_first_token_p99", "time_per_output_token_p50", "time_per_output_token_p90", "time_per_output_token_p95", "time_per_output_token_p99", "request_latency_p50", "request_latency_p90", "request_latency_p95", "request_latency_p99")
}
if group == "" || group == "requests" {
metrics = append(metrics, "average_generation_tokens")
}
}
var columns []string
if seconds > 0 {
operation := "AVG"
if aggregation == "max" {
operation = "MAX"
}
columns = []string{fmt.Sprintf("(CAST(strftime('%%s', created_at) AS INTEGER) - %d) / %d AS bucket", start.Unix(), seconds)}
for _, column := range metrics {
columns = append(columns, operation+"("+column+") AS "+column)
}
db = db.Group("bucket").Order("bucket ASC")
} else {
columns = append([]string{"id", "created_at", "app_install_id", "status", "sample_interval_seconds"}, metrics...)
if group == "" || group == "requests" {
columns = append(columns, "histogram_deltas", "request_deltas")
}
db = db.Order("created_at ASC, id ASC")
}
if group == "" || group == "overview" {
count := "CASE WHEN json_valid(request_deltas) THEN CASE WHEN json_extract(request_deltas, '$.seconds') > 0 THEN ROUND(request_throughput * json_extract(request_deltas, '$.seconds')) END END"
if seconds > 0 {
count = "SUM(" + count + ")"
}
columns = append(columns, count+" AS completed_requests")
}
var points []VLLMHistoryPoint
err := db.Select(strings.Join(columns, ", ")).Scan(&points).Error
return points, err
}
func (r *VLLMMonitorRepo) WalkDeltas(ctx context.Context, id uint, start, end time.Time, seconds int64, aggregation, group string, visit func(int64, string, string) error) error {
histograms, requests := "''", "''"
if group == "" || group == "requests" || (group == "latency" && aggregation != "max") {
histograms = "COALESCE(histogram_deltas, '')"
}
if group == "" || group == "requests" || (group == "overview" && aggregation != "max") {
requests = "COALESCE(request_deltas, '')"
}
if histograms == "''" && requests == "''" {
return ctx.Err()
}
bucket := fmt.Sprintf("(CAST(strftime('%%s', created_at) AS INTEGER) - %d) / %d", start.Unix(), seconds)
rows, err := global.VLLMMonitorDB.WithContext(ctx).Model(&model.MonitorVLLM{}).
Where("app_install_id = ? AND created_at >= ? AND created_at <= ?", id, start, end).
Select(bucket + ", " + histograms + ", " + requests).Rows()
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
if err := ctx.Err(); err != nil {
return err
}
var index int64
var histograms, requests string
if err := rows.Scan(&index, &histograms, &requests); err != nil {
return err
}
if err := visit(index, histograms, requests); err != nil {
return err
}
}
return rows.Err()
}
+84
View File
@@ -1,11 +1,13 @@
package repo package repo
import ( import (
"context"
"errors" "errors"
"github.com/1Panel-dev/1Panel/agent/app/model" "github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/global" "github.com/1Panel-dev/1Panel/agent/global"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause"
) )
type SettingRepo struct{} type SettingRepo struct{}
@@ -13,18 +15,26 @@ type SettingRepo struct{}
type ISettingRepo interface { type ISettingRepo interface {
GetList(opts ...DBOption) ([]model.Setting, error) GetList(opts ...DBOption) ([]model.Setting, error)
Get(opts ...DBOption) (model.Setting, error) Get(opts ...DBOption) (model.Setting, error)
GetWithContext(ctx context.Context, opts ...DBOption) (model.Setting, error)
GetValueByKey(key string) (string, error) GetValueByKey(key string) (string, error)
GetCoreValueByKey(key string) (string, error)
Create(key, value string) error Create(key, value string) error
CreateWithContext(ctx context.Context, setting *model.Setting) error
Update(key, value string) error Update(key, value string) error
UpdateIfMatch(ctx context.Context, id uint, expectedValue, value string) (int64, error)
WithByKey(key string) DBOption WithByKey(key string) DBOption
UpdateOrCreate(key, value string) error UpdateOrCreate(key, value string) error
UpdateValues(map[string]string) error
GetDescription(opts ...DBOption) (model.CommonDescription, error) GetDescription(opts ...DBOption) (model.CommonDescription, error)
GetDescriptionList(opts ...DBOption) ([]model.CommonDescription, error) GetDescriptionList(opts ...DBOption) ([]model.CommonDescription, error)
CreateDescription(data *model.CommonDescription) error CreateDescription(data *model.CommonDescription) error
SaveDescriptions(context.Context, []model.CommonDescription) error
UpdateDescription(id string, val map[string]interface{}) error UpdateDescription(id string, val map[string]interface{}) error
DelDescription(id string) error DelDescription(id string) error
DeleteDescriptions(context.Context, string, []string, bool) (int64, error)
WithDescriptionIDs(ids []string) DBOption
WithByDescriptionID(id string) DBOption WithByDescriptionID(id string) DBOption
} }
@@ -60,6 +70,22 @@ func (s *SettingRepo) Get(opts ...DBOption) (model.Setting, error) {
return settings, err return settings, err
} }
func (s *SettingRepo) GetWithContext(ctx context.Context, opts ...DBOption) (model.Setting, error) {
var setting model.Setting
err := getTx(ctx, opts...).WithContext(ctx).First(&setting).Error
return setting, err
}
func (s *SettingRepo) CreateWithContext(ctx context.Context, setting *model.Setting) error {
return getTx(ctx).WithContext(ctx).Create(setting).Error
}
func (s *SettingRepo) UpdateIfMatch(ctx context.Context, id uint, expectedValue, value string) (int64, error) {
result := getTx(ctx).WithContext(ctx).Model(&model.Setting{}).
Where("id = ? AND value = ?", id, expectedValue).Update("value", value)
return result.RowsAffected, result.Error
}
func (s *SettingRepo) GetValueByKey(key string) (string, error) { func (s *SettingRepo) GetValueByKey(key string) (string, error) {
var setting model.Setting var setting model.Setting
if err := global.DB.Model(&model.Setting{}).Where("key = ?", key).First(&setting).Error; err != nil { if err := global.DB.Model(&model.Setting{}).Where("key = ?", key).First(&setting).Error; err != nil {
@@ -68,6 +94,17 @@ func (s *SettingRepo) GetValueByKey(key string) (string, error) {
return setting.Value, nil return setting.Value, nil
} }
func (s *SettingRepo) GetCoreValueByKey(key string) (string, error) {
if global.CoreDB == nil {
return "", errors.New("core database is unavailable")
}
var setting model.Setting
if err := global.CoreDB.Where("key = ?", key).First(&setting).Error; err != nil {
return "", err
}
return setting.Value, nil
}
func (s *SettingRepo) WithByKey(key string) DBOption { func (s *SettingRepo) WithByKey(key string) DBOption {
return func(g *gorm.DB) *gorm.DB { return func(g *gorm.DB) *gorm.DB {
return g.Where("key = ?", key) return g.Where("key = ?", key)
@@ -90,6 +127,25 @@ func (s *SettingRepo) UpdateOrCreate(key, value string) error {
return global.DB.Model(&setting).UpdateColumn("value", value).Error return global.DB.Model(&setting).UpdateColumn("value", value).Error
} }
func (s *SettingRepo) UpdateValues(values map[string]string) error {
return global.DB.Transaction(func(tx *gorm.DB) error {
for key, value := range values {
var setting model.Setting
err := tx.Where("key = ?", key).First(&setting).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
if err := tx.Create(&model.Setting{Key: key, Value: value}).Error; err != nil {
return err
}
} else if err != nil {
return err
} else if err := tx.Model(&setting).UpdateColumn("value", value).Error; err != nil {
return err
}
}
return nil
})
}
func (s *SettingRepo) GetDescriptionList(opts ...DBOption) ([]model.CommonDescription, error) { func (s *SettingRepo) GetDescriptionList(opts ...DBOption) ([]model.CommonDescription, error) {
var lists []model.CommonDescription var lists []model.CommonDescription
db := global.DB.Model(&model.CommonDescription{}) db := global.DB.Model(&model.CommonDescription{})
@@ -111,14 +167,42 @@ func (s *SettingRepo) GetDescription(opts ...DBOption) (model.CommonDescription,
func (s *SettingRepo) CreateDescription(data *model.CommonDescription) error { func (s *SettingRepo) CreateDescription(data *model.CommonDescription) error {
return global.DB.Create(data).Error return global.DB.Create(data).Error
} }
func (s *SettingRepo) SaveDescriptions(ctx context.Context, descriptions []model.CommonDescription) error {
return global.DB.WithContext(ctx).Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "id"}},
DoUpdates: clause.AssignmentColumns([]string{"description"}),
}).CreateInBatches(&descriptions, 100).Error
}
func (s *SettingRepo) UpdateDescription(id string, val map[string]interface{}) error { func (s *SettingRepo) UpdateDescription(id string, val map[string]interface{}) error {
return global.DB.Model(&model.CommonDescription{}).Where("id = ?", id).Updates(val).Error return global.DB.Model(&model.CommonDescription{}).Where("id = ?", id).Updates(val).Error
} }
func (s *SettingRepo) DelDescription(id string) error { func (s *SettingRepo) DelDescription(id string) error {
return global.DB.Where("id = ?", id).Delete(&model.CommonDescription{}).Error return global.DB.Where("id = ?", id).Delete(&model.CommonDescription{}).Error
} }
func (s *SettingRepo) DeleteDescriptions(ctx context.Context, kind string, ids []string, emptyOnly bool) (int64, error) {
var deleted int64
for start := 0; start < len(ids); start += 500 {
query := global.DB.WithContext(ctx).Where("type = ? AND id IN ?", kind, ids[start:min(start+500, len(ids))])
if emptyOnly {
query = query.Where("description = ? AND is_pinned = ?", "", false)
}
result := query.Delete(&model.CommonDescription{})
deleted += result.RowsAffected
if result.Error != nil {
return deleted, result.Error
}
}
return deleted, nil
}
func (s *SettingRepo) WithByDescriptionID(id string) DBOption { func (s *SettingRepo) WithByDescriptionID(id string) DBOption {
return func(g *gorm.DB) *gorm.DB { return func(g *gorm.DB) *gorm.DB {
return g.Where("id = ?", id) return g.Where("id = ?", id)
} }
} }
func (s *SettingRepo) WithDescriptionIDs(ids []string) DBOption {
return func(db *gorm.DB) *gorm.DB { return db.Where("id IN ?", ids) }
}
+22 -6
View File
@@ -2,6 +2,7 @@ package repo
import ( import (
"context" "context"
"time"
"github.com/1Panel-dev/1Panel/agent/constant" "github.com/1Panel-dev/1Panel/agent/constant"
"github.com/1Panel-dev/1Panel/agent/global" "github.com/1Panel-dev/1Panel/agent/global"
@@ -18,8 +19,8 @@ type ITaskRepo interface {
GetFirst(opts ...DBOption) (model.Task, error) GetFirst(opts ...DBOption) (model.Task, error)
Page(page, size int, opts ...DBOption) (int64, []model.Task, error) Page(page, size int, opts ...DBOption) (int64, []model.Task, error)
Update(ctx context.Context, task *model.Task) error Update(ctx context.Context, task *model.Task) error
UpdateRunningTaskToFailed() error UpdateRunningTaskToFailed(opts ...DBOption) error
CountExecutingTask() (int64, error) CountExecutingTask(opts ...DBOption) (int64, error)
Delete(opts ...DBOption) error Delete(opts ...DBOption) error
DeleteAll() error DeleteAll() error
@@ -28,6 +29,7 @@ type ITaskRepo interface {
WithResourceID(id uint) DBOption WithResourceID(id uint) DBOption
WithOperate(taskOperate string) DBOption WithOperate(taskOperate string) DBOption
WithByStatus(status string) DBOption WithByStatus(status string) DBOption
WithoutScopes(scopes ...string) DBOption
} }
func NewITaskRepo() ITaskRepo { func NewITaskRepo() ITaskRepo {
@@ -109,16 +111,30 @@ func (t TaskRepo) Update(ctx context.Context, task *model.Task) error {
return getTaskTx(ctx).Save(&task).Error return getTaskTx(ctx).Save(&task).Error
} }
func (t TaskRepo) UpdateRunningTaskToFailed() error { func (t TaskRepo) UpdateRunningTaskToFailed(opts ...DBOption) error {
return getTaskDb(t.WithByStatus(constant.StatusExecuting)).Model(&model.Task{}).Updates(map[string]interface{}{"status": constant.StatusFailed, "error_msg": "1Panel restart causes failure"}).Error opts = append(opts, t.WithByStatus(constant.StatusExecuting))
return getTaskDb(opts...).Model(&model.Task{}).Updates(map[string]interface{}{
"status": constant.StatusFailed, "error_msg": constant.InterruptedMsg, "end_at": time.Now(),
}).Error
} }
func (t TaskRepo) CountExecutingTask() (int64, error) { func (t TaskRepo) CountExecutingTask(opts ...DBOption) (int64, error) {
var count int64 var count int64
err := getTaskDb(t.WithByStatus(constant.StatusExecuting)).Model(&model.Task{}).Count(&count).Error opts = append(opts, t.WithByStatus(constant.StatusExecuting))
err := getTaskDb(opts...).Model(&model.Task{}).Count(&count).Error
return count, err return count, err
} }
func (t TaskRepo) WithoutScopes(scopes ...string) DBOption {
excluded := append([]string(nil), scopes...)
return func(db *gorm.DB) *gorm.DB {
if len(excluded) == 0 {
return db
}
return db.Where("type IS NULL OR type NOT IN ?", excluded)
}
}
func (t TaskRepo) Delete(opts ...DBOption) error { func (t TaskRepo) Delete(opts ...DBOption) error {
db := global.TaskDB db := global.TaskDB
for _, opt := range opts { for _, opt := range opts {
+60 -6
View File
@@ -88,6 +88,7 @@ type IAgentService interface {
GetFeishuConfig(req dto.AgentFeishuConfigReq) (*dto.AgentFeishuConfig, error) GetFeishuConfig(req dto.AgentFeishuConfigReq) (*dto.AgentFeishuConfig, error)
UpdateFeishuConfig(req dto.AgentFeishuConfigUpdateReq) error UpdateFeishuConfig(req dto.AgentFeishuConfigUpdateReq) error
SwitchFeishuPlugin(req dto.AgentFeishuSwitchReq) error
GetTelegramConfig(req dto.AgentTelegramConfigReq) (*dto.AgentTelegramConfig, error) GetTelegramConfig(req dto.AgentTelegramConfigReq) (*dto.AgentTelegramConfig, error)
UpdateTelegramConfig(req dto.AgentTelegramConfigUpdateReq) error UpdateTelegramConfig(req dto.AgentTelegramConfigUpdateReq) error
GetDiscordConfig(req dto.AgentIDReq) (*dto.AgentDiscordConfig, error) GetDiscordConfig(req dto.AgentIDReq) (*dto.AgentDiscordConfig, error)
@@ -142,6 +143,9 @@ const (
func (a AgentService) Create(req dto.AgentCreateReq) (*dto.AgentItem, error) { func (a AgentService) Create(req dto.AgentCreateReq) (*dto.AgentItem, error) {
agentType := req.AgentType agentType := req.AgentType
if err := validateNativeAgentCreate(req); err != nil {
return nil, err
}
if err := checkPortExist(req.WebUIPort); err != nil { if err := checkPortExist(req.WebUIPort); err != nil {
return nil, err return nil, err
} }
@@ -237,6 +241,8 @@ func (a AgentService) Create(req dto.AgentCreateReq) (*dto.AgentItem, error) {
} }
} else if agentType == constant.AppCopaw { } else if agentType == constant.AppCopaw {
dashboardAuth = normalizeAgentDashboardAuth(req.DashboardUsername, req.DashboardPassword) dashboardAuth = normalizeAgentDashboardAuth(req.DashboardUsername, req.DashboardPassword)
} else if agentType == constant.AppDeepSeekHarness || agentType == constant.AppOctop {
dashboardAuth = agentDashboardAuth{Username: req.DashboardUsername, Password: req.DashboardPassword}
} }
params := map[string]interface{}{ params := map[string]interface{}{
@@ -245,6 +251,12 @@ func (a AgentService) Create(req dto.AgentCreateReq) (*dto.AgentItem, error) {
constant.HostIP: "", constant.HostIP: "",
} }
setAgentWebUIParams(params, agentType, detail.Version, req.WebUIPort) setAgentWebUIParams(params, agentType, detail.Version, req.WebUIPort)
if agentType == constant.AppDeepSeekHarness {
params["HTTPS_ACCESS_HOST"] = req.AccessHost
}
if agentType == constant.AppOctop {
params["OCTOP_LOG_LEVEL"] = "info"
}
if agentType == constant.AppOpenclaw { if agentType == constant.AppOpenclaw {
if allowedOrigin := firstAllowedOrigin(allowedOrigins); allowedOrigin != "" { if allowedOrigin := firstAllowedOrigin(allowedOrigins); allowedOrigin != "" {
params["ALLOWED_ORIGIN"] = allowedOrigin params["ALLOWED_ORIGIN"] = allowedOrigin
@@ -738,7 +750,7 @@ func requiresAgentAccount(agentType string) bool {
} }
func setAgentWebUIParams(params map[string]interface{}, agentType, appVersion string, webUIPort int) { func setAgentWebUIParams(params map[string]interface{}, agentType, appVersion string, webUIPort int) {
if agentType == constant.AppOpenclaw && isOpenclawHTTPSWindowVersion(appVersion) { if agentType == constant.AppDeepSeekHarness || agentType == constant.AppOpenclaw && isOpenclawHTTPSWindowVersion(appVersion) {
params["PANEL_APP_PORT_HTTPS"] = webUIPort params["PANEL_APP_PORT_HTTPS"] = webUIPort
return return
} }
@@ -1044,7 +1056,7 @@ func (a AgentService) CreateAccount(req dto.AgentAccountCreateReq) error {
return err return err
} }
validateAvailability := req.ValidateAvailability == nil || *req.ValidateAvailability validateAvailability := req.ValidateAvailability == nil || *req.ValidateAvailability
resolvedInput, err := resolveAgentAccountInput(provider, req.APIType, req.AuthMode, req.APIKey, req.BaseURL, verifyModel, validateAvailability) resolvedInput, err := resolveAgentAccountInput(provider, req.APIType, req.AuthMode, req.APIKey, req.BaseURL, verifyModel, validateAvailability, false)
if err != nil { if err != nil {
return err return err
} }
@@ -1097,12 +1109,15 @@ func (a AgentService) UpdateAccount(req dto.AgentAccountUpdateReq) error {
if strings.TrimSpace(requestedVerifyModel) == "" { if strings.TrimSpace(requestedVerifyModel) == "" {
requestedVerifyModel = account.VerifyModel requestedVerifyModel = account.VerifyModel
} }
verifyModel, err := resolveAgentAccountVerifyModel(provider, requestedVerifyModel, models) verifyModel := ""
if account.AppInstallID == 0 || len(models) > 0 {
verifyModel, err = resolveAgentAccountVerifyModel(provider, requestedVerifyModel, models)
if err != nil { if err != nil {
return err return err
} }
}
validateAvailability := req.ValidateAvailability == nil || *req.ValidateAvailability validateAvailability := req.ValidateAvailability == nil || *req.ValidateAvailability
resolvedInput, err := resolveAgentAccountInput(provider, req.APIType, req.AuthMode, req.APIKey, req.BaseURL, verifyModel, validateAvailability) resolvedInput, err := resolveAgentAccountInput(provider, req.APIType, req.AuthMode, req.APIKey, req.BaseURL, verifyModel, validateAvailability, account.AppInstallID != 0 && len(models) == 0)
if err != nil { if err != nil {
return err return err
} }
@@ -1116,7 +1131,7 @@ func (a AgentService) UpdateAccount(req dto.AgentAccountUpdateReq) error {
account.Remark = req.Remark account.Remark = req.Remark
account.Verified = true account.Verified = true
if err := global.DB.Save(account).Error; err != nil { if err := agentAccountRepo.Save(account); err != nil {
return err return err
} }
terminalai.InvalidateTerminalRuntimeCache() terminalai.InvalidateTerminalRuntimeCache()
@@ -1147,13 +1162,34 @@ func (a AgentService) PageAccounts(req dto.AgentAccountSearch) (int64, []dto.Age
if err != nil { if err != nil {
return 0, nil, err return 0, nil, err
} }
installIDs := make([]uint, 0, len(list))
for _, account := range list {
if account.AppInstallID != 0 {
installIDs = append(installIDs, account.AppInstallID)
}
}
localInstalls := make(map[uint]bool, len(installIDs))
if len(installIDs) > 0 {
installs, err := appInstallRepo.ListBy(context.Background(), repo.WithByIDs(installIDs))
if err != nil {
return 0, nil, err
}
for _, install := range installs {
localInstalls[install.ID] = true
}
}
items := make([]dto.AgentAccountInfo, 0, len(list)) items := make([]dto.AgentAccountInfo, 0, len(list))
for _, item := range list { for _, item := range list {
apiKey := "" apiKey := ""
if item.RememberAPIKey { if item.RememberAPIKey {
apiKey = item.APIKey apiKey = item.APIKey
} }
appInstallID := uint(0)
if localInstalls[item.AppInstallID] {
appInstallID = item.AppInstallID
}
items = append(items, dto.AgentAccountInfo{ items = append(items, dto.AgentAccountInfo{
AppInstallID: appInstallID,
ID: item.ID, ID: item.ID,
MasterAccountID: item.MasterAccountID, MasterAccountID: item.MasterAccountID,
Provider: item.Provider, Provider: item.Provider,
@@ -1351,13 +1387,27 @@ func (a AgentService) SyncAgentsByAccount(account *model.AgentAccount) error {
} }
func (a AgentService) VerifyAccount(req dto.AgentAccountVerifyReq) error { func (a AgentService) VerifyAccount(req dto.AgentAccountVerifyReq) error {
_, err := resolveAgentAccountInput(req.Provider, req.APIType, req.AuthMode, req.APIKey, req.BaseURL, req.Model, true) _, err := resolveAgentAccountInput(req.Provider, req.APIType, req.AuthMode, req.APIKey, req.BaseURL, req.Model, true, false)
return err return err
} }
func (a AgentService) DeleteAccount(req dto.AgentAccountDeleteReq) error { func (a AgentService) DeleteAccount(req dto.AgentAccountDeleteReq) error {
agentAccountMutationMu.Lock() agentAccountMutationMu.Lock()
defer agentAccountMutationMu.Unlock() defer agentAccountMutationMu.Unlock()
account, err := agentAccountRepo.GetFirst(repo.WithByID(req.ID))
if err != nil {
return err
}
if account.AppInstallID != 0 && account.Provider != "ollama" {
_, err := appInstallRepo.GetFirst(repo.WithByID(account.AppInstallID))
if err == nil {
return buserr.WithDetail("ErrInvalidParams", "Delete the local inference engine instead", nil)
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
}
if err := global.DB.Transaction(func(tx *gorm.DB) error { if err := global.DB.Transaction(func(tx *gorm.DB) error {
var agentCount int64 var agentCount int64
if err := tx.Model(&model.Agent{}).Where("account_id = ?", req.ID).Count(&agentCount).Error; err != nil { if err := tx.Model(&model.Agent{}).Where("account_id = ?", req.ID).Count(&agentCount).Error; err != nil {
@@ -1542,6 +1592,10 @@ func (a AgentService) UpdateConfigFile(req dto.AgentConfigFileUpdateReq) error {
if err != nil { if err != nil {
return err return err
} }
if _, busy := feishuSwitches.LoadOrStore(agent.ConfigPath, true); busy {
return buserr.New("TaskIsExecuting")
}
defer feishuSwitches.Delete(agent.ConfigPath)
if err := validateAgentConfigFileContent(agent.AgentType, req.Content); err != nil { if err := validateAgentConfigFileContent(agent.AgentType, req.Content); err != nil {
return err return err
} }
+38 -3
View File
@@ -45,9 +45,29 @@ func (a AgentService) GetFeishuConfig(req dto.AgentFeishuConfigReq) (*dto.AgentF
if err != nil { if err != nil {
return nil, err return nil, err
} }
if agent.AgentType != constant.AppOpenclaw || !isOpenclawBundledFeishuVersion(install.Version) {
result := extractFeishuConfig(conf) result := extractFeishuConfig(conf)
installed, _ := checkPluginInstalled(install.GetPath(), "feishu") result.Installed, _ = checkPluginInstalled(install.GetPath(), "feishu")
result.Installed = installed return &result, nil
}
legacy, configured, err := classifyLegacyFeishu(conf, install.GetPath())
if err != nil {
return nil, err
}
result := extractFeishuConfig(conf)
if !legacy {
result = extractBundledFeishuConfig(conf)
}
result.BuiltinManaged = true
result.BuiltinAvailable = hasBundledFeishu(install.ContainerName)
result.Installed = result.BuiltinAvailable
result.Legacy, result.LegacyConfigured = legacy, configured
if legacy {
result.GroupSessionScope = "group"
if result.ThreadSession {
result.GroupSessionScope = "group_topic"
}
}
return &result, nil return &result, nil
} }
@@ -72,6 +92,9 @@ func (a AgentService) UpdateFeishuConfig(req dto.AgentFeishuConfigUpdateReq) err
}) })
}) })
} }
if agent.AgentType == constant.AppOpenclaw && isOpenclawBundledFeishuVersion(install.Version) {
return saveBundledFeishuConfig(agent, install, req)
}
return a.mutateAgentConfig(req.AgentID, func(_ *model.Agent, _ *model.AppInstall, conf map[string]interface{}) error { return a.mutateAgentConfig(req.AgentID, func(_ *model.Agent, _ *model.AppInstall, conf map[string]interface{}) error {
config := dto.AgentFeishuConfig{ config := dto.AgentFeishuConfig{
Enabled: req.Enabled, Enabled: req.Enabled,
@@ -358,6 +381,9 @@ func (a AgentService) InstallPlugin(req dto.AgentPluginInstallReq) error {
if err != nil { if err != nil {
return err return err
} }
if req.Type == "feishu" && agent.AgentType == constant.AppOpenclaw && isOpenclawBundledFeishuVersion(install.Version) {
return buserr.New("ErrFeishuManaged")
}
if err := task.CheckScopeTaskIsExecuting(task.TaskScopeAI, req.AgentID); err != nil { if err := task.CheckScopeTaskIsExecuting(task.TaskScopeAI, req.AgentID); err != nil {
return err return err
} }
@@ -403,6 +429,9 @@ func (a AgentService) UpgradePlugin(req dto.AgentPluginUpgradeReq) error {
if err != nil { if err != nil {
return err return err
} }
if req.Type == "feishu" && agent.AgentType == constant.AppOpenclaw && isOpenclawBundledFeishuVersion(install.Version) {
return buserr.New("ErrFeishuManaged")
}
if err := task.CheckScopeTaskIsExecuting(task.TaskScopeAI, req.AgentID); err != nil { if err := task.CheckScopeTaskIsExecuting(task.TaskScopeAI, req.AgentID); err != nil {
return err return err
} }
@@ -443,6 +472,9 @@ func (a AgentService) UninstallPlugin(req dto.AgentPluginUninstallReq) error {
if err != nil { if err != nil {
return err return err
} }
if req.Type == "feishu" && agent.AgentType == constant.AppOpenclaw && isOpenclawBundledFeishuVersion(install.Version) {
return buserr.New("ErrFeishuManaged")
}
if err := task.CheckScopeTaskIsExecuting(task.TaskScopeAI, req.AgentID); err != nil { if err := task.CheckScopeTaskIsExecuting(task.TaskScopeAI, req.AgentID); err != nil {
return err return err
} }
@@ -538,10 +570,13 @@ func (a AgentService) DeleteChannelConfig(req dto.AgentChannelDeleteReq) error {
} }
func (a AgentService) CheckPlugin(req dto.AgentPluginCheckReq) (*dto.AgentPluginStatus, error) { func (a AgentService) CheckPlugin(req dto.AgentPluginCheckReq) (*dto.AgentPluginStatus, error) {
_, install, err := a.loadAgentAndInstall(req.AgentID) agent, install, err := a.loadAgentAndInstall(req.AgentID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
if req.Type == "feishu" && agent.AgentType == constant.AppOpenclaw && isOpenclawBundledFeishuVersion(install.Version) {
return &dto.AgentPluginStatus{Installed: hasBundledFeishu(install.ContainerName)}, nil
}
installed, err := checkPluginInstalled(install.GetPath(), req.Type) installed, err := checkPluginInstalled(install.GetPath(), req.Type)
if err != nil { if err != nil {
return nil, err return nil, err
+677
View File
@@ -0,0 +1,677 @@
package service
import (
"bytes"
"encoding/json"
"errors"
"os"
"path/filepath"
"slices"
"strings"
"sync"
"syscall"
"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/i18n"
"github.com/1Panel-dev/1Panel/agent/utils/cmd"
"github.com/1Panel-dev/1Panel/agent/utils/common"
"github.com/1Panel-dev/1Panel/agent/utils/compose"
)
// Keep panel configuration writes out of the backup/activation/recovery window.
var feishuSwitches sync.Map
func isOpenclawBundledFeishuVersion(version string) bool {
return common.CompareAppVersion(version, "2026.10.7")
}
func hasBundledFeishu(container string) bool {
image, err := feishuContainerImage(container)
if err != nil {
return false
}
_, err = bundledFeishuImage(image)
return err == nil
}
func usesBundledFeishu(conf map[string]interface{}) bool {
f := getChannelConfig(conf, "feishu")
for _, key := range []string{"threadSession", "replyMode"} {
if _, old := f[key]; old {
return false
}
}
if _, old := f["streaming"].(bool); old {
return false
}
entries := childMap(childMap(conf, "plugins"), "entries")
if extractBoolValue(childMap(entries, "openclaw-lark")["enabled"], false) {
return false
}
_, streaming := f["streaming"].(map[string]interface{})
_, selected := entries["feishu"]
return selected && (extractBoolValue(childMap(entries, "feishu")["enabled"], false) || f["groupSessionScope"] != nil || streaming)
}
func hasLegacyFeishu(conf map[string]interface{}, installPath string) bool {
legacy, _, err := classifyLegacyFeishu(conf, installPath)
return err != nil || legacy
}
func selectBundledFeishu(conf map[string]interface{}, enabled bool) {
plugins := ensureChildMap(conf, "plugins")
entries := ensureChildMap(plugins, "entries")
if legacy, ok := entries["openclaw-lark"]; ok {
if entry, ok := legacy.(map[string]interface{}); ok {
entry["enabled"] = false
}
}
ensureChildMap(entries, "feishu")["enabled"] = enabled
// Preserve an absent allowlist; creating one would disable unrelated plugins.
if allow, ok := plugins["allow"]; ok && len(extractStringList(allow)) > 0 {
ids := slices.DeleteFunc(extractStringList(allow), func(id string) bool { return id == "openclaw-lark" })
if !slices.Contains(ids, "feishu") {
ids = append(ids, "feishu")
}
plugins["allow"] = ids
}
if deny, ok := plugins["deny"]; ok {
plugins["deny"] = slices.DeleteFunc(extractStringList(deny), func(id string) bool { return id == "feishu" })
}
}
func feishuConfigError(field string) error { return buserr.WithDetail("ErrFeishuConfig", field, nil) }
func convertLegacyFeishu(conf map[string]interface{}, req dto.AgentFeishuSwitchReq) error {
f := getChannelConfig(conf, "feishu")
if len(f) == 0 {
if raw, exists := childMap(conf, "channels")["feishu"]; exists && raw != nil {
if _, ok := raw.(map[string]interface{}); !ok {
return feishuConfigError("channels.feishu")
}
}
f = ensureChildMap(ensureChildMap(conf, "channels"), "feishu")
f["enabled"], f["dmPolicy"], f["groupPolicy"] = false, "pairing", "allowlist"
}
if err := checkLegacyFeishuFields(f, "channels.feishu", false); err != nil {
return err
}
if err := convertFeishuAccess(f, "channels.feishu"); err != nil {
return err
}
delete(f, "threadSession")
delete(f, "replyMode")
f["streaming"] = map[string]interface{}{"mode": "off"}
if req.Streaming {
f["streaming"] = map[string]interface{}{"mode": "partial"}
}
f["groupSessionScope"] = req.GroupSessionScope
f["requireMention"] = req.RequireMention == "true"
delete(f, "topicSessionMode")
// Preserve legacy defaults explicitly; bundled defaults are more restrictive.
if _, ok := f["groupPolicy"]; !ok {
f["groupPolicy"] = "open"
}
entries := ensureChildMap(ensureChildMap(conf, "plugins"), "entries")
ensureChildMap(entries, "openclaw-lark")["enabled"] = false
selectBundledFeishu(conf, extractBoolValue(f["enabled"], true))
return nil
}
// Limit automatic conversion to the shared channel fields; custom plugin options require review.
func checkLegacyFeishuFields(f map[string]interface{}, field string, group bool) error {
allowed := strings.Fields("enabled name appId appSecret encryptKey verificationToken domain connectionMode webhookPath defaultAccount dmPolicy allowFrom groupPolicy groupAllowFrom groupSenderAllowFrom requireMention accounts groups streaming threadSession replyMode groupSessionScope topicSessionMode replyInThread")
if group {
allowed = strings.Fields("enabled requireMention allowFrom groupSessionScope topicSessionMode replyInThread")
}
for _, key := range sortedChildKeys(f) {
if !slices.Contains(allowed, key) {
return feishuConfigError(field + "." + key)
}
}
if enabled, exists := f["enabled"]; exists {
if _, ok := enabled.(bool); !ok {
return feishuConfigError(field + ".enabled")
}
}
for _, key := range []string{"dmPolicy", "groupPolicy"} {
if raw, exists := f[key]; exists {
value, ok := raw.(string)
options := []string{"open", "allowlist", "disabled"}
if key == "dmPolicy" {
options = append(options, "pairing")
}
if !ok || !slices.Contains(options, value) {
return feishuConfigError(field + "." + key)
}
}
}
for _, key := range []string{"allowFrom", "groupSenderAllowFrom"} {
if raw, exists := f[key]; exists {
values, ok := raw.([]interface{})
if !ok {
return feishuConfigError(field + "." + key)
}
for _, value := range values {
if _, ok := value.(string); !ok {
return feishuConfigError(field + "." + key)
}
}
}
}
for _, section := range []string{"accounts", "groups"} {
raw, exists := f[section]
if !exists {
continue
}
entries, ok := raw.(map[string]interface{})
if !ok {
return feishuConfigError(field + "." + section)
}
for _, id := range sortedChildKeys(entries) {
entry, ok := entries[id].(map[string]interface{})
name := field + "." + section + "." + id
if !ok {
return feishuConfigError(name)
}
if err := checkLegacyFeishuFields(entry, name, section == "groups"); err != nil {
return err
}
}
}
return nil
}
func convertFeishuAccess(f map[string]interface{}, field string) error {
if raw, ok := f["groupAllowFrom"]; ok {
values, ok := raw.([]interface{})
if !ok {
return feishuConfigError(field + ".groupAllowFrom")
}
groups, senders := []string{}, []string{}
for _, value := range values {
id, ok := value.(string)
if !ok {
return feishuConfigError(field + ".groupAllowFrom")
}
switch {
case strings.HasPrefix(id, "oc_"):
groups = append(groups, id)
case strings.HasPrefix(id, "ou_"):
senders = append(senders, id)
default:
return feishuConfigError(field + ".groupAllowFrom")
}
}
// A sender-only legacy allowlist has different group admission semantics.
if len(senders) > 0 && (len(groups) == 0 || f["groupSenderAllowFrom"] != nil) {
return feishuConfigError(field + ".groupAllowFrom")
}
f["groupAllowFrom"] = groups
if len(senders) > 0 {
f["groupSenderAllowFrom"] = senders
}
}
for _, section := range []string{"accounts", "groups"} {
for id, value := range childMap(f, section) {
entry, ok := value.(map[string]interface{})
name := field + "." + section + "." + id
if !ok {
return feishuConfigError(name)
}
// Account/group overrides must be resolved explicitly, not flattened.
for _, key := range []string{"threadSession", "replyMode"} {
if _, ok := entry[key]; ok {
return feishuConfigError(name + "." + key)
}
}
if mention, ok := entry["requireMention"]; ok {
if _, ok := mention.(bool); !ok {
return feishuConfigError(name + ".requireMention")
}
}
if raw, exists := entry["streaming"]; exists {
streaming, ok := raw.(bool)
if !ok || streaming {
return feishuConfigError(name + ".streaming")
}
entry["streaming"] = map[string]interface{}{"mode": "off"}
}
if err := convertFeishuAccess(entry, name); err != nil {
return err
}
}
}
return nil
}
func extractBundledFeishuConfig(conf map[string]interface{}) dto.AgentFeishuConfig {
result := extractFeishuConfig(conf)
f := getChannelConfig(conf, "feishu")
result.Enabled = extractBoolValue(f["enabled"], true) && extractBoolValue(childMap(childMap(childMap(conf, "plugins"), "entries"), "feishu")["enabled"], true)
result.GroupSessionScope = extractStringValue(f["groupSessionScope"])
if result.GroupSessionScope == "" {
result.GroupSessionScope = "group"
}
result.Streaming = extractStringValue(childMap(f, "streaming")["mode"]) != "off"
result.GroupPolicy = extractStringValue(f["groupPolicy"])
if result.GroupPolicy == "" {
result.GroupPolicy = "allowlist"
}
if len(f) == 0 {
result.Streaming = false
result.RequireMention = "true"
} else if _, ok := f["requireMention"]; !ok {
result.RequireMention = "open"
}
accounts := childMap(f, "accounts")
ids := sortedChildKeys(accounts)
if (f["appId"] != nil || f["appSecret"] != nil) && !slices.Contains(ids, "default") {
ids = append([]string{"default"}, ids...)
}
result.Bots = []dto.AgentFeishuBot{}
for _, id := range ids {
effective := make(map[string]interface{}, len(f))
for k, v := range f {
effective[k] = v
}
for k, v := range childMap(accounts, id) {
effective[k] = v
}
bot := defaultFeishuBot()
bot.AccountID, bot.IsDefault = id, id == "default"
bot.Name = extractDisplayName(childMap(accounts, id), "", id)
bot.Enabled = extractBoolValue(childMap(accounts, id)["enabled"], true)
bot.AppID, bot.AppSecret = extractStringValue(effective["appId"]), extractStringValue(effective["appSecret"])
bot.DmPolicy = extractStringValue(effective["dmPolicy"])
if bot.DmPolicy == "" {
bot.DmPolicy = "pairing"
}
bot.AllowFrom = extractStringList(effective["allowFrom"])
result.Bots = append(result.Bots, bot)
}
return result
}
func setBundledFeishuConfig(conf map[string]interface{}, req dto.AgentFeishuConfigUpdateReq) error {
if !slices.Contains([]string{"group", "group_sender", "group_topic", "group_topic_sender"}, req.GroupSessionScope) {
return feishuConfigError("groupSessionScope")
}
f := ensureChildMap(ensureChildMap(conf, "channels"), "feishu")
accounts := childMap(f, "accounts")
seen := map[string]bool{}
appIDs := map[string]bool{}
for _, bot := range req.Bots {
if bot.AccountID == "" || bot.AccountID != strings.ToLower(strings.TrimSpace(bot.AccountID)) || seen[bot.AccountID] || appIDs[bot.AppID] || bot.AppID == "" || !slices.Contains([]string{"pairing", "open", "allowlist", "disabled"}, bot.DmPolicy) || (bot.DmPolicy == "allowlist" && len(bot.AllowFrom) == 0) {
return feishuConfigError("accounts")
}
seen[bot.AccountID] = true
appIDs[bot.AppID] = true
entry := childMap(accounts, bot.AccountID)
if _, exists := entry["appSecret"]; !exists && bot.AppSecret == "" {
entry["appSecret"] = f["appSecret"]
}
// SecretRef credentials cannot be represented by the form: keep them intact.
if _, secretRef := entry["appSecret"].(map[string]interface{}); !secretRef && bot.AppSecret == "" {
return feishuConfigError("accounts." + bot.AccountID + ".appSecret")
}
entry["appId"], entry["name"], entry["enabled"], entry["dmPolicy"] = bot.AppID, bot.Name, bot.Enabled, bot.DmPolicy
if bot.AppSecret != "" {
entry["appSecret"] = bot.AppSecret
}
switch bot.DmPolicy {
case "open":
entry["allowFrom"] = []string{"*"}
case "allowlist":
entry["allowFrom"] = bot.AllowFrom
default:
if len(bot.AllowFrom) > 0 {
entry["allowFrom"] = bot.AllowFrom
} else {
entry["allowFrom"] = []string{}
}
}
accounts[bot.AccountID] = entry
}
for id := range accounts {
if !seen[id] {
delete(accounts, id)
}
}
if !seen["default"] {
delete(f, "appId")
delete(f, "appSecret")
}
if id := extractStringValue(f["defaultAccount"]); id != "" && !seen[id] {
return feishuConfigError("defaultAccount")
}
if len(accounts) > 0 {
f["accounts"] = accounts
} else {
delete(f, "accounts")
}
f["enabled"], f["groupSessionScope"], f["groupPolicy"] = req.Enabled, req.GroupSessionScope, req.GroupPolicy
if req.RequireMention == "open" {
delete(f, "requireMention")
} else {
f["requireMention"] = req.RequireMention == "true"
}
if req.GroupPolicy == "allowlist" {
f["groupAllowFrom"] = req.GroupAllowFrom
} else {
delete(f, "groupAllowFrom")
}
streaming := ensureChildMap(f, "streaming")
streaming["mode"] = "off"
if req.Streaming {
streaming["mode"] = "partial"
}
selectBundledFeishu(conf, req.Enabled && hasEnabledBots(req.Bots))
return nil
}
// Write beside the original so validation and rename preserve the mounted filesystem and owner.
func stageFeishuConfig(configPath string, payload []byte) (string, error) {
info, err := os.Lstat(configPath)
if err != nil {
return "", err
}
if !info.Mode().IsRegular() {
return "", feishuConfigError("openclaw.json")
}
f, err := os.CreateTemp(filepath.Dir(configPath), ".feishu-*.json")
if err != nil {
return "", err
}
name := f.Name()
defer func() {
f.Close()
if err != nil {
os.Remove(name)
}
}()
if stat, ok := info.Sys().(*syscall.Stat_t); ok {
err = f.Chown(int(stat.Uid), int(stat.Gid))
}
if err == nil {
err = f.Chmod(info.Mode().Perm())
}
if err == nil {
_, err = f.Write(payload)
}
if err == nil {
err = f.Sync()
}
if err == nil {
err = f.Close()
}
return name, err
}
func validateFeishuConfig(container, configPath string) error {
out, err := cmd.RunDockerExecWithStdout(time.Minute, container, "env", "OPENCLAW_CONFIG_PATH=/home/node/.openclaw/"+filepath.Base(configPath), "openclaw", "config", "validate", "--json")
return feishuValidationResult(out, err)
}
func replaceFeishuConfig(configPath string, expected, payload []byte) error {
tmp, err := stageFeishuConfig(configPath, payload)
if err != nil {
return err
}
defer os.Remove(tmp)
current, err := os.ReadFile(configPath)
if err != nil {
return err
}
if !bytes.Equal(current, expected) {
return buserr.New("ErrFeishuConfigChanged")
}
return os.Rename(tmp, configPath)
}
func (a AgentService) SwitchFeishuPlugin(req dto.AgentFeishuSwitchReq) error {
agent, install, err := a.loadOpenclawAgentAndInstall(req.AgentID)
if err != nil {
return err
}
if !isOpenclawBundledFeishuVersion(install.Version) {
return buserr.New("ErrFeishuImageRequired")
}
if _, busy := feishuSwitches.LoadOrStore(agent.ConfigPath, true); busy {
return buserr.New("TaskIsExecuting")
}
started := false
defer func() {
if !started {
feishuSwitches.Delete(agent.ConfigPath)
}
}()
if err := task.CheckScopeTaskIsExecuting(task.TaskScopeAI, req.AgentID); err != nil {
return err
}
if !hasBundledFeishu(install.ContainerName) {
return buserr.New("ErrFeishuImageRequired")
}
conf, err := readOpenclawConfig(agent.ConfigPath)
if err != nil {
return err
}
legacy, configured, err := classifyLegacyFeishu(conf, install.GetPath())
if err != nil {
return err
}
if !legacy {
return buserr.New("ErrFeishuAlreadySwitched")
}
if err := prepareLegacyFeishu(conf, configured, &dto.AgentFeishuTransition{Confirmed: true, Streaming: req.Streaming, GroupSessionScope: req.GroupSessionScope, RequireMention: req.RequireMention}); err != nil {
return err
}
switchTask, err := task.NewTaskWithOps("Feishu", task.TaskUpdate, task.TaskScopeAI, req.TaskID, req.AgentID)
if err != nil {
return err
}
switchTask.AddSubTaskWithAlias("AgentFeishuSwitch", func(t *task.Task) error { return a.runFeishuSwitch(agent, install, req, t) }, nil)
started = true
go func() { defer feishuSwitches.Delete(agent.ConfigPath); executeAgentPluginTask(switchTask) }()
return nil
}
func (a AgentService) runFeishuSwitch(agent *model.Agent, install *model.AppInstall, req dto.AgentFeishuSwitchReq, t *task.Task) (result error) {
original, conf, err := readFeishuConfigBytes(agent.ConfigPath)
if err != nil {
return err
}
legacy, configured, err := classifyLegacyFeishu(conf, install.GetPath())
if err != nil {
return err
}
if !legacy {
return buserr.New("ErrFeishuAlreadySwitched")
}
image, err := feishuContainerImage(install.ContainerName)
if err != nil {
return err
}
image, err = bundledFeishuImage(image)
if err != nil {
return err
}
if err = prepareLegacyFeishu(conf, configured, &dto.AgentFeishuTransition{Confirmed: true, Streaming: req.Streaming, GroupSessionScope: req.GroupSessionScope, RequireMention: req.RequireMention}); err != nil {
return err
}
payload, err := json.MarshalIndent(conf, "", " ")
if err != nil {
return err
}
recovery, err := feishuRecoveryPayload(payload, configured)
if err != nil {
return err
}
// Validate both candidates without invoking Doctor or mutating retained state.
for _, candidate := range [][]byte{payload, recovery} {
staged, stageErr := stageFeishuConfig(agent.ConfigPath, candidate)
if stageErr != nil {
return stageErr
}
validateErr := validateFeishuImageConfig(image, staged)
os.Remove(staged)
if validateErr != nil {
return validateErr
}
}
if _, err = compose.Stop(install.GetComposePath()); err != nil {
return err
}
replaced := false
defer func() {
if !replaced {
if _, startErr := compose.Operate(install.GetComposePath(), "start"); startErr != nil {
result = errors.Join(result, buserr.WithErr("ErrFeishuRestore", startErr))
}
return
}
if result == nil {
return
}
if _, stopErr := compose.Stop(install.GetComposePath()); stopErr != nil {
result = errors.Join(result, buserr.WithErr("ErrFeishuRestore", stopErr))
return
}
if restoreErr := replaceFeishuConfig(agent.ConfigPath, payload, recovery); restoreErr != nil {
result = errors.Join(result, buserr.WithErr("ErrFeishuRestore", restoreErr))
return
}
if _, startErr := compose.Operate(install.GetComposePath(), "start"); startErr != nil {
result = errors.Join(result, buserr.WithErr("ErrFeishuRestore", startErr))
return
}
if healthErr := verifyFeishuActivation(install.ContainerName, false); healthErr != nil {
result = errors.Join(result, buserr.WithErr("ErrFeishuRestore", healthErr))
return
}
t.Log(i18n.GetMsgByKey("AgentFeishuRecoveryDisabled"))
}()
current, err := os.ReadFile(agent.ConfigPath)
if err != nil {
return err
}
if !bytes.Equal(current, original) {
return buserr.New("ErrFeishuConfigChanged")
}
backup, err := backupFeishuConfig(agent.ConfigPath, original)
if err != nil {
return err
}
t.Log(i18n.GetWithName("AgentFeishuBackup", filepath.Base(backup)))
if err = replaceFeishuConfig(agent.ConfigPath, original, payload); err != nil {
return err
}
replaced = true
if _, err = compose.Operate(install.GetComposePath(), "start"); err != nil {
return err
}
enabled := configured && extractBoolValue(getChannelConfig(conf, "feishu")["enabled"], true)
if err = verifyFeishuActivation(install.ContainerName, enabled); err != nil {
return err
}
if enabled {
probeFeishuConnection(install.ContainerName, t)
}
if configured {
t.Log(i18n.GetMsgByKey("AgentFeishuVerifyMessages"))
} else {
t.Log(i18n.GetMsgByKey("AgentFeishuRecoveryDisabled"))
}
return nil
}
func saveBundledFeishuConfig(agent *model.Agent, install *model.AppInstall, req dto.AgentFeishuConfigUpdateReq) error {
if !isOpenclawBundledFeishuVersion(install.Version) {
return buserr.New("ErrFeishuImageRequired")
}
if _, busy := feishuSwitches.LoadOrStore(agent.ConfigPath, true); busy {
return buserr.New("TaskIsExecuting")
}
defer feishuSwitches.Delete(agent.ConfigPath)
if err := task.CheckScopeTaskIsExecuting(task.TaskScopeAI, req.AgentID); err != nil {
return err
}
if !hasBundledFeishu(install.ContainerName) {
return buserr.New("ErrFeishuImageRequired")
}
original, err := os.ReadFile(agent.ConfigPath)
if err != nil {
return err
}
var conf map[string]interface{}
if err = json.Unmarshal(original, &conf); err != nil {
return err
}
if hasLegacyFeishu(conf, install.GetPath()) {
return buserr.New("ErrFeishuSwitchRequired")
}
if err = setBundledFeishuConfig(conf, req); err != nil {
return err
}
payload, err := json.MarshalIndent(conf, "", " ")
if err != nil {
return err
}
staged, err := stageFeishuConfig(agent.ConfigPath, payload)
if err != nil {
return err
}
defer os.Remove(staged)
if err = validateFeishuConfig(install.ContainerName, staged); err != nil {
return err
}
return replaceFeishuConfig(agent.ConfigPath, original, payload)
}
func feishuProbeOK(output string) bool {
raw, err := extractEmbeddedJSON(output)
if err != nil {
return false
}
var status struct {
ChannelAccounts map[string][]struct {
Enabled bool `json:"enabled"`
Configured bool `json:"configured"`
Running bool `json:"running"`
Probe struct {
OK bool `json:"ok"`
} `json:"probe"`
} `json:"channelAccounts"`
}
if json.Unmarshal(raw, &status) != nil {
return false
}
accounts, ok := status.ChannelAccounts["feishu"]
if !ok {
return false
}
for _, account := range accounts {
if account.Enabled && (!account.Configured || !account.Running || !account.Probe.OK) {
return false
}
}
return true
}
func feishuGatewayReady(output string, enabled bool) bool {
raw, err := extractEmbeddedJSON(output)
if err != nil {
return false
}
var health struct {
OK bool `json:"ok"`
Plugins struct {
Loaded []string `json:"loaded"`
} `json:"plugins"`
}
return json.Unmarshal(raw, &health) == nil && health.OK && !slices.Contains(health.Plugins.Loaded, "openclaw-lark") && (!enabled || slices.Contains(health.Plugins.Loaded, "feishu"))
}
+201
View File
@@ -0,0 +1,201 @@
package service
import (
"context"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"syscall"
"time"
"github.com/1Panel-dev/1Panel/agent/buserr"
"github.com/1Panel-dev/1Panel/agent/utils/cmd"
"github.com/1Panel-dev/1Panel/agent/utils/docker"
"github.com/docker/docker/api/types/container"
)
// Inspect a stopped disposable container; its entrypoint never touches retained state.
func bundledFeishuImage(image string) (string, error) {
cli, err := docker.NewDockerClient()
if err != nil {
return "", err
}
defer cli.Close()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
info, _, err := cli.ImageInspectWithRaw(ctx, image)
if err != nil {
return "", buserr.New("ErrFeishuImageRequired")
}
created, err := cli.ContainerCreate(ctx, &container.Config{Image: info.ID, Entrypoint: []string{"/bin/true"}}, &container.HostConfig{NetworkMode: "none"}, nil, nil, "")
if err != nil {
return "", err
}
defer func() {
cleanup, done := context.WithTimeout(context.Background(), 10*time.Second)
defer done()
_ = cli.ContainerRemove(cleanup, created.ID, container.RemoveOptions{Force: true, RemoveVolumes: true})
}()
if _, err = cli.ContainerStatPath(ctx, created.ID, "/app/.1panel-feishu-bundled-v1"); err != nil {
return "", buserr.New("ErrFeishuImageRequired")
}
return info.ID, nil
}
func feishuContainerImage(name string) (string, error) {
cli, err := docker.NewDockerClient()
if err != nil {
return "", err
}
defer cli.Close()
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
info, err := cli.ContainerInspect(ctx, name)
if err != nil {
return "", err
}
return info.Image, nil
}
// The original state is read-only and the normal Doctor entrypoint is bypassed.
func validateFeishuImageConfig(image, configPath string) error {
info, err := os.Stat(configPath)
if err != nil {
return err
}
uid, gid := uint32(1000), uint32(1000)
if stat, ok := info.Sys().(*syscall.Stat_t); ok {
uid, gid = stat.Uid, stat.Gid
}
user := fmt.Sprintf("%d:%d", uid, gid)
tmpfs := fmt.Sprintf("/tmp:rw,mode=0700,uid=%d,gid=%d", uid, gid)
name := "1panel-feishu-" + strings.TrimSuffix(filepath.Base(configPath), ".json")
mgr := cmd.NewCommandMgr(cmd.WithTimeout(2 * time.Minute))
defer func() { _ = cmd.NewCommandMgr(cmd.WithTimeout(15*time.Second)).Run("docker", "rm", "-f", "-v", name) }()
out, runErr := mgr.RunWithStdout("docker", "run", "--name", name, "--rm", "--network", "none", "--read-only", "--user", user,
"--tmpfs", tmpfs,
"--mount", "type=bind,src="+filepath.Dir(configPath)+",dst=/home/node/.openclaw,readonly",
"--env", "HOME=/home/node", "--env", "TMPDIR=/tmp", "--env", "OPENCLAW_STATE_DIR=/tmp/state",
"--env", "OPENCLAW_CONFIG_PATH=/home/node/.openclaw/"+filepath.Base(configPath),
"--entrypoint", "sh", image, "-c", "mkdir -m 700 /tmp/state && ln -s /home/node/.openclaw/extensions /tmp/state/extensions && exec node /app/openclaw.mjs config validate --json")
return feishuValidationResult(out, runErr)
}
func feishuValidationResult(out string, runErr error) error {
raw, _ := extractEmbeddedJSON(out)
var result struct {
Valid bool `json:"valid"`
Issues []struct {
Path string `json:"path"`
} `json:"issues"`
}
if json.Unmarshal(raw, &result) == nil && result.Valid && runErr == nil {
return nil
}
fields := []string{}
for _, issue := range result.Issues {
if issue.Path != "" {
fields = append(fields, issue.Path)
}
}
if len(fields) == 0 {
fields = append(fields, "openclaw.json")
}
return feishuConfigError(strings.Join(fields, ", "))
}
func backupFeishuConfig(configPath string, original []byte) (string, error) {
f, err := os.CreateTemp(filepath.Dir(configPath), ".feishu-backup-*.json")
if err != nil {
return "", err
}
if _, err = f.Write(original); err == nil {
err = f.Sync()
}
closeErr := f.Close()
if err != nil {
return "", err
}
if closeErr != nil {
return "", closeErr
}
return f.Name(), nil
}
// Doctor can spend 94 seconds waiting for the previous container's owner lease.
func waitOpenclawReady(name string) error {
script := `const fs=require('fs');const c=JSON.parse(fs.readFileSync('/home/node/.openclaw/openclaw.json','utf8'));const tls=c.gateway?.tls?.enabled===true;const h=require(tls?'https':'http');function check(p){const r=h.get({hostname:'127.0.0.1',port:c.gateway?.port||18789,path:p,rejectUnauthorized:false,timeout:3000},s=>{s.resume();if(s.statusCode===404&&p==='/readyz')return check('/healthz');process.exitCode=s.statusCode===200?0:1});r.on('timeout',()=>r.destroy());r.on('error',()=>process.exitCode=1)}check('/readyz')`
deadline := time.Now().Add(4 * time.Minute)
for time.Now().Before(deadline) {
if cmd.RunDockerExec(8*time.Second, name, "node", "-e", script) == nil {
return nil
}
time.Sleep(2 * time.Second)
}
return buserr.New("ErrFeishuActivation")
}
func verifyFeishuActivation(name string, enabled bool) error {
if err := waitOpenclawReady(name); err != nil {
return err
}
output, err := cmd.RunDockerExecWithStdout(time.Minute, name, "openclaw", "plugins", "list", "--json")
if err != nil {
return buserr.New("ErrFeishuActivation")
}
raw, _ := extractEmbeddedJSON(output)
var result struct {
Plugins []struct {
ID string `json:"id"`
Status string `json:"status"`
Origin string `json:"origin"`
} `json:"plugins"`
}
if json.Unmarshal(raw, &result) != nil {
return buserr.New("ErrFeishuActivation")
}
found := false
for _, p := range result.Plugins {
if p.ID == "openclaw-lark" && p.Status != "disabled" {
return buserr.New("ErrFeishuActivation")
}
if p.ID == "feishu" && p.Origin == "bundled" && (p.Status == "loaded" || (!enabled && p.Status == "disabled")) {
found = true
}
}
if !found {
return buserr.New("ErrFeishuActivation")
}
health, err := cmd.RunDockerExecWithStdout(20*time.Second, name, "openclaw", "gateway", "health", "--json")
if err != nil || !feishuGatewayReady(health, enabled) {
return buserr.New("ErrFeishuActivation")
}
return nil
}
func readFeishuConfigBytes(file string) ([]byte, map[string]interface{}, error) {
info, err := os.Lstat(file)
if err != nil {
return nil, nil, err
}
if !info.Mode().IsRegular() {
return nil, nil, feishuConfigError("openclaw.json")
}
f, err := os.Open(file)
if err != nil {
return nil, nil, err
}
defer f.Close()
raw, err := io.ReadAll(f)
if err != nil {
return nil, nil, err
}
var conf map[string]interface{}
if err = json.Unmarshal(raw, &conf); err != nil || conf == nil {
return nil, nil, feishuConfigError("openclaw.json")
}
return raw, conf, nil
}
+6 -2
View File
@@ -8,11 +8,15 @@ import (
) )
const hermesWeixinLoginScript = `import asyncio const hermesWeixinLoginScript = `import asyncio
from gateway.platforms.weixin import qr_login try:
from plugins.platforms.weixin.adapter import qr_login
except ModuleNotFoundError as exc:
if exc.name not in ('plugins', 'plugins.platforms', 'plugins.platforms.weixin', 'plugins.platforms.weixin.adapter'):
raise
from gateway.platforms.weixin import qr_login
from hermes_cli.config import save_env_value from hermes_cli.config import save_env_value
creds = asyncio.run(qr_login('/opt/data')) creds = asyncio.run(qr_login('/opt/data'))
print('RESULT =', creds)
if not creds: if not creds:
raise SystemExit(1) raise SystemExit(1)
+202
View File
@@ -0,0 +1,202 @@
package service
import (
"errors"
"fmt"
"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/constant"
"github.com/1Panel-dev/1Panel/agent/global"
terminalai "github.com/1Panel-dev/1Panel/agent/utils/terminal/ai"
"gorm.io/gorm"
)
func SyncLocalModelAccount(install model.AppInstall, accountID uint, baseURL string, models []dto.AgentAccountModel, updateBaseURL, createIfMissing bool) (*model.AgentAccount, error) {
if install.ID == 0 || (install.App.Key != "ollama" && install.App.Key != "vllm") {
return nil, fmt.Errorf("invalid local model installation")
}
agentAccountMutationMu.Lock()
defer agentAccountMutationMu.Unlock()
account, err := agentAccountRepo.GetFirst(repo.WithByAppInstallID(install.ID))
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
if errors.Is(err, gorm.ErrRecordNotFound) && accountID != 0 {
account, err = agentAccountRepo.GetFirst(repo.WithByID(accountID), repo.WithByProvider(install.App.Key))
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
if err == nil && account.AppInstallID != 0 && account.AppInstallID != install.ID {
return nil, fmt.Errorf("model account belongs to another installation")
}
}
if errors.Is(err, gorm.ErrRecordNotFound) {
if !createIfMissing {
return nil, nil
}
name := install.Name
for suffix := 1; ; suffix++ {
existing, err := agentAccountRepo.List(repo.WithByProvider(install.App.Key), repo.WithByName(name))
if err != nil {
return nil, err
}
if len(existing) == 0 {
break
}
name = fmt.Sprintf("%s-%d", install.Name, suffix)
}
apiType, apiKey := "openai-completions", "vllm-local"
if install.App.Key == "ollama" {
apiType, apiKey = "openai-responses", "ollama-local"
}
account = &model.AgentAccount{Name: name, Provider: install.App.Key, BaseURL: baseURL, APIType: apiType, APIKey: apiKey, RememberAPIKey: true, Verified: true}
}
accountChanged := account.ID == 0 || account.AppInstallID != install.ID || (updateBaseURL && account.BaseURL != baseURL)
account.AppInstallID = install.ID
if updateBaseURL {
account.BaseURL = baseURL
}
rows := make([]model.AgentAccountModel, 0, len(models))
verifyModelExists := account.VerifyModel == ""
for _, item := range models {
rows = append(rows, model.AgentAccountModel{Model: item.ID, Name: item.Name})
if item.ID == account.VerifyModel {
verifyModelExists = true
}
}
if !verifyModelExists {
account.VerifyModel = ""
accountChanged = true
}
var previous []model.AgentAccountModel
if account.ID != 0 {
previous, err = agentAccountModelRepo.List(repo.WithByAccountID(account.ID), repo.WithOrderAsc("sort_order"), repo.WithOrderAsc("id"))
if err != nil {
return nil, err
}
}
modelsChanged := len(previous) != len(rows)
if !modelsChanged {
for i := range rows {
if previous[i].Model != rows[i].Model || previous[i].Name != rows[i].Name {
modelsChanged = true
break
}
}
}
if modelsChanged {
err = agentAccountRepo.SaveLocal(account, rows)
} else if accountChanged {
err = agentAccountRepo.Save(account)
} else {
return account, nil
}
if err != nil {
return nil, err
}
terminalai.InvalidateTerminalRuntimeCache()
terminalai.InvalidateFileAIRuntimeCache()
return account, nil
}
func SyncOllamaModelAccount(createIfMissing bool) error {
app, err := appRepo.GetFirst(appRepo.WithKey("ollama"))
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil
}
if err != nil {
return err
}
install, err := appInstallRepo.GetFirst(appInstallRepo.WithAppId(app.ID))
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil
}
if err != nil {
return err
}
if !install.SyncModelAccount {
return nil
}
return syncOllamaModelAccount(install, fmt.Sprintf("http://%s:11434/v1", install.ContainerName), false, createIfMissing)
}
func syncOllamaModelAccount(install model.AppInstall, baseURL string, updateBaseURL, createIfMissing bool) error {
models, err := aiRepo.List(repo.WithByStatus(constant.StatusSuccess), repo.WithOrderAsc("name"))
if err != nil {
return err
}
items := make([]dto.AgentAccountModel, 0, len(models))
for _, item := range models {
items = append(items, dto.AgentAccountModel{ID: item.Name, Name: item.Name})
}
_, err = SyncLocalModelAccount(install, 0, baseURL, items, updateBaseURL, createIfMissing)
return err
}
func loadOllamaModelAccountAddress(install model.AppInstall) (string, string, error) {
addressType := install.ModelAccountBaseURLType
if addressType == "" {
addressType = "container"
}
account, err := agentAccountRepo.GetFirst(repo.WithByAppInstallID(install.ID), repo.WithByProvider("ollama"))
if errors.Is(err, gorm.ErrRecordNotFound) {
return addressType, install.ModelAccountBaseURL, nil
}
if err != nil {
return "", "", err
}
if addressType != "custom" {
expectedURL, err := ResolveLocalModelAccountBaseURL(addressType, "", install.ContainerName, "11434", install.HttpPort)
if err != nil {
return "", "", err
}
if strings.TrimRight(account.BaseURL, "/") != expectedURL {
addressType = "custom"
}
}
return addressType, account.BaseURL, nil
}
func CleanupLocalModelAccount(installID uint) {
if installID == 0 {
return
}
accounts, err := agentAccountRepo.List(repo.WithByAppInstallID(installID))
if err != nil {
global.LOG.Errorf("load local model accounts: %v", err)
return
}
for _, account := range accounts {
if err := (AgentService{}).DeleteAccount(dto.AgentAccountDeleteReq{ID: account.ID}); err != nil {
global.LOG.Warnf("keep local model account %d after uninstall: %v", account.ID, err)
}
}
}
func ResolveLocalModelAccountBaseURL(addressType, customURL, containerName, containerPort string, hostPort int) (string, error) {
switch addressType {
case "custom":
baseURL := strings.TrimRight(strings.TrimSpace(customURL), "/")
if !strings.HasPrefix(baseURL, "http://") && !strings.HasPrefix(baseURL, "https://") {
return "", fmt.Errorf("model account base URL must start with http:// or https://")
}
return baseURL, nil
case "localhost":
return fmt.Sprintf("http://127.0.0.1:%d/v1", hostPort), nil
case "systemIP":
systemIP, err := settingRepo.GetValueByKey("SystemIP")
if err != nil {
return "", err
}
systemIP = strings.TrimSpace(systemIP)
if systemIP == "" {
systemIP = "127.0.0.1"
}
return fmt.Sprintf("http://%s:%d/v1", systemIP, hostPort), nil
default:
return fmt.Sprintf("http://%s:%s/v1", containerName, containerPort), nil
}
}
+115
View File
@@ -0,0 +1,115 @@
package service
import (
"encoding/json"
"path/filepath"
"reflect"
"testing"
"github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/app/model"
providercatalog "github.com/1Panel-dev/1Panel/agent/app/provider"
)
func TestMiniMaxOpenclawModelMetadata(t *testing.T) {
for _, provider := range []string{"minimax", "custom", "bailian-coding-plan"} {
for _, protocol := range []string{"anthropic-messages", "openai-completions"} {
for _, id := range []string{"MiniMax-M3.1-Flash", "MiniMax-M3", "MiniMax-M2.7", "MiniMax-M2.5", "saved-custom"} {
t.Run(provider+"/"+protocol+"/"+id, func(t *testing.T) {
account := &model.AgentAccount{Provider: provider, APIType: protocol, BaseURL: "https://example.test/v1", APIKey: "test-key"}
_, entry, _, _, err := buildOpenclawAccountModelConfig(account, dto.AgentAccountModel{ID: id, Name: "Saved name"})
if err != nil {
t.Fatal(err)
}
payload, err := json.Marshal(entry)
if err != nil {
t.Fatal(err)
}
var actual modelEntry
if err := json.Unmarshal(payload, &actual); err != nil {
t.Fatal(err)
}
if actual.ID != id || actual.Name != "Saved name" {
t.Fatalf("changed saved identity: %s", payload)
}
if provider == "minimax" && id == "MiniMax-M3.1-Flash" {
if actual.ContextWindow != 1000000 || actual.MaxTokens != 524288 || !reflect.DeepEqual(actual.Input, []string{"text", "image"}) {
t.Fatalf("incorrect direct Flash metadata: %s", payload)
}
} else if actual.ContextWindow != 0 || actual.MaxTokens != 0 {
t.Fatalf("changed existing/gateway limits: %s", payload)
}
})
}
}
}
}
func TestMiniMaxPreservesSavedOpenclawMetadata(t *testing.T) {
current := &modelsConfig{Mode: "merge", Providers: map[string]modelProvider{
"minimax": {Models: []modelEntry{{ID: "MiniMax-M3.1-Flash", Input: []string{"text"}, ContextWindow: 32000, MaxTokens: 4096}}},
}}
conf := map[string]interface{}{}
if err := applyOpenclawModelsConfig(conf, current); err != nil {
t.Fatal(err)
}
next := &modelsConfig{Mode: "merge", Providers: map[string]modelProvider{
"minimax": {Models: []modelEntry{{ID: "MiniMax-M3.1-Flash", Input: []string{"text", "image"}, ContextWindow: 1000000, MaxTokens: 524288}}},
}}
preserveOpenclawModelMetadata(conf, next)
if !reflect.DeepEqual(next.Providers["minimax"].Models, current.Providers["minimax"].Models) {
t.Fatalf("saved metadata changed: %+v", next)
}
}
func TestMiniMaxDefaultAccountRequest(t *testing.T) {
for _, protocol := range []string{"anthropic-messages", "openai-completions"} {
account := &model.AgentAccount{Provider: "minimax", APIType: protocol}
models, err := buildInitialAgentAccountModels(account, nil)
if err != nil {
t.Fatal(err)
}
selected, err := resolveAgentAccountVerifyModel("minimax", "", models)
if err != nil || selected != "MiniMax-M3.1-Flash" || len(models) != 2 || models[1].ID != "MiniMax-M3" {
t.Fatalf("unexpected default: %s, %+v, %v", selected, models, err)
}
request := providercatalog.BuildVerifyRequest("minimax", protocol, "", "https://example.test", "test-key", selected)
var body map[string]interface{}
if err := json.Unmarshal(request.Body, &body); err != nil || body["model"] != selected {
t.Fatalf("default request: %s, %v", request.Body, err)
}
}
}
func TestMiniMaxHermesConfiguration(t *testing.T) {
for _, protocol := range []string{"anthropic-messages", "openai-completions"} {
for _, id := range []string{"MiniMax-M3.1-Flash", "MiniMax-M3", "MiniMax-M2.7", "my-custom-model"} {
dir := t.TempDir()
account := &model.AgentAccount{Provider: "minimax", APIType: protocol, BaseURL: "https://api.minimaxi.com/anthropic", APIKey: "test-key"}
if err := writeHermesConfig(dir, account, id, ""); err != nil {
t.Fatal(err)
}
conf, err := readHermesConfig(filepath.Join(dir, "config.yaml"))
if err != nil {
t.Fatal(err)
}
if conf.Model.Default != id || conf.Model.Provider != "minimax-cn" || conf.Model.BaseURL != account.BaseURL {
t.Fatalf("changed Hermes selection: %+v", conf.Model)
}
}
}
}
func TestMiniMaxExplicitAccountModels(t *testing.T) {
for _, provider := range []string{"minimax", "bailian-coding-plan", "custom"} {
account := &model.AgentAccount{Provider: provider, APIType: "openai-completions"}
requested := []dto.AgentAccountModel{{ID: "MiniMax-M2.5", Name: "Saved legacy"}, {ID: "my-custom-model", Name: "Saved custom"}}
actual, err := buildInitialAgentAccountModels(account, requested)
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(actual, requested) {
t.Fatalf("changed explicit %s models: %+v", provider, actual)
}
}
}
+69
View File
@@ -0,0 +1,69 @@
package service
import (
"net"
"regexp"
"strings"
"unicode"
"unicode/utf8"
"github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/buserr"
"github.com/1Panel-dev/1Panel/agent/constant"
)
var deepSeekUsernamePattern = regexp.MustCompile(`^[A-Za-z0-9._-]+$`)
var deepSeekHostLabelPattern = regexp.MustCompile(`^[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$`)
func isDeepSeekAccessHost(value string) bool {
if ip := net.ParseIP(value); ip != nil {
return ip.To4() != nil && !strings.Contains(value, ":")
}
if value == "" || len(value) > 253 || strings.Trim(value, "0123456789.") == "" {
return false
}
for _, label := range strings.Split(value, ".") {
if !deepSeekHostLabelPattern.MatchString(label) {
return false
}
}
return true
}
func validateNativeAgentCreate(req dto.AgentCreateReq) error {
if req.AgentType != constant.AppDeepSeekHarness && req.AgentType != constant.AppOctop {
return nil
}
if strings.TrimSpace(req.DashboardUsername) == "" || strings.ContainsAny(req.DashboardUsername, "\r\n\x00") {
return buserr.WithDetail("ErrInvalidParams", "dashboardUsername", nil)
}
if strings.ContainsAny(req.DashboardPassword, "\r\n\x00") || req.DashboardPassword != strings.TrimSpace(req.DashboardPassword) {
return buserr.WithDetail("ErrInvalidParams", "dashboardPassword", nil)
}
if req.AgentType == constant.AppDeepSeekHarness {
if !isDeepSeekAccessHost(req.AccessHost) {
return buserr.WithDetail("ErrInvalidParams", "accessHost", nil)
}
if !deepSeekUsernamePattern.MatchString(req.DashboardUsername) {
return buserr.WithDetail("ErrInvalidParams", "dashboardUsername", nil)
}
if utf8.RuneCountInString(req.DashboardPassword) < 12 {
return buserr.WithDetail("ErrInvalidParams", "dashboardPassword", nil)
}
return nil
}
hasLetter, hasDigit := false, false
for _, char := range req.DashboardPassword {
hasLetter = hasLetter || unicode.IsLetter(char)
hasDigit = hasDigit || unicode.IsDigit(char)
}
if utf8.RuneCountInString(req.DashboardPassword) < 8 || !hasLetter || !hasDigit {
return buserr.WithDetail("ErrInvalidParams", "dashboardPassword", nil)
}
switch strings.ToLower(req.DashboardPassword) {
case "password", "password1", "password12", "password123", "12345678", "123456789", "qwerty123",
"admin123", "welcome1", "letmein1", "changeme1", "octop123", "abc12345", "iloveyou1":
return buserr.WithDetail("ErrInvalidParams", "dashboardPassword", nil)
}
return nil
}
+6
View File
@@ -107,6 +107,9 @@ func (a AgentService) InstallMarketPlugin(req dto.AgentPluginMarketInstallReq) e
if err != nil { if err != nil {
return err return err
} }
if isOpenclawBundledFeishuVersion(install.Version) && (strings.TrimSpace(req.Package) == "@larksuite/openclaw-lark" || strings.TrimSpace(req.Package) == "@openclaw/feishu") {
return buserr.New("ErrFeishuManaged")
}
if err := task.CheckScopeTaskIsExecuting(task.TaskScopeAI, req.AgentID); err != nil { if err := task.CheckScopeTaskIsExecuting(task.TaskScopeAI, req.AgentID); err != nil {
return err return err
} }
@@ -132,6 +135,9 @@ func (a AgentService) OperatePlugin(req dto.AgentPluginOperateReq) error {
if err != nil { if err != nil {
return err return err
} }
if isOpenclawBundledFeishuVersion(install.Version) && (req.PluginID == "openclaw-lark" || req.PluginID == "feishu") {
return buserr.New("ErrFeishuManaged")
}
if err := task.CheckScopeTaskIsExecuting(task.TaskScopeAI, req.AgentID); err != nil { if err := task.CheckScopeTaskIsExecuting(task.TaskScopeAI, req.AgentID); err != nil {
return err return err
} }
+31 -8
View File
@@ -98,7 +98,7 @@ func ensureContainerRunning(containerName string) error {
return nil return nil
} }
func resolveAgentAccountInput(provider, apiType, authMode, apiKey, baseURL, modelID string, validateAvailability bool) (resolvedAgentAccountInput, error) { func resolveAgentAccountInput(provider, apiType, authMode, apiKey, baseURL, modelID string, validateAvailability, allowEmptyModel bool) (resolvedAgentAccountInput, error) {
resolvedAPIKey := strings.TrimSpace(apiKey) resolvedAPIKey := strings.TrimSpace(apiKey)
resolvedAPIType := strings.TrimSpace(apiType) resolvedAPIType := strings.TrimSpace(apiType)
resolvedAuthMode, err := providercatalog.ResolveAuthMode(provider, resolvedAPIType, authMode) resolvedAuthMode, err := providercatalog.ResolveAuthMode(provider, resolvedAPIType, authMode)
@@ -113,7 +113,7 @@ func resolveAgentAccountInput(provider, apiType, authMode, apiKey, baseURL, mode
return resolvedAgentAccountInput{}, err return resolvedAgentAccountInput{}, err
} }
modelID = strings.TrimSpace(modelID) modelID = strings.TrimSpace(modelID)
if modelID == "" { if modelID == "" && !allowEmptyModel {
return resolvedAgentAccountInput{}, buserr.New("ErrAgentAccountModelsRequired") return resolvedAgentAccountInput{}, buserr.New("ErrAgentAccountModelsRequired")
} }
imageAPI := providercatalog.IsImageAPIType(resolvedAPIType) imageAPI := providercatalog.IsImageAPIType(resolvedAPIType)
@@ -148,6 +148,10 @@ func readOpenclawConfig(configPath string) (map[string]interface{}, error) {
} }
func writeOpenclawConfigRaw(configPath string, conf map[string]interface{}) error { func writeOpenclawConfigRaw(configPath string, conf map[string]interface{}) error {
if _, busy := feishuSwitches.LoadOrStore(configPath, true); busy {
return buserr.New("TaskIsExecuting")
}
defer feishuSwitches.Delete(configPath)
ensureGatewaySecurityDefaults(conf) ensureGatewaySecurityDefaults(conf)
ensureOpenclawUpdateDefaults(conf) ensureOpenclawUpdateDefaults(conf)
payload, err := json.MarshalIndent(conf, "", " ") payload, err := json.MarshalIndent(conf, "", " ")
@@ -415,6 +419,15 @@ func buildAgentItem(agent *model.Agent, appInstall *model.AppInstall, envMap map
} else { } else {
item.WebUIPort = appInstall.HttpPort item.WebUIPort = appInstall.HttpPort
} }
if agentType == constant.AppDeepSeekHarness {
item.WebUIPort = appInstall.HttpsPort
if values, err := readAgentEnvMap(appInstall.GetEnvPath()); err == nil {
accessHost := values["HTTPS_ACCESS_HOST"]
if isDeepSeekAccessHost(accessHost) {
item.AccessHost = accessHost
}
}
}
item.Path = appInstall.GetPath() item.Path = appInstall.GetPath()
item.Status = appInstall.Status item.Status = appInstall.Status
item.Message = appInstall.Message item.Message = appInstall.Message
@@ -442,7 +455,8 @@ func localizedAgentProviderName(provider string) string {
} }
func isAgentAppKey(appKey string) bool { func isAgentAppKey(appKey string) bool {
return appKey == constant.AppOpenclaw || appKey == constant.AppCopaw || appKey == constant.AppHermesAgent return appKey == constant.AppOpenclaw || appKey == constant.AppCopaw || appKey == constant.AppHermesAgent ||
appKey == constant.AppDeepSeekHarness || appKey == constant.AppOctop
} }
func isOpenclawLegacyHTTPVersion(version string) bool { func isOpenclawLegacyHTTPVersion(version string) bool {
@@ -652,7 +666,7 @@ func checkAgentUpgradable(install model.AppInstall) bool {
if len(ignores) > 0 { if len(ignores) > 0 {
continue continue
} }
if common.IsCrossVersion(install.Version, item.Version) && !install.App.CrossVersionUpdate { if isCrossAppVersion(install.App.Key, install.Version, item.Version) && !install.App.CrossVersionUpdate {
continue continue
} }
versions = append(versions, item.Version) versions = append(versions, item.Version)
@@ -660,12 +674,12 @@ func checkAgentUpgradable(install model.AppInstall) bool {
if len(versions) == 0 { if len(versions) == 0 {
return false return false
} }
versions = common.GetSortedVersions(versions) versions = sortAppVersions(install.App.Key, versions)
lastVersion := versions[0] lastVersion := versions[0]
if common.IsCrossVersion(install.Version, lastVersion) { if isCrossAppVersion(install.App.Key, install.Version, lastVersion) {
return install.App.CrossVersionUpdate return install.App.CrossVersionUpdate
} }
return common.CompareVersion(lastVersion, install.Version) return isNewerAppVersion(install.App.Key, lastVersion, install.Version)
} }
type openclawConfig struct { type openclawConfig struct {
@@ -1253,7 +1267,12 @@ func buildOpenclawAccountModelConfig(account *model.AgentAccount, model dto.Agen
if err != nil { if err != nil {
return "", modelEntry{}, "", modelProvider{}, err return "", modelEntry{}, "", modelProvider{}, err
} }
return providerPatch.PrimaryModel, buildOpenclawModelEntry(providerPatch.ModelID, model), providerPatch.ProviderKey, modelProvider{ entry := buildOpenclawModelEntry(providerPatch.ModelID, model)
if account.Provider == "minimax" && providerPatch.ModelID == "MiniMax-M3.1-Flash" {
entry.ContextWindow = 1000000
entry.MaxTokens = 524288
}
return providerPatch.PrimaryModel, entry, providerPatch.ProviderKey, modelProvider{
ApiKey: providerPatch.APIKey, ApiKey: providerPatch.APIKey,
BaseUrl: providerPatch.BaseURL, BaseUrl: providerPatch.BaseURL,
Api: providerPatch.APIType, Api: providerPatch.APIType,
@@ -1641,6 +1660,10 @@ func agentDashboardAuthEnvKeys(agentType string) (string, string, bool) {
return hermesDashboardUsernameEnvKey, hermesDashboardPasswordEnvKey, true return hermesDashboardUsernameEnvKey, hermesDashboardPasswordEnvKey, true
case constant.AppCopaw: case constant.AppCopaw:
return qwenPawAuthUsernameEnvKey, qwenPawAuthPasswordEnvKey, true return qwenPawAuthUsernameEnvKey, qwenPawAuthPasswordEnvKey, true
case constant.AppDeepSeekHarness:
return "DSH_AUTH_USERNAME", "DSH_AUTH_PASSWORD", true
case constant.AppOctop:
return "OCTOP_ADMIN_USERNAME", "OCTOP_DEFAULT_PASSWORD", true
default: default:
return "", "", false return "", "", false
} }
+107 -47
View File
@@ -8,6 +8,7 @@ import (
"os/exec" "os/exec"
"path" "path"
"strings" "strings"
"sync"
"syscall" "syscall"
"time" "time"
@@ -28,13 +29,15 @@ import (
type AIToolService struct{} type AIToolService struct{}
var ollamaModelMu sync.Mutex
type IAIToolService interface { type IAIToolService interface {
Search(search dto.SearchWithPage) (int64, []dto.OllamaModelInfo, error) Search(search dto.SearchWithPage) (int64, []dto.OllamaModelInfo, error)
Create(req dto.OllamaModelName) error Create(req dto.OllamaModelName) error
Close(name string) error Close(name string) error
Recreate(req dto.OllamaModelName) error Recreate(req dto.OllamaModelName) error
Delete(req dto.ForceDelete) error Delete(req dto.ForceDelete) error
Sync() ([]dto.OllamaModelDropList, error) SyncAccount(req dto.OllamaModelAccountSync) error
LoadDetail(name string) (string, error) LoadDetail(name string) (string, error)
BindDomain(req dto.OllamaBindDomain) error BindDomain(req dto.OllamaBindDomain) error
GetBindDomain(req dto.OllamaBindDomainReq) (*dto.OllamaBindDomainRes, error) GetBindDomain(req dto.OllamaBindDomainReq) (*dto.OllamaBindDomainRes, error)
@@ -46,6 +49,7 @@ func NewIAIToolService() IAIToolService {
} }
func (u *AIToolService) Search(req dto.SearchWithPage) (int64, []dto.OllamaModelInfo, error) { func (u *AIToolService) Search(req dto.SearchWithPage) (int64, []dto.OllamaModelInfo, error) {
syncErr := syncOllamaModels()
var options []repo.DBOption var options []repo.DBOption
if len(req.Info) != 0 { if len(req.Info) != 0 {
options = append(options, repo.WithByLikeName(req.Info)) options = append(options, repo.WithByLikeName(req.Info))
@@ -60,6 +64,11 @@ func (u *AIToolService) Search(req dto.SearchWithPage) (int64, []dto.OllamaModel
if err := copier.Copy(&item, &itemModel); err != nil { if err := copier.Copy(&item, &itemModel); err != nil {
return 0, nil, buserr.WithDetail("ErrStructTransform", err.Error(), nil) return 0, nil, buserr.WithDetail("ErrStructTransform", err.Error(), nil)
} }
if syncErr != nil && item.Status != constant.StatusWaiting {
item.Status = constant.StatusUnknown
item.Message = syncErr.Error()
item.Size = ""
}
taskModel, _ := taskRepo.GetFirst(taskRepo.WithResourceID(item.ID), repo.WithByType(task.TaskScopeAI)) taskModel, _ := taskRepo.GetFirst(taskRepo.WithResourceID(item.ID), repo.WithByType(task.TaskScopeAI))
if len(taskModel.ID) != 0 { if len(taskModel.ID) != 0 {
item.LogFileExist = true item.LogFileExist = true
@@ -85,6 +94,8 @@ func (u *AIToolService) LoadDetail(name string) (string, error) {
} }
func (u *AIToolService) Create(req dto.OllamaModelName) error { func (u *AIToolService) Create(req dto.OllamaModelName) error {
ollamaModelMu.Lock()
defer ollamaModelMu.Unlock()
if cmd.CheckIllegal(req.Name) { if cmd.CheckIllegal(req.Name) {
return buserr.New("ErrCmdIllegal") return buserr.New("ErrCmdIllegal")
} }
@@ -125,6 +136,9 @@ func (u *AIToolService) Create(req dto.OllamaModelName) error {
if err := taskItem.Execute(); err != nil { if err := taskItem.Execute(); err != nil {
_ = aiRepo.Update(info.ID, map[string]interface{}{"status": constant.StatusFailed, "message": err.Error()}) _ = aiRepo.Update(info.ID, map[string]interface{}{"status": constant.StatusFailed, "message": err.Error()})
} }
if err := SyncOllamaModelAccount(false); err != nil {
global.LOG.Errorf("sync Ollama model account after pull: %v", err)
}
}() }()
return nil return nil
} }
@@ -144,6 +158,8 @@ func (u *AIToolService) Close(name string) error {
} }
func (u *AIToolService) Recreate(req dto.OllamaModelName) error { func (u *AIToolService) Recreate(req dto.OllamaModelName) error {
ollamaModelMu.Lock()
defer ollamaModelMu.Unlock()
if cmd.CheckIllegal(req.Name) { if cmd.CheckIllegal(req.Name) {
return buserr.New("ErrCmdIllegal") return buserr.New("ErrCmdIllegal")
} }
@@ -179,11 +195,16 @@ func (u *AIToolService) Recreate(req dto.OllamaModelName) error {
if err := taskItem.Execute(); err != nil { if err := taskItem.Execute(); err != nil {
_ = aiRepo.Update(modelInfo.ID, map[string]interface{}{"status": constant.StatusFailed, "message": err.Error()}) _ = aiRepo.Update(modelInfo.ID, map[string]interface{}{"status": constant.StatusFailed, "message": err.Error()})
} }
if err := SyncOllamaModelAccount(false); err != nil {
global.LOG.Errorf("sync Ollama model account after pull: %v", err)
}
}() }()
return nil return nil
} }
func (u *AIToolService) Delete(req dto.ForceDelete) error { func (u *AIToolService) Delete(req dto.ForceDelete) error {
ollamaModelMu.Lock()
defer ollamaModelMu.Unlock()
ollamaList, _ := aiRepo.List(repo.WithByIDs(req.IDs)) ollamaList, _ := aiRepo.List(repo.WithByIDs(req.IDs))
if len(ollamaList) == 0 { if len(ollamaList) == 0 {
return buserr.New("ErrRecordNotFound") return buserr.New("ErrRecordNotFound")
@@ -202,55 +223,28 @@ func (u *AIToolService) Delete(req dto.ForceDelete) error {
logItem := path.Join(global.Dir.DataDir, "log", "AITools", item.Name) logItem := path.Join(global.Dir.DataDir, "log", "AITools", item.Name)
_ = os.Remove(logItem) _ = os.Remove(logItem)
} }
return nil return SyncOllamaModelAccount(false)
} }
func (u *AIToolService) Sync() ([]dto.OllamaModelDropList, error) { func (u *AIToolService) SyncAccount(req dto.OllamaModelAccountSync) error {
containerName, err := LoadContainerName() install, err := appInstallRepo.GetFirst(repo.WithByID(req.ID))
if err != nil { if err != nil {
return nil, err return err
} }
stdout, err := cmd.RunDockerExecWithStdout(20*time.Second, containerName, "ollama", "list") if install.App.Key != "ollama" {
return buserr.New("ErrInvalidParams")
}
baseURL, err := ResolveLocalModelAccountBaseURL(req.ModelAccountBaseURLType, req.ModelAccountBaseURL, install.ContainerName, "11434", install.HttpPort)
if err != nil { if err != nil {
return nil, err return err
} }
var list []model.OllamaModel if err := syncOllamaModelAccount(install, baseURL, true, true); err != nil {
lines := strings.Split(stdout, "\n") return err
for _, line := range lines {
parts := strings.Fields(line)
if len(parts) < 5 {
continue
} }
if parts[0] == "NAME" { return appInstallRepo.BatchUpdateBy(map[string]interface{}{
continue "model_account_base_url_type": req.ModelAccountBaseURLType,
} "model_account_base_url": baseURL,
list = append(list, model.OllamaModel{Name: parts[0], Size: parts[2] + " " + parts[3]}) }, repo.WithByID(install.ID))
}
listInDB, _ := aiRepo.List()
var dropList []dto.OllamaModelDropList
for _, itemModel := range listInDB {
isExit := false
for i := 0; i < len(list); i++ {
if list[i].Name == itemModel.Name {
_ = aiRepo.Update(itemModel.ID, map[string]interface{}{"status": constant.StatusSuccess, "message": "", "size": list[i].Size})
list = append(list[:i], list[(i+1):]...)
isExit = true
break
}
}
if !isExit && itemModel.Status != constant.StatusWaiting {
_ = aiRepo.Update(itemModel.ID, map[string]interface{}{"status": constant.StatusDeleted, "message": "not exist", "size": ""})
dropList = append(dropList, dto.OllamaModelDropList{ID: itemModel.ID, Name: itemModel.Name})
continue
}
}
for _, item := range list {
item.Status = constant.StatusSuccess
item.From = "remote"
_ = aiRepo.Create(&item)
}
return dropList, nil
} }
func (u *AIToolService) BindDomain(req dto.OllamaBindDomain) error { func (u *AIToolService) BindDomain(req dto.OllamaBindDomain) error {
@@ -370,6 +364,72 @@ func (u *AIToolService) UpdateBindDomain(req dto.OllamaBindDomain) error {
return nil return nil
} }
func syncOllamaModels() error {
ollamaModelMu.Lock()
defer ollamaModelMu.Unlock()
containerName, err := LoadContainerName()
if err != nil {
return err
}
stdout, err := cmd.RunDockerExecWithStdout(20*time.Second, containerName, "ollama", "list")
if err != nil {
return err
}
models := make(map[string]string)
lines := strings.Split(strings.TrimSpace(stdout), "\n")
if len(lines) == 0 || !strings.HasPrefix(strings.TrimSpace(lines[0]), "NAME") {
return fmt.Errorf("invalid ollama list output: %s", stdout)
}
for _, line := range lines[1:] {
if strings.TrimSpace(line) == "" {
continue
}
parts := strings.Fields(line)
if len(parts) < 5 {
return fmt.Errorf("invalid ollama model entry: %s", line)
}
models[parts[0]] = parts[2] + " " + parts[3]
}
list, err := aiRepo.List()
if err != nil {
return err
}
known := make(map[string]bool, len(list))
for _, item := range list {
name := item.Name
if !strings.Contains(path.Base(name), ":") {
name += ":latest"
}
size, exists := models[name]
known[name] = true
if item.Status == constant.StatusWaiting {
continue
}
status, message := constant.StatusSuccess, ""
if !exists {
if item.Status == constant.StatusFailed || item.Status == constant.StatusCanceled {
continue
}
status, message = constant.StatusDeleted, "not exist"
}
if item.Status != status || item.Size != size || item.Message != message {
if err := aiRepo.Update(item.ID, map[string]interface{}{"status": status, "size": size, "message": message}); err != nil {
return err
}
}
}
for name, size := range models {
if known[name] {
continue
}
item := model.OllamaModel{Name: name, Size: size, From: "remote", Status: constant.StatusSuccess}
if err := aiRepo.Create(&item); err != nil {
return err
}
}
return nil
}
func LoadContainerName() (string, error) { func LoadContainerName() (string, error) {
ollamaBaseInfo, err := appInstallRepo.LoadBaseInfo("ollama", "") ollamaBaseInfo, err := appInstallRepo.LoadBaseInfo("ollama", "")
if err != nil { if err != nil {
@@ -387,12 +447,12 @@ func loadModelSize(name string, containerName string) (string, error) {
return "", err return "", err
} }
lines := strings.Split(stdout, "\n") lines := strings.Split(stdout, "\n")
for _, line := range lines { if !strings.Contains(path.Base(name), ":") {
if !strings.Contains(line, name) { name += ":latest"
continue
} }
for _, line := range lines {
parts := strings.Fields(line) parts := strings.Fields(line)
if len(parts) < 5 { if len(parts) < 5 || parts[0] != name {
continue continue
} }
return parts[2] + " " + parts[3], nil return parts[2] + " " + parts[3], nil
+209
View File
@@ -0,0 +1,209 @@
package service
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
"github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/utils/docker"
"github.com/mattn/go-shellwords"
)
func AIBenchmarkSources(kind, selected string) ([]dto.AIBenchmarkSource, error) {
if kind != "vllm" && kind != "ollama" {
return nil, errors.New("unsupported benchmark source")
}
selectedID := strings.SplitN(selected, ":", 2)[0]
type instance struct {
id uint
name, container string
port int
}
instances := []instance{}
apps, err := appRepo.GetBy(appRepo.WithKey(kind))
if err != nil {
return nil, err
}
for _, app := range apps {
installs, err := appInstallRepo.ListBy(context.Background(), appInstallRepo.WithAppId(app.ID))
if err != nil {
return nil, err
}
for _, install := range installs {
if selected != "" && strconv.FormatUint(uint64(install.ID), 10) != selectedID {
continue
}
instances = append(instances, instance{id: install.ID, name: install.Name, container: install.ContainerName, port: install.HttpPort})
}
}
results := []dto.AIBenchmarkSource{}
if len(instances) == 0 {
return results, nil
}
client, err := docker.NewDockerClient()
if err != nil {
return nil, err
}
defer client.Close()
if !strings.HasPrefix(client.DaemonHost(), "unix://") {
return nil, errors.New("benchmark shortcuts require a Docker daemon on the main node; enter the remote service address manually")
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
for _, instance := range instances {
base := dto.AIBenchmarkSource{Kind: kind, Name: instance.name, Endpoint: "/v1/chat/completions", Models: []string{}, Status: "unknown"}
inspected, err := client.ContainerInspect(ctx, instance.container)
if err != nil {
base.ID = strconv.FormatUint(uint64(instance.id), 10)
base.Message = "container inspection unavailable"
results = append(results, base)
continue
}
if inspected.State != nil {
base.Status = inspected.State.Status
}
args := []string{}
if inspected.Config != nil {
args = append(args, inspected.Config.Entrypoint...)
args = append(args, inspected.Config.Cmd...)
}
if len(args) >= 3 && (args[0] == "sh" || args[0] == "bash" || strings.HasSuffix(args[0], "/sh") || strings.HasSuffix(args[0], "/bash")) && args[1] == "-c" {
parser := shellwords.NewParser()
parser.ParseEnv = false
parser.ParseBacktick = false
if parsed, err := parser.Parse(args[2]); err == nil {
args = parsed
}
}
flags := map[string]string{}
for i, arg := range args {
if key, value, ok := strings.Cut(arg, "="); ok && strings.HasPrefix(key, "--") {
flags[key] = value
} else if strings.HasPrefix(arg, "--") && i+1 < len(args) && !strings.HasPrefix(args[i+1], "--") {
flags[arg] = args[i+1]
}
}
if name := flags["--served-model-name"]; name != "" {
base.Models = []string{name}
}
tokenizer := flags["--tokenizer"]
if tokenizer == "" {
tokenizer = flags["--model"]
for i, arg := range args {
if tokenizer == "" && arg == "serve" && i+1 < len(args) && !strings.HasPrefix(args[i+1], "-") {
tokenizer = args[i+1]
}
}
}
if kind != "ollama" && tokenizer != "" {
if !filepath.IsAbs(tokenizer) {
base.Tokenizer = tokenizer
} else {
longest := 0
for _, mount := range inspected.Mounts {
relative, err := filepath.Rel(mount.Destination, tokenizer)
if err == nil && relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator)) && len(mount.Destination) > longest {
candidate := filepath.Join(mount.Source, relative)
if info, err := os.Stat(candidate); err == nil && info.IsDir() {
base.Tokenizer = candidate
longest = len(mount.Destination)
}
}
}
}
}
ports := map[int]string{}
if instance.port > 0 {
ports[instance.port] = "127.0.0.1"
}
if inspected.NetworkSettings != nil {
for _, bindings := range inspected.NetworkSettings.Ports {
for _, binding := range bindings {
port, _ := strconv.Atoi(binding.HostPort)
if port > 0 {
host := binding.HostIP
if host == "" || host == "0.0.0.0" || host == "::" {
host = "127.0.0.1"
}
ports[port] = host
}
}
}
}
if len(ports) == 0 && inspected.HostConfig != nil && inspected.HostConfig.NetworkMode == "host" {
port, _ := strconv.Atoi(flags["--port"])
if port > 0 {
ports[port] = "127.0.0.1"
}
}
ordered := []int{}
for port := range ports {
ordered = append(ordered, port)
}
sort.Ints(ordered)
if len(ordered) == 0 {
ordered = append(ordered, 0)
}
for _, port := range ordered {
source := base
source.ID = fmt.Sprintf("%d:%d", instance.id, port)
if selected != "" && source.ID != selected {
continue
}
if port > 0 {
source.BaseURL = "http://" + net.JoinHostPort(ports[port], strconv.Itoa(port))
}
if selected == source.ID && source.BaseURL != "" && source.Status == "running" {
endpoint := "/v1/models"
if kind == "ollama" {
endpoint = "/api/tags"
}
request, err := http.NewRequestWithContext(ctx, http.MethodGet, source.BaseURL+endpoint, nil)
if err != nil {
return nil, err
}
response, err := (&http.Client{Timeout: 3 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}).Do(request)
if err != nil {
source.Message = "model list unavailable; enter the served model name"
} else {
raw, readErr := io.ReadAll(io.LimitReader(response.Body, 2*1024*1024))
response.Body.Close()
if response.StatusCode == http.StatusOK && readErr == nil {
var data struct {
Data []struct {
ID string `json:"id"`
} `json:"data"`
Models []struct {
Name string `json:"name"`
} `json:"models"`
}
if err := json.Unmarshal(raw, &data); err == nil {
source.Models = []string{}
for _, model := range data.Data {
source.Models = append(source.Models, model.ID)
}
for _, model := range data.Models {
source.Models = append(source.Models, model.Name)
}
}
} else {
source.Message = fmt.Sprintf("model list HTTP %d; provide model name and credentials if required", response.StatusCode)
}
}
}
results = append(results, source)
}
}
return results, nil
}
+63
View File
@@ -0,0 +1,63 @@
package service
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/buserr"
"github.com/1Panel-dev/1Panel/agent/global"
"github.com/1Panel-dev/1Panel/agent/utils/files"
)
func prepareAIGatewayAppRecover(install *model.AppInstall, recoverFile, secret string) (string, func(), error) {
noop := func() {}
if !isAIGatewayApp(install.App.Key) {
return "", noop, nil
}
tmpDir, err := os.MkdirTemp(global.Dir.TmpDir, "ai-gateway-recover-")
if err != nil {
return "", noop, err
}
cleanup := func() { _ = os.RemoveAll(tmpDir) }
fail := func(err error) (string, func(), error) {
cleanup()
return "", noop, err
}
if err := files.NewFileOp().TarGzExtractPro(recoverFile, tmpDir, secret); err != nil {
return fail(err)
}
backupDir := filepath.Join(tmpDir, strings.ReplaceAll(filepath.Base(recoverFile), ".tar.gz", ""))
for _, name := range []string{"app.json", "app.tar.gz"} {
info, err := os.Lstat(filepath.Join(backupDir, name))
if err != nil || !info.Mode().IsRegular() {
return fail(buserr.New("AppBackupFileIncomplete"))
}
}
metadata, err := os.ReadFile(filepath.Join(backupDir, "app.json"))
if err != nil {
return fail(err)
}
var backup model.AppInstall
if err := json.Unmarshal(metadata, &backup); err != nil {
return fail(fmt.Errorf("unmarshal app.json failed: %w", err))
}
if backup.App.Key != install.App.Key || backup.Name != install.Name {
return fail(buserr.New("AppAttributesNotMatch"))
}
// Historical backups already include Version. Unsuffixed releases are
// SQLite; no new metadata field is required. Missing versions are invalid,
// not evidence that an archive contains SQLite data.
if strings.TrimSpace(backup.Version) == "" || strings.TrimSpace(install.Version) == "" {
return fail(buserr.New("AppBackupFileIncomplete"))
}
// Deliberately do not compare version order: restoring an older version of
// the same database type is required for failed-upgrade rollbacks.
if !isAIGatewayUpgradeVersionAllowed(install.App.Key, install.Version, backup.Version) {
return fail(buserr.New("ErrAIGatewayRecoverDatabaseTypeMismatch"))
}
return backupDir, cleanup, nil
}
+37
View File
@@ -0,0 +1,37 @@
package service
import (
"sort"
"strings"
"github.com/1Panel-dev/1Panel/agent/utils/common"
)
const aiGatewayAppKey = "ai-gateway"
func isAIGatewayApp(appKey string) bool {
return appKey == aiGatewayAppKey || appKey == "local"+aiGatewayAppKey
}
func isAIGatewayPostgresVersion(version string) bool {
return strings.HasSuffix(version, "-pg") || strings.HasSuffix(version, "-postgres")
}
// Database variants are deployment choices, not interchangeable upgrades.
func isAIGatewayUpgradeVersionAllowed(appKey, currentVersion, targetVersion string) bool {
return !isAIGatewayApp(appKey) ||
isAIGatewayPostgresVersion(currentVersion) == isAIGatewayPostgresVersion(targetVersion)
}
func sortAIGatewayVersions(versions []string) {
sort.SliceStable(versions, func(i, j int) bool {
if common.CompareVersion(versions[i], versions[j]) {
return true
}
if common.CompareVersion(versions[j], versions[i]) {
return false
}
// Prefer SQLite only when the numeric versions are equal.
return !isAIGatewayPostgresVersion(versions[i]) && isAIGatewayPostgresVersion(versions[j])
})
}
+135 -135
View File
@@ -4,11 +4,8 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"mime" "mime"
"sort"
"strconv" "strconv"
"strings" "strings"
"sync"
"time"
"github.com/1Panel-dev/1Panel/agent/app/dto" "github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/app/model" "github.com/1Panel-dev/1Panel/agent/app/model"
@@ -17,14 +14,13 @@ import (
"github.com/1Panel-dev/1Panel/agent/constant" "github.com/1Panel-dev/1Panel/agent/constant"
"github.com/1Panel-dev/1Panel/agent/global" "github.com/1Panel-dev/1Panel/agent/global"
"github.com/1Panel-dev/1Panel/agent/i18n" "github.com/1Panel-dev/1Panel/agent/i18n"
alertUtil "github.com/1Panel-dev/1Panel/agent/utils/alert"
alertconfig "github.com/1Panel-dev/1Panel/agent/utils/alert_config" alertconfig "github.com/1Panel-dev/1Panel/agent/utils/alert_config"
alertwebhook "github.com/1Panel-dev/1Panel/agent/utils/alert_webhook" alertwebhook "github.com/1Panel-dev/1Panel/agent/utils/alert_webhook"
"github.com/1Panel-dev/1Panel/agent/utils/cmd"
"github.com/1Panel-dev/1Panel/agent/utils/copier" "github.com/1Panel-dev/1Panel/agent/utils/copier"
"github.com/1Panel-dev/1Panel/agent/utils/email" "github.com/1Panel-dev/1Panel/agent/utils/email"
"github.com/1Panel-dev/1Panel/agent/utils/xpack" "github.com/1Panel-dev/1Panel/agent/utils/xpack"
"github.com/1Panel-dev/1Panel/agent/utils/xpack/providers" "github.com/1Panel-dev/1Panel/agent/utils/xpack/providers"
"github.com/shirou/gopsutil/v4/disk"
) )
type AlertService struct{} type AlertService struct{}
@@ -78,10 +74,10 @@ type IAlertService interface {
GetAlertConfig(req dto.AlertConfigQuery) ([]model.AlertConfig, error) GetAlertConfig(req dto.AlertConfigQuery) ([]model.AlertConfig, error)
PageAlertConfig(req dto.AlertConfigPageReq) (int64, []model.AlertConfig, error) PageAlertConfig(req dto.AlertConfigPageReq) (int64, []model.AlertConfig, error)
UpdateAlertConfig(req dto.AlertConfigUpdate, operator string) error UpdateAlertConfig(req dto.AlertConfigUpdate, operator string) error
UpdateAlertConfigStatus(req dto.AlertConfigStatusUpdate, operator string) error UpdateAlertConfigStatus(req dto.AlertConfigStatusUpdate, operator string) (string, error)
DeleteAlertConfig(id uint) error DeleteAlertConfig(id uint) (string, error)
TestAlertConfig(req dto.AlertConfigTest) (bool, error) TestAlertConfig(req dto.AlertConfigTest) (bool, error)
TestCustomAlertConfig(req dto.AlertConfigTest) (dto.AlertConfigTestResult, error) TestCustomAlertConfig(req dto.AlertConfigTest, lang string) (dto.AlertConfigTestResult, error)
} }
func NewIAlertService() IAlertService { func NewIAlertService() IAlertService {
@@ -109,7 +105,38 @@ func (a AlertService) PageAlert(search dto.AlertSearch) (int64, []dto.AlertDTO,
return 0, nil, err return 0, nil, err
} }
cronjobProjects := make(map[string]uint)
var cronjobIDs []uint
for _, item := range alerts { for _, item := range alerts {
if alertUtil.GetCronJobType(item.Type) != "cronJob" {
continue
}
if _, exists := cronjobProjects[item.Project]; exists {
continue
}
id, parseErr := strconv.ParseUint(item.Project, 10, strconv.IntSize)
if parseErr != nil || id == 0 {
continue
}
cronjobProjects[item.Project] = uint(id)
cronjobIDs = append(cronjobIDs, uint(id))
}
cronjobsByID := make(map[uint]model.Cronjob)
if len(cronjobIDs) > 0 {
cronjobs, err := cronjobRepo.List(repo.WithByIDs(cronjobIDs))
if err != nil {
return 0, nil, err
}
for _, cronjob := range cronjobs {
cronjobsByID[cronjob.ID] = cronjob
}
}
for _, item := range alerts {
var taskName string
if cronjob, exists := cronjobsByID[cronjobProjects[item.Project]]; exists && cronjob.Type == item.Type {
taskName = cronjob.Name
}
result = append(result, dto.AlertDTO{ result = append(result, dto.AlertDTO{
ID: item.ID, ID: item.ID,
@@ -119,6 +146,7 @@ func (a AlertService) PageAlert(search dto.AlertSearch) (int64, []dto.AlertDTO,
Method: item.Method, Method: item.Method,
Title: item.Title, Title: item.Title,
Project: item.Project, Project: item.Project,
TaskName: taskName,
Status: item.Status, Status: item.Status,
SendCount: item.SendCount, SendCount: item.SendCount,
AdvancedParams: item.AdvancedParams, AdvancedParams: item.AdvancedParams,
@@ -190,6 +218,16 @@ func (a AlertService) CreateAlert(create dto.AlertCreate, operator string) error
return err return err
} }
} else { } else {
advanced, err := prepareCronJobAlertParams(create.Type, "", create.AdvancedParams)
if err != nil {
return err
}
create.AdvancedParams = advanced
if create.Status != constant.AlertDisable {
if err := a.validateCronJobAlertChannels(create.Type, advanced, create.Method); err != nil {
return err
}
}
alertInfo.Status = constant.AlertEnable alertInfo.Status = constant.AlertEnable
if err := copier.Copy(&alertInfo, &create); err != nil { if err := copier.Copy(&alertInfo, &create); err != nil {
return buserr.WithErr("ErrStructTransform", err) return buserr.WithErr("ErrStructTransform", err)
@@ -207,11 +245,24 @@ func (a AlertService) CreateAlert(create dto.AlertCreate, operator string) error
} }
func (a AlertService) UpdateAlert(req dto.AlertUpdate, operator string) error { func (a AlertService) UpdateAlert(req dto.AlertUpdate, operator string) error {
if alertUtil.GetCronJobType(req.Type) == "cronJob" {
previous, err := alertRepo.Get(repo.WithByID(req.ID))
if err != nil {
return err
}
req.AdvancedParams, err = prepareCronJobAlertParams(req.Type, previous.AdvancedParams, req.AdvancedParams)
if err != nil {
return err
}
}
methodTypes, err := a.validateAlertMethodReferences(req.Method) methodTypes, err := a.validateAlertMethodReferences(req.Method)
if err != nil { if err != nil {
return err return err
} }
if req.Status != constant.AlertDisable { if req.Status != constant.AlertDisable {
if err := a.validateCronJobAlertChannels(req.Type, req.AdvancedParams, req.Method); err != nil {
return err
}
if err := a.validateAlertMethodEntitlement(methodTypes); err != nil { if err := a.validateAlertMethodEntitlement(methodTypes); err != nil {
return err return err
} }
@@ -278,6 +329,9 @@ func (a AlertService) UpdateStatus(id uint, status string) error {
return err return err
} }
if status == constant.AlertEnable { if status == constant.AlertEnable {
if err := a.validateCronJobAlertChannels(alertInfo.Type, alertInfo.AdvancedParams, alertInfo.Method); err != nil {
return err
}
if err := a.validateAlertMethodEntitlement(methodTypes); err != nil { if err := a.validateAlertMethodEntitlement(methodTypes); err != nil {
return err return err
} }
@@ -299,120 +353,12 @@ func (a AlertService) UpdateStatus(id uint, status string) error {
} }
func (a AlertService) GetDisks() ([]dto.DiskDTO, error) { func (a AlertService) GetDisks() ([]dto.DiskDTO, error) {
var disks []dto.DiskDTO infos := loadDiskInfo(true)
excludes := map[string]struct{}{ disks := make([]dto.DiskDTO, 0, len(infos))
"/mnt/cdrom": {}, "/boot": {}, "/boot/efi": {}, "/dev": {}, "/dev/shm": {}, for _, item := range infos {
"/run/lock": {}, "/run": {}, "/run/shm": {}, "/run/user": {}, disks = append(disks, dto.DiskDTO(item))
} }
stdout, err := executeDiskCommand()
if err != nil {
return disks, nil return disks, nil
}
lines := strings.Split(stdout, "\n")
var mounts []dto.AlertDiskInfo
for _, line := range lines {
fields := strings.Fields(line)
if len(fields) < 7 {
continue
}
mountPoint := strings.Join(fields[6:], " ")
if shouldExclude(fields, mountPoint, excludes) {
continue
}
mounts = append(mounts, dto.AlertDiskInfo{Type: fields[1], Device: fields[0], Mount: mountPoint})
}
var (
wg sync.WaitGroup
mu sync.Mutex
)
wg.Add(len(mounts))
for i := 0; i < len(mounts); i++ {
go func(timeoutCh <-chan time.Time, mount dto.AlertDiskInfo) {
defer wg.Done()
var itemData dto.DiskDTO
itemData.Path = mount.Mount
itemData.Type = mount.Type
itemData.Device = mount.Device
select {
case <-timeoutCh:
mu.Lock()
disks = append(disks, itemData)
mu.Unlock()
global.LOG.Errorf("load disk info from %s failed, err: timeout", mount.Mount)
default:
state, err := disk.Usage(mount.Mount)
if err != nil {
mu.Lock()
disks = append(disks, itemData)
mu.Unlock()
global.LOG.Errorf("load disk info from %s failed, err: %v", mount.Mount, err)
return
}
itemData.Total = state.Total
itemData.Free = state.Free
itemData.Used = state.Used
itemData.UsedPercent = state.UsedPercent
itemData.InodesTotal = state.InodesTotal
itemData.InodesUsed = state.InodesUsed
itemData.InodesFree = state.InodesFree
itemData.InodesUsedPercent = state.InodesUsedPercent
mu.Lock()
disks = append(disks, itemData)
mu.Unlock()
}
}(time.After(5*time.Second), mounts[i])
}
wg.Wait()
sort.Slice(disks, func(i, j int) bool {
return disks[i].Path < disks[j].Path
})
return disks, nil
}
func executeDiskCommand() (string, error) {
cmdMgr := cmd.NewCommandMgr(cmd.WithTimeout(2 * time.Second))
stdout, err := cmdMgr.RunWithStdout("df", "-hT", "-P")
if err != nil {
cmdMgr2 := cmd.NewCommandMgr(cmd.WithTimeout(1 * time.Second))
stdout, err = cmdMgr2.RunWithStdout("df", "-lhT", "-P")
}
if err != nil {
return stdout, err
}
var lines []string
for _, line := range strings.Split(stdout, "\n") {
if !strings.Contains(line, "/") || strings.Contains(line, "tmpfs") || strings.Contains(line, "snap/core") || strings.Contains(line, "udev") {
continue
}
lines = append(lines, line)
}
if len(lines) == 0 {
return "", nil
}
return strings.Join(lines, "\n"), nil
}
func shouldExclude(fields []string, mountPoint string, excludes map[string]struct{}) bool {
if strings.HasPrefix(mountPoint, "/snap") || len(strings.Split(mountPoint, "/")) > 10 {
return true
}
if strings.TrimSpace(fields[1]) == "tmpfs" {
return true
}
if strings.Contains(fields[2], "K") {
return true
}
if strings.Contains(mountPoint, "docker") {
return true
}
_, excluded := excludes[mountPoint]
return excluded
} }
func (a AlertService) PageAlertLogs(search dto.AlertLogSearch) (int64, []dto.AlertLogDTO, error) { func (a AlertService) PageAlertLogs(search dto.AlertLogSearch) (int64, []dto.AlertLogDTO, error) {
@@ -677,25 +623,26 @@ func (a AlertService) updateCustomAlertConfig(req dto.AlertConfigUpdate, operato
}) })
} }
func (a AlertService) UpdateAlertConfigStatus(req dto.AlertConfigStatusUpdate, operator string) error { func (a AlertService) UpdateAlertConfigStatus(req dto.AlertConfigStatusUpdate, operator string) (string, error) {
if err := validateAlertConfigStatus(req.Status); err != nil { if err := validateAlertConfigStatus(req.Status); err != nil {
return err return "", err
} }
config, err := alertRepo.GetConfigById(req.ID) config, err := alertRepo.GetConfigById(req.ID)
if err != nil { if err != nil {
return err return "", err
} }
name := alertConfigLogName(config.Config, config.Title)
if req.Status == constant.AlertEnable { if req.Status == constant.AlertEnable {
if err := a.validateCommunityAlertConfigType(config.Type); err != nil { if err := a.validateCommunityAlertConfigType(config.Type); err != nil {
return err return name, err
} }
if config.Type == constant.Custom { if config.Type == constant.Custom {
if _, err := alertwebhook.Resolve(config); err != nil { if _, err := alertwebhook.Resolve(config); err != nil {
return err return name, err
} }
} }
} }
return alertRepo.UpdateAlertConfig(map[string]interface{}{ return name, alertRepo.UpdateAlertConfig(map[string]interface{}{
"status": req.Status, "status": req.Status,
"update_user": operator, "update_user": operator,
}, repo.WithByID(req.ID)) }, repo.WithByID(req.ID))
@@ -867,19 +814,20 @@ func alertConfigSMSPhone(configData string) string {
return strings.TrimSpace(cfg.Phone) return strings.TrimSpace(cfg.Phone)
} }
func (a AlertService) DeleteAlertConfig(id uint) error { func (a AlertService) DeleteAlertConfig(id uint) (string, error) {
_, err := alertRepo.GetConfigById(id) config, err := alertRepo.GetConfigById(id)
if err != nil { if err != nil {
return err return "", err
} }
name := alertConfigLogName(config.Config, config.Title)
usedAlerts, err := alertRepo.List(alertRepo.WithByAlertMethodContainsConfigID(id)) usedAlerts, err := alertRepo.List(alertRepo.WithByAlertMethodContainsConfigID(id))
if err != nil { if err != nil {
return err return name, err
} }
if len(usedAlerts) > 0 { if len(usedAlerts) > 0 {
return buserr.New("ErrAlertConfigInUse") return name, buserr.New("ErrAlertConfigInUse")
} }
return alertRepo.DeleteAlertConfig(repo.WithByID(id)) return name, alertRepo.DeleteAlertConfig(repo.WithByID(id))
} }
func (a AlertService) TestAlertConfig(req dto.AlertConfigTest) (bool, error) { func (a AlertService) TestAlertConfig(req dto.AlertConfigTest) (bool, error) {
@@ -905,7 +853,7 @@ func (a AlertService) TestAlertConfig(req dto.AlertConfigTest) (bool, error) {
msg := email.EmailMessage{ msg := email.EmailMessage{
Subject: i18n.GetMsgByKey("TestAlertTitle"), Subject: i18n.GetMsgByKey("TestAlertTitle"),
Body: i18n.GetMsgByKey("TestAlert"), Body: i18n.GetMsgByKey("TestAlertMessage"),
IsHTML: false, IsHTML: false,
} }
transport := xpack.MultiNodeProvider.LoadRequestTransport() transport := xpack.MultiNodeProvider.LoadRequestTransport()
@@ -953,7 +901,7 @@ func resolveEmailTestConfig(req dto.AlertConfigTest) (dto.AlertEmailConfig, erro
return emailConfig, nil return emailConfig, nil
} }
func (a AlertService) TestCustomAlertConfig(req dto.AlertConfigTest) (dto.AlertConfigTestResult, error) { func (a AlertService) TestCustomAlertConfig(req dto.AlertConfigTest, lang string) (dto.AlertConfigTestResult, error) {
if req.Type != constant.Custom { if req.Type != constant.Custom {
return dto.AlertConfigTestResult{}, fmt.Errorf("alert config test type must be custom") return dto.AlertConfigTestResult{}, fmt.Errorf("alert config test type must be custom")
} }
@@ -982,12 +930,15 @@ func (a AlertService) TestCustomAlertConfig(req dto.AlertConfigTest) (dto.AlertC
} }
tester, ok := xpack.AlertProvider.(providers.CustomWebhookTester) tester, ok := xpack.AlertProvider.(providers.CustomWebhookTester)
if !ok { if !ok {
if global.LOG != nil {
global.LOG.Warnf("webhook test failed: %v", providers.ErrCustomWebhookUnsupported)
}
return dto.AlertConfigTestResult{ return dto.AlertConfigTestResult{
Success: false, Success: false,
Message: providers.ErrCustomWebhookUnsupported.Error(), Message: providers.ErrCustomWebhookUnsupported.Error(),
}, nil }, nil
} }
return tester.TestCustomWebhook(resolved) return tester.TestCustomWebhook(resolved, lang)
} }
func (a AlertService) ExternalUpdateAlert(updateAlert dto.AlertCreate, operator string) error { func (a AlertService) ExternalUpdateAlert(updateAlert dto.AlertCreate, operator string) error {
@@ -1021,6 +972,23 @@ func (a AlertService) ExternalUpdateAlert(updateAlert dto.AlertCreate, operator
alertRepo.WithByType(updateAlert.Type), alertRepo.WithByType(updateAlert.Type),
alertRepo.WithByProject(updateAlert.Project), alertRepo.WithByProject(updateAlert.Project),
) )
advanced, err := prepareCronJobAlertParams(updateAlert.Type, alertInfo.AdvancedParams, updateAlert.AdvancedParams)
if err != nil {
return err
}
updateAlert.AdvancedParams = advanced
if alertUtil.GetCronJobType(updateAlert.Type) == "cronJob" {
upMap["advanced_params"] = advanced
}
if newStatus == constant.AlertEnable {
method := updateAlert.Method
if method == "" {
method = alertInfo.Method
}
if err := a.validateCronJobAlertChannels(updateAlert.Type, advanced, method); err != nil {
return err
}
}
if alertInfo.ID > 0 { if alertInfo.ID > 0 {
shouldUpdate := false shouldUpdate := false
@@ -1034,6 +1002,9 @@ func (a AlertService) ExternalUpdateAlert(updateAlert dto.AlertCreate, operator
if val, ok := upMap["method"]; ok && val != "" && val != alertInfo.Method { if val, ok := upMap["method"]; ok && val != "" && val != alertInfo.Method {
shouldUpdate = true shouldUpdate = true
} }
if val, ok := upMap["advanced_params"]; ok && val != alertInfo.AdvancedParams {
shouldUpdate = true
}
if shouldUpdate { if shouldUpdate {
if err := alertRepo.Update( if err := alertRepo.Update(
@@ -1055,3 +1026,32 @@ func (a AlertService) ExternalUpdateAlert(updateAlert dto.AlertCreate, operator
return nil return nil
} }
func prepareCronJobAlertParams(alertType, previous, incoming string) (string, error) {
if alertUtil.GetCronJobType(alertType) != "cronJob" {
return incoming, nil
}
return alertUtil.MergeCronJobAlertParams(previous, incoming)
}
func (a AlertService) validateCronJobAlertChannels(alertType, advanced, method string) error {
if alertUtil.GetCronJobType(alertType) != "cronJob" {
return nil
}
mode, err := alertUtil.CronJobAlertTriggerMode(advanced)
if err != nil || mode != alertUtil.CronJobAlertSuccess {
return err
}
_, err = a.validateAlertMethodReferences(method)
return err
}
func alertConfigLogName(config, title string) string {
var value struct {
DisplayName string `json:"displayName"`
}
if json.Unmarshal([]byte(config), &value) == nil && strings.TrimSpace(value.DisplayName) != "" {
return strings.TrimSpace(value.DisplayName)
}
return title
}
+31 -22
View File
@@ -34,6 +34,7 @@ const (
CheckIntervalSec = 3 CheckIntervalSec = 3
LoadCheckIntervalMin = 5 LoadCheckIntervalMin = 5
sshIPLoginWindow = 30 * time.Minute sshIPLoginWindow = 30 * time.Minute
sslAutoRenewAlertSkipDays = 31
) )
type AlertTaskHelper struct { type AlertTaskHelper struct {
@@ -529,11 +530,15 @@ func loadSSHLogin(alert dto.AlertDTO) {
if err != nil { if err != nil {
global.LOG.Errorf("Failed to load ssh login logs: %v", err) global.LOG.Errorf("Failed to load ssh login logs: %v", err)
} }
interfaceAddrs, err := net.InterfaceAddrs()
if err != nil {
global.LOG.Warnf("Failed to load local IP addresses for ssh login alert: %v", err)
}
count, records := summarizeSSHLoginHistories( count, records := summarizeSSHLoginHistories(
histories, histories,
now, now,
failedWindow, failedWindow,
strings.Split(strings.TrimSpace(alert.AdvancedParams), "\n"), sshSuccessLoginWhitelist(alert.AdvancedParams, interfaceAddrs),
) )
isAlert := count >= int(alert.Count) isAlert := count >= int(alert.Count)
if isAlert { if isAlert {
@@ -569,6 +574,19 @@ func loadSSHLogin(alert dto.AlertDTO) {
} }
} }
func sshSuccessLoginWhitelist(configured string, interfaceAddrs []net.Addr) []string {
whitelist := strings.Split(strings.TrimSpace(configured), "\n")
whitelist = append(whitelist, "127.0.0.0/8", "::1")
for _, addr := range interfaceAddrs {
ipNet, ok := addr.(*net.IPNet)
if !ok || ipNet.IP == nil || ipNet.IP.IsUnspecified() {
continue
}
whitelist = append(whitelist, ipNet.IP.String())
}
return whitelist
}
func filterLoginLogsNotInWhitelist(records []model.LoginLog, whitelist []string) []model.LoginLog { func filterLoginLogsNotInWhitelist(records []model.LoginLog, whitelist []string) []model.LoginLog {
filtered := make([]model.LoginLog, 0, len(records)) filtered := make([]model.LoginLog, 0, len(records))
for _, record := range records { for _, record := range records {
@@ -864,7 +882,7 @@ func calculateSSLExpiryDays(sslList []model.WebsiteSSL, cycle uint) (map[int][]s
daysDiff := int(math.Ceil( daysDiff := int(math.Ceil(
ssl.ExpireDate.Sub(currentDate).Hours() / 24, ssl.ExpireDate.Sub(currentDate).Hours() / 24,
)) ))
if daysDiff > 0 && int(cycle) >= daysDiff { if daysDiff > 0 && int(cycle) >= daysDiff && !shouldSuppressSSLExpiryAlert(ssl, daysDiff) {
daysDiffMap[daysDiff] = append(daysDiffMap[daysDiff], ssl.PrimaryDomain) daysDiffMap[daysDiff] = append(daysDiffMap[daysDiff], ssl.PrimaryDomain)
projectMap[ssl.ID] = append(projectMap[ssl.ID], ssl.ExpireDate) projectMap[ssl.ID] = append(projectMap[ssl.ID], ssl.ExpireDate)
} }
@@ -872,6 +890,10 @@ func calculateSSLExpiryDays(sslList []model.WebsiteSSL, cycle uint) (map[int][]s
return daysDiffMap, projectMap return daysDiffMap, projectMap
} }
func shouldSuppressSSLExpiryAlert(ssl model.WebsiteSSL, remainingDays int) bool {
return ssl.AutoRenew && remainingDays < sslAutoRenewAlertSkipDays
}
func calculateWebsiteExpiryDays(websites []model.Website, cycle uint) (map[int][]string, map[uint][]time.Time) { func calculateWebsiteExpiryDays(websites []model.Website, cycle uint) (map[int][]string, map[uint][]time.Time) {
currentDate := time.Now() currentDate := time.Now()
daysDiffMap := make(map[int][]string) daysDiffMap := make(map[int][]string)
@@ -1044,50 +1066,37 @@ func processAllDisks(alert dto.AlertDTO) error {
global.LOG.Errorf("error getting disk list, err: %v", err) global.LOG.Errorf("error getting disk list, err: %v", err)
return err return err
} }
var errMsgs []string
for _, item := range diskList { for _, item := range diskList {
err := checkAndCreateDiskAlert(alert, item.Path) if item.Total == 0 {
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 continue
} }
} checkAndCreateDiskAlert(alert, item.Path, &disk.UsageStat{Used: item.Used, UsedPercent: item.UsedPercent})
if len(errMsgs) > 0 {
return fmt.Errorf("batch process disks failed, error count: %d, details: %s", len(errMsgs), strings.Join(errMsgs, "; "))
} }
return nil return nil
} }
func processSingleDisk(alert dto.AlertDTO) error { func processSingleDisk(alert dto.AlertDTO) error {
err := checkAndCreateDiskAlert(alert, alert.Project) usageStat, err := loadDiskUsageWithTimeout(alert.Project, true)
if err != nil { if err != nil {
global.LOG.Errorf("%s", err.Error()) global.LOG.Errorf("error getting disk usage for %s, err: %v", alert.Project, err)
return err return err
} }
checkAndCreateDiskAlert(alert, alert.Project, usageStat)
return nil return nil
} }
func checkAndCreateDiskAlert(alert dto.AlertDTO, path string) error { func checkAndCreateDiskAlert(alert dto.AlertDTO, path string, usageStat *disk.UsageStat) {
usageStat, err := psutil.DISK.GetUsage(path, false)
if err != nil {
global.LOG.Errorf("error getting disk usage for %s, err: %v", path, err)
return err
}
usedTotal, usedStr := calculateUsedTotal(alert.Cycle, usageStat) usedTotal, usedStr := calculateUsedTotal(alert.Cycle, usageStat)
commonTotal := float64(alert.Count) commonTotal := float64(alert.Count)
if alert.Cycle == 1 { if alert.Cycle == 1 {
commonTotal *= 1024 * 1024 * 1024 commonTotal *= 1024 * 1024 * 1024
} }
if usedTotal < commonTotal { if usedTotal < commonTotal {
return nil return
} }
params := createAlertDiskParams(path, usedStr) params := createAlertDiskParams(path, usedStr)
sender := NewAlertSender(alert, alert.Project) sender := NewAlertSender(alert, alert.Project)
sender.ResourceSend(path, params) sender.ResourceSend(path, params)
return nil
} }
func calculateUsedTotal(cycle uint, usageStat *disk.UsageStat) (float64, string) { func calculateUsedTotal(cycle uint, usageStat *disk.UsageStat) (float64, string) {
+51 -4
View File
@@ -60,6 +60,7 @@ type IAppService interface {
type appInstallHooks struct { type appInstallHooks struct {
AfterCopyData func(appInstall *model.AppInstall) error AfterCopyData func(appInstall *model.AppInstall) error
PrepareSiteDir func(model.App, model.AppDetail, request.AppInstallCreate, *task.Task) error
} }
func NewIAppService() IAppService { func NewIAppService() IAppService {
@@ -258,13 +259,22 @@ func (a AppService) GetAppDetail(appID uint, version, appType string) (response.
fileOp := files.NewFileOp() fileOp := files.NewFileOp()
versionPath := filepath.Join(app.GetAppResourcePath(), detail.Version) versionPath := filepath.Join(app.GetAppResourcePath(), detail.Version)
if !fileOp.Stat(versionPath) || detail.Update {
if err = downloadApp(app, detail, nil, nil); err != nil && !fileOp.Stat(versionPath) { if err = downloadApp(app, detail, nil, nil); err != nil && !fileOp.Stat(versionPath) {
return appDetailDTO, err return appDetailDTO, err
} }
if err == nil && app.Resource == constant.AppResourceRemote {
detail.Update = false
appDetailDTO.Update = false
} }
switch app.Type { switch app.Type {
case constant.RuntimePHP: case constant.RuntimePHP:
paramMap := make(map[string]interface{})
if app.Resource == constant.AppResourceRemote {
if err = json.Unmarshal([]byte(detail.Params), &paramMap); err != nil {
return appDetailDTO, err
}
appDetailDTO.Params = paramMap
} else {
paramsPath := filepath.Join(versionPath, "data.yml") paramsPath := filepath.Join(versionPath, "data.yml")
if !fileOp.Stat(paramsPath) { if !fileOp.Stat(paramsPath) {
return appDetailDTO, buserr.WithDetail("ErrFileNotExist", paramsPath, nil) return appDetailDTO, buserr.WithDetail("ErrFileNotExist", paramsPath, nil)
@@ -273,11 +283,11 @@ func (a AppService) GetAppDetail(appID uint, version, appType string) (response.
if err != nil { if err != nil {
return appDetailDTO, err return appDetailDTO, err
} }
paramMap := make(map[string]interface{})
if err = yaml.Unmarshal(param, &paramMap); err != nil { if err = yaml.Unmarshal(param, &paramMap); err != nil {
return appDetailDTO, err return appDetailDTO, err
} }
appDetailDTO.Params = paramMap["additionalProperties"] appDetailDTO.Params = paramMap["additionalProperties"]
}
composePath := filepath.Join(versionPath, "docker-compose.yml") composePath := filepath.Join(versionPath, "docker-compose.yml")
if !fileOp.Stat(composePath) { if !fileOp.Stat(composePath) {
return appDetailDTO, buserr.WithDetail("ErrFileNotExist", composePath, nil) return appDetailDTO, buserr.WithDetail("ErrFileNotExist", composePath, nil)
@@ -308,6 +318,12 @@ func (a AppService) GetAppDetail(appID uint, version, appType string) (response.
if appDetailDTO.DockerCompose == "" { if appDetailDTO.DockerCompose == "" {
filename := filepath.Base(appDetailDTO.DownloadUrl) filename := filepath.Base(appDetailDTO.DownloadUrl)
dockerComposeUrl := fmt.Sprintf("%s%s", strings.TrimSuffix(appDetailDTO.DownloadUrl, filename), "docker-compose.yml") dockerComposeUrl := fmt.Sprintf("%s%s", strings.TrimSuffix(appDetailDTO.DownloadUrl, filename), "docker-compose.yml")
if app.Resource == constant.AppResourceRemote {
dockerComposeUrl, err = currentAppDownloadURL(dockerComposeUrl)
if err != nil {
return appDetailDTO, err
}
}
statusCode, composeRes, err := req_helper.HandleRequest(dockerComposeUrl, http.MethodGet, constant.TimeOut20s) statusCode, composeRes, err := req_helper.HandleRequest(dockerComposeUrl, http.MethodGet, constant.TimeOut20s)
if statusCode == http.StatusNotFound { if statusCode == http.StatusNotFound {
return appDetailDTO, buserr.New("ErrAppVersionUnavailable") return appDetailDTO, buserr.New("ErrAppVersionUnavailable")
@@ -434,6 +450,12 @@ func (a AppService) installWithHooks(req request.AppInstallCreate, executeScript
} else { } else {
if appDetail.DockerCompose == "" { if appDetail.DockerCompose == "" {
dockerComposeUrl := fmt.Sprintf("%s/%s/1panel/%s/%s/docker-compose.yml", global.AppRepoURL(), global.CONF.Base.Mode, app.Key, appDetail.Version) dockerComposeUrl := fmt.Sprintf("%s/%s/1panel/%s/%s/docker-compose.yml", global.AppRepoURL(), global.CONF.Base.Mode, app.Key, appDetail.Version)
if app.Resource == constant.AppResourceRemote {
dockerComposeUrl, err = currentAppDownloadURL(dockerComposeUrl)
if err != nil {
return
}
}
var statusCode int var statusCode int
statusCode, composeRes, err = req_helper.HandleRequest(dockerComposeUrl, http.MethodGet, constant.TimeOut20s) statusCode, composeRes, err = req_helper.HandleRequest(dockerComposeUrl, http.MethodGet, constant.TimeOut20s)
if statusCode == http.StatusNotFound { if statusCode == http.StatusNotFound {
@@ -478,6 +500,22 @@ func (a AppService) installWithHooks(req request.AppInstallCreate, executeScript
req.Params[constant.ContainerName] = containerName req.Params[constant.ContainerName] = containerName
appInstall.ContainerName = containerName appInstall.ContainerName = containerName
modelAccountBaseURL := ""
if app.Key == "ollama" {
appInstall.SyncModelAccount = req.SyncModelAccount == nil || *req.SyncModelAccount
appInstall.ModelAccountBaseURLType = req.ModelAccountBaseURLType
if appInstall.ModelAccountBaseURLType == "" {
appInstall.ModelAccountBaseURLType = "container"
}
appInstall.ModelAccountBaseURL = req.ModelAccountBaseURL
}
if app.Key == "ollama" && appInstall.SyncModelAccount {
modelAccountBaseURL, err = ResolveLocalModelAccountBaseURL(req.ModelAccountBaseURLType, req.ModelAccountBaseURL, containerName, "11434", httpPort)
if err != nil {
return
}
}
index := 0 index := 0
serviceName := "" serviceName := ""
for k := range servicesMap { for k := range servicesMap {
@@ -550,6 +588,11 @@ func (a AppService) installWithHooks(req request.AppInstallCreate, executeScript
installTask, err := task.NewTaskWithOps(appInstall.Name, task.TaskInstall, task.TaskScopeApp, req.TaskID, appInstall.ID) installTask, err := task.NewTaskWithOps(appInstall.Name, task.TaskInstall, task.TaskScopeApp, req.TaskID, appInstall.ID)
if err != nil { if err != nil {
appInstall.Status = constant.StatusInstallErr
appInstall.Message = err.Error()
if saveErr := appInstallRepo.Save(context.Background(), appInstall); saveErr != nil {
err = fmt.Errorf("%w; save failed install status: %v", err, saveErr)
}
return return
} }
@@ -557,7 +600,7 @@ func (a AppService) installWithHooks(req request.AppInstallCreate, executeScript
return return
} }
installApp := func(t *task.Task) error { installApp := func(t *task.Task) (err error) {
if err = copyData(t, app, appDetail, appInstall, req); err != nil { if err = copyData(t, app, appDetail, appInstall, req); err != nil {
return err return err
} }
@@ -572,7 +615,7 @@ func (a AppService) installWithHooks(req request.AppInstallCreate, executeScript
} }
} }
if app.Key == "openresty" { if app.Key == "openresty" {
if err = handleSiteDir(app, appDetail, req, t); err != nil { if err = prepareAppInstallSiteDirectory(app, appDetail, req, t, hooks); err != nil {
return err return err
} }
if err = handleOpenrestyFile(appInstall); err != nil { if err = handleOpenrestyFile(appInstall); err != nil {
@@ -583,6 +626,10 @@ func (a AppService) installWithHooks(req request.AppInstallCreate, executeScript
return err return err
} }
updateToolApp(appInstall) updateToolApp(appInstall)
if app.Key == "ollama" && appInstall.SyncModelAccount {
_, err := SyncLocalModelAccount(*appInstall, 0, modelAccountBaseURL, nil, false, true)
return err
}
return nil return nil
} }
+114 -7
View File
@@ -159,6 +159,14 @@ func (a *AppInstallService) CheckExist(req request.AppInstalledInfo) (*response.
} }
res.ContainerName = appInstall.ContainerName res.ContainerName = appInstall.ContainerName
res.ModelAccountBaseURLType = appInstall.ModelAccountBaseURLType
res.ModelAccountBaseURL = appInstall.ModelAccountBaseURL
if app.Key == "ollama" {
res.ModelAccountBaseURLType, res.ModelAccountBaseURL, err = loadOllamaModelAccountAddress(appInstall)
if err != nil {
return nil, err
}
}
res.Name = appInstall.Name res.Name = appInstall.Name
res.Version = appInstall.Version res.Version = appInstall.Version
res.CreatedAt = appInstall.CreatedAt res.CreatedAt = appInstall.CreatedAt
@@ -297,6 +305,7 @@ func (a *AppInstallService) Operate(req request.AppInstalledOperate) error {
return syncAppInstallStatus(&install, true) return syncAppInstallStatus(&install, true)
case constant.Upgrade: case constant.Upgrade:
upgradeReq := request.AppInstallUpgrade{ upgradeReq := request.AppInstallUpgrade{
FeishuTransition: req.FeishuTransition,
InstallID: install.ID, InstallID: install.ID,
DetailID: req.DetailId, DetailID: req.DetailId,
Backup: req.Backup, Backup: req.Backup,
@@ -339,6 +348,11 @@ func operateAppWithLifecycleScripts(install model.AppInstall, req request.AppIns
operationTask, err := task.NewTaskWithOps(install.Name, taskType, task.TaskScopeApp, req.TaskID, install.ID) operationTask, err := task.NewTaskWithOps(install.Name, taskType, task.TaskScopeApp, req.TaskID, install.ID)
if err != nil { if err != nil {
install.Status = constant.StatusUpErr
install.Message = err.Error()
if saveErr := appInstallRepo.Save(context.Background(), &install); saveErr != nil {
return fmt.Errorf("%w; save failed operation status: %v", err, saveErr)
}
return err return err
} }
operation := string(req.Operate) operation := string(req.Operate)
@@ -433,6 +447,32 @@ func (a *AppInstallService) Update(req request.AppInstalledUpdate) error {
} }
} }
modelAccountBaseURL := ""
if installed.App.Key == "ollama" {
if req.SyncModelAccount != nil {
installed.SyncModelAccount = *req.SyncModelAccount
}
if req.ModelAccountBaseURLType != "" {
installed.ModelAccountBaseURLType = req.ModelAccountBaseURLType
installed.ModelAccountBaseURL = req.ModelAccountBaseURL
} else if installed.SyncModelAccount {
installed.ModelAccountBaseURLType, installed.ModelAccountBaseURL, err = loadOllamaModelAccountAddress(oldInstalled)
if err != nil {
return err
}
}
if installed.SyncModelAccount {
containerName := installed.ContainerName
if req.Advanced && req.ContainerName != "" {
containerName = req.ContainerName
}
modelAccountBaseURL, err = ResolveLocalModelAccountBaseURL(installed.ModelAccountBaseURLType, installed.ModelAccountBaseURL, containerName, "11434", installed.HttpPort)
if err != nil {
return err
}
}
}
backupDockerCompose := installed.DockerCompose backupDockerCompose := installed.DockerCompose
if req.Advanced { if req.Advanced {
composeMap := make(map[string]interface{}) composeMap := make(map[string]interface{})
@@ -558,6 +598,9 @@ func (a *AppInstallService) Update(req request.AppInstalledUpdate) error {
if proxyChanged && website.ID != 0 && currentProxyErr != nil { if proxyChanged && website.ID != 0 && currentProxyErr != nil {
global.LOG.Error(buserr.WithErr("ErrUpdateBuWebsite", currentProxyErr).Error()) global.LOG.Error(buserr.WithErr("ErrUpdateBuWebsite", currentProxyErr).Error())
} }
if installed.App.Key == "ollama" && installed.SyncModelAccount {
return syncOllamaModelAccount(installed, modelAccountBaseURL, true, true)
}
return nil return nil
} }
@@ -567,11 +610,15 @@ func (a *AppInstallService) SyncAll(systemInit bool) error {
return err return err
} }
for _, i := range allList { for _, i := range allList {
if i.Status == constant.StatusInstalling || i.Status == constant.StatusUpgrading || i.Status == constant.StatusRebuilding || i.Status == constant.StatusUninstalling { if appInstallOperationInterruptedOnRestart(i.Status) {
if systemInit { if systemInit {
i.Status = constant.StatusError i.Status = appInstallOperationFailureStatus(i.Status)
i.Message = "1Panel restart causes the task to terminate" i.Message = constant.InterruptedMsg
_ = appInstallRepo.Save(context.Background(), &i) if err := appInstallRepo.Save(context.Background(), &i); err != nil {
return err
}
} else if err := syncAppInstallStatus(&i, false); err != nil {
global.LOG.Errorf("sync install app[%s] error,mgs: %s", i.Name, err.Error())
} }
continue continue
} }
@@ -670,6 +717,7 @@ func (a *AppInstallService) GetUpdateVersions(req request.AppUpdateVersion) ([]d
if err != nil { if err != nil {
return versions, err return versions, err
} }
var feishuLegacy, feishuConfigured, feishuChecked bool
details, err := appDetailRepo.GetBy(appDetailRepo.WithAppId(app.ID)) details, err := appDetailRepo.GetBy(appDetailRepo.WithAppId(app.ID))
if err != nil { if err != nil {
return versions, err return versions, err
@@ -678,23 +726,44 @@ func (a *AppInstallService) GetUpdateVersions(req request.AppUpdateVersion) ([]d
if !canAccessVllmVersion(app.Key, detail.Version) { if !canAccessVllmVersion(app.Key, detail.Version) {
continue continue
} }
if !isAIGatewayUpgradeVersionAllowed(app.Key, install.Version, detail.Version) {
continue
}
ignores, _ := appIgnoreUpgradeRepo.List(runtimeRepo.WithDetailId(detail.ID), appIgnoreUpgradeRepo.WithScope("version")) ignores, _ := appIgnoreUpgradeRepo.List(runtimeRepo.WithDetailId(detail.ID), appIgnoreUpgradeRepo.WithScope("version"))
if len(ignores) > 0 { if len(ignores) > 0 {
continue continue
} }
if common.IsCrossVersion(install.Version, detail.Version) && !app.CrossVersionUpdate { if isCrossAppVersion(app.Key, install.Version, detail.Version) && !app.CrossVersionUpdate {
continue continue
} }
canUpgrade := common.CompareVersion(detail.Version, install.Version) canUpgrade := isNewerAppVersion(app.Key, detail.Version, install.Version)
if app.Key == vllmAppKeyForUpgrade { if app.Key == vllmAppKeyForUpgrade {
currentImage := loadVllmImageFromEnv(install.Env) currentImage := loadVllmImageFromEnv(install.Env)
canUpgrade = isVllmUpgradeCandidate(install.Version, detail.Version, currentImage) canUpgrade = isVllmUpgradeCandidate(install.Version, detail.Version, currentImage)
} }
if canUpgrade { if canUpgrade {
feishuTarget := app.Key == constant.AppOpenclaw && isOpenclawBundledFeishuVersion(detail.Version)
if feishuTarget && !feishuChecked {
conf, readErr := readOpenclawConfig(path.Join(install.GetPath(), "data/conf/openclaw.json"))
if readErr != nil {
return versions, readErr
}
feishuLegacy, feishuConfigured, err = classifyLegacyFeishu(conf, install.GetPath())
if err != nil {
return versions, err
}
feishuChecked = true
}
var newCompose string var newCompose string
if req.UpdateVersion != "" && req.UpdateVersion == detail.Version && detail.DockerCompose == "" && !app.IsLocalApp() { if req.UpdateVersion != "" && req.UpdateVersion == detail.Version && detail.DockerCompose == "" && !app.IsLocalApp() {
filename := filepath.Base(detail.DownloadUrl) filename := filepath.Base(detail.DownloadUrl)
dockerComposeUrl := fmt.Sprintf("%s%s", strings.TrimSuffix(detail.DownloadUrl, filename), "docker-compose.yml") dockerComposeUrl := fmt.Sprintf("%s%s", strings.TrimSuffix(detail.DownloadUrl, filename), "docker-compose.yml")
if app.Resource == constant.AppResourceRemote {
dockerComposeUrl, err = currentAppDownloadURL(dockerComposeUrl)
if err != nil {
return versions, err
}
}
statusCode, composeRes, err := req_helper.HandleRequest(dockerComposeUrl, http.MethodGet, constant.TimeOut20s) statusCode, composeRes, err := req_helper.HandleRequest(dockerComposeUrl, http.MethodGet, constant.TimeOut20s)
if statusCode == http.StatusNotFound { if statusCode == http.StatusNotFound {
return versions, buserr.New("ErrAppVersionUnavailable") return versions, buserr.New("ErrAppVersionUnavailable")
@@ -719,6 +788,8 @@ func (a *AppInstallService) GetUpdateVersions(req request.AppUpdateVersion) ([]d
} }
} }
versions = append(versions, dto.AppVersion{ versions = append(versions, dto.AppVersion{
FeishuLegacy: feishuTarget && feishuLegacy,
FeishuConfigured: feishuTarget && feishuConfigured,
Version: detail.Version, Version: detail.Version,
DetailId: detail.ID, DetailId: detail.ID,
DockerCompose: newCompose, DockerCompose: newCompose,
@@ -726,7 +797,7 @@ func (a *AppInstallService) GetUpdateVersions(req request.AppUpdateVersion) ([]d
} }
} }
sort.Slice(versions, func(i, j int) bool { sort.Slice(versions, func(i, j int) bool {
return common.CompareVersion(versions[i].Version, versions[j].Version) return appVersionSortBefore(app.Key, versions[i].Version, versions[j].Version)
}) })
return versions, nil return versions, nil
} }
@@ -916,6 +987,18 @@ func (a *AppInstallService) GetParams(id uint) (*response.AppConfig, error) {
config := getAppCommonConfig(envs) config := getAppCommonConfig(envs)
config.DockerCompose = install.DockerCompose config.DockerCompose = install.DockerCompose
res.Params = params res.Params = params
res.AppKey = install.App.Key
res.Name = install.Name
res.HttpPort = install.HttpPort
res.SyncModelAccount = install.SyncModelAccount
res.ModelAccountBaseURLType = install.ModelAccountBaseURLType
res.ModelAccountBaseURL = install.ModelAccountBaseURL
if install.App.Key == "ollama" {
res.ModelAccountBaseURLType, res.ModelAccountBaseURL, err = loadOllamaModelAccountAddress(install)
if err != nil {
return nil, err
}
}
if config.ContainerName == "" { if config.ContainerName == "" {
config.ContainerName = install.ContainerName config.ContainerName = install.ContainerName
} }
@@ -931,10 +1014,34 @@ func (a *AppInstallService) GetParams(id uint) (*response.AppConfig, error) {
} }
func syncAppInstallStatus(appInstall *model.AppInstall, force bool) error { func syncAppInstallStatus(appInstall *model.AppInstall, force bool) error {
operation := ""
switch appInstall.Status {
case constant.StatusInstalling:
operation = task.TaskInstall
case constant.StatusUpgrading:
operation = task.TaskUpgrade
case constant.StatusRebuilding:
operation = task.TaskBuild
case constant.StatusUninstalling:
operation = task.TaskUninstall
case constant.StatusStarting, constant.StatusWaiting:
operation = task.TaskUpdate
case constant.StatusRestarting:
operation = task.TaskRestart
}
if operation != "" {
if err := reconcileAppInstallTaskFailure(appInstall, operation, global.DB, global.TaskDB); err != nil {
return err
}
}
switch appInstall.Status { switch appInstall.Status {
case constant.StatusInstalling, constant.StatusRebuilding, constant.StatusUpgrading, constant.StatusUninstalling, case constant.StatusInstalling, constant.StatusRebuilding, constant.StatusUpgrading, constant.StatusUninstalling,
constant.StatusStarting, constant.StatusRestarting, constant.StatusWaiting: constant.StatusStarting, constant.StatusRestarting, constant.StatusWaiting:
return nil return nil
case constant.StatusInstallErr, constant.StatusUpgradeErr, constant.StatusUpErr:
if !force {
return nil
}
} }
cli, err := docker.NewClient() cli, err := docker.NewClient()
if err != nil { if err != nil {
+19
View File
@@ -0,0 +1,19 @@
package service
import (
"github.com/1Panel-dev/1Panel/agent/app/dto/request"
"github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/app/task"
)
func (a AppService) InstallForMCP(req request.AppInstallCreate, executeScript bool) (*model.AppInstall, error) {
return a.installWithHooks(req, executeScript, &appInstallHooks{PrepareSiteDir: prepareMCPInstallSiteDirectory})
}
func prepareMCPInstallSiteDirectory(app model.App, detail model.AppDetail, req request.AppInstallCreate, _ *task.Task) error {
if !managesOpenrestySiteDirectory(app, detail) {
return nil
}
directory, _ := req.Params["WEBSITE_DIR"].(string)
return ensureCurrentWebsiteDirectory(directory)
}
+76
View File
@@ -0,0 +1,76 @@
package service
import (
"errors"
"github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/constant"
"gorm.io/gorm"
)
func appInstallOperationFailureStatus(status string) string {
switch status {
case constant.StatusInstalling:
return constant.StatusInstallErr
case constant.StatusUpgrading:
return constant.StatusUpgradeErr
case constant.StatusUninstalling:
return constant.StatusError
default:
return constant.StatusUpErr
}
}
func appInstallOperationPending(status string) bool {
switch status {
case constant.StatusInstalling, constant.StatusRebuilding, constant.StatusUpgrading, constant.StatusUninstalling,
constant.StatusStarting, constant.StatusRestarting, constant.StatusWaiting:
return true
default:
return false
}
}
func appInstallOperationInterruptedOnRestart(status string) bool {
// ReStarting can also describe a container's Docker restart policy.
return appInstallOperationPending(status) && status != constant.StatusRestarting
}
func reconcileAppInstallTaskFailure(install *model.AppInstall, operation string, appDB, taskDB *gorm.DB) error {
if !appInstallOperationPending(install.Status) {
return nil
}
var operationTask model.Task
err := taskDB.Where("type = ? AND resource_id = ? AND operate = ?", "App", install.ID, operation).
Order("created_at DESC").First(&operationTask).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil
}
if err != nil {
return err
}
if operationTask.Status != constant.StatusFailed && operationTask.Status != constant.StatusCanceled {
return nil
}
// A previous operation must not turn a newly queued retry into a failure.
if operationTask.CreatedAt.Before(install.UpdatedAt) &&
(operationTask.EndAt.IsZero() || operationTask.EndAt.Before(install.UpdatedAt)) {
return nil
}
message := operationTask.ErrorMsg
if message == "" {
message = "the application operation task failed"
}
status := appInstallOperationFailureStatus(install.Status)
result := appDB.Model(&model.AppInstall{}).
Where("id = ? AND status = ? AND updated_at = ?", install.ID, install.Status, install.UpdatedAt).
Updates(map[string]interface{}{"status": status, "message": message})
if result.Error != nil {
return result.Error
}
if result.RowsAffected > 0 {
install.Status = status
install.Message = message
}
return nil
}
@@ -0,0 +1,147 @@
package service
import (
"path/filepath"
"testing"
"time"
"github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/constant"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"gorm.io/gorm/logger"
)
func operationStatusTestDB(t *testing.T) (*gorm.DB, *gorm.DB, model.AppInstall) {
t.Helper()
open := func(name string, schema interface{}) *gorm.DB {
db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), name)), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(schema); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
sqlDB, _ := db.DB()
if sqlDB != nil {
_ = sqlDB.Close()
}
})
return db
}
appDB := open("apps.db", &model.AppInstall{})
taskDB := open("tasks.db", &model.Task{})
install := model.AppInstall{
Name: "gb10-ds-vision", AppId: 1, AppDetailId: 1,
Version: "nvidia-gb10-dspark-0.1.1", ContainerName: "vllm-dspark-1", ServiceName: "vllm-dspark",
Status: constant.StatusStarting, Env: `{"MODEL_DIR":"/models/vision"}`,
}
if err := appDB.Omit(clause.Associations).Create(&install).Error; err != nil {
t.Fatal(err)
}
return appDB, taskDB, install
}
func TestAppInstallFailedTaskRecoversPendingState(t *testing.T) {
for _, state := range []string{constant.StatusStarting, constant.StatusWaiting, constant.StatusRestarting, constant.StatusInstalling} {
t.Run(state, func(t *testing.T) {
appDB, taskDB, install := operationStatusTestDB(t)
install.Status = state
if err := appDB.Omit(clause.Associations).Save(&install).Error; err != nil {
t.Fatal(err)
}
task := model.Task{ID: "failed", Type: "App", Operate: "TaskUpdate", ResourceID: install.ID,
Status: constant.StatusFailed, ErrorMsg: "model startup script failed", CreatedAt: install.UpdatedAt.Add(time.Second), EndAt: install.UpdatedAt.Add(2 * time.Second)}
if err := taskDB.Create(&task).Error; err != nil {
t.Fatal(err)
}
if err := reconcileAppInstallTaskFailure(&install, task.Operate, appDB, taskDB); err != nil {
t.Fatal(err)
}
want := constant.StatusUpErr
if state == constant.StatusInstalling {
want = constant.StatusInstallErr
}
var stored model.AppInstall
if err := appDB.First(&stored, install.ID).Error; err != nil {
t.Fatal(err)
}
if install.Status != want || stored.Status != want || stored.Message != task.ErrorMsg || stored.Env != install.Env {
t.Fatalf("recovered state = %s/%s, message %q, env %q", install.Status, stored.Status, stored.Message, stored.Env)
}
})
}
}
func TestAppInstallPendingStateKeepsActiveAndUnrelatedTasks(t *testing.T) {
for _, taskStatus := range []string{constant.StatusExecuting, constant.StatusSuccess, "missing", "previous-failure", "other-resource"} {
t.Run(taskStatus, func(t *testing.T) {
appDB, taskDB, install := operationStatusTestDB(t)
task := model.Task{ID: "task", Type: "App", Operate: "TaskUpdate", ResourceID: install.ID,
Status: taskStatus, CreatedAt: install.UpdatedAt.Add(time.Second), EndAt: install.UpdatedAt.Add(2 * time.Second)}
if taskStatus == "previous-failure" {
task.Status = constant.StatusFailed
task.CreatedAt = install.UpdatedAt.Add(-2 * time.Hour)
task.EndAt = install.UpdatedAt.Add(-time.Hour)
}
if taskStatus == "other-resource" {
task.Status = constant.StatusFailed
task.ResourceID++
}
if taskStatus != "missing" {
if err := taskDB.Create(&task).Error; err != nil {
t.Fatal(err)
}
}
if err := reconcileAppInstallTaskFailure(&install, task.Operate, appDB, taskDB); err != nil {
t.Fatal(err)
}
if install.Status != constant.StatusStarting {
t.Fatalf("unrelated or active task changed pending state to %s", install.Status)
}
})
}
}
func TestAppInstallRecoveryDoesNotOverwriteConcurrentRetry(t *testing.T) {
appDB, taskDB, install := operationStatusTestDB(t)
task := model.Task{ID: "failed", Type: "App", Operate: "TaskUpdate", ResourceID: install.ID,
Status: constant.StatusCanceled, ErrorMsg: "interrupted", CreatedAt: install.UpdatedAt.Add(time.Second), EndAt: install.UpdatedAt.Add(2 * time.Second)}
if err := taskDB.Create(&task).Error; err != nil {
t.Fatal(err)
}
if err := appDB.Callback().Update().Before("gorm:update").Register("new-operation", func(db *gorm.DB) {
if err := db.Exec("UPDATE app_installs SET updated_at = ?, message = ? WHERE id = ?", install.UpdatedAt.Add(time.Hour), "new attempt", install.ID).Error; err != nil {
t.Error(err)
}
}); err != nil {
t.Fatal(err)
}
if err := reconcileAppInstallTaskFailure(&install, task.Operate, appDB, taskDB); err != nil {
t.Fatal(err)
}
var stored model.AppInstall
if err := appDB.First(&stored, install.ID).Error; err != nil {
t.Fatal(err)
}
if stored.Status != constant.StatusStarting || stored.Message != "new attempt" {
t.Fatalf("recovery overwrote a newer operation: %s, %s", stored.Status, stored.Message)
}
}
func TestAppInstallRestartRecoveryCoversLifecycleStates(t *testing.T) {
for _, state := range []string{constant.StatusStarting, constant.StatusWaiting, constant.StatusInstalling} {
if !appInstallOperationInterruptedOnRestart(state) {
t.Errorf("restart recovery omitted %s", state)
}
}
for _, state := range []string{constant.StatusRunning, constant.StatusStopped, constant.StatusRestarting} {
if appInstallOperationInterruptedOnRestart(state) {
t.Errorf("restart recovery changes a Docker runtime state: %s", state)
}
}
}
+49
View File
@@ -0,0 +1,49 @@
package service
import (
"context"
"errors"
"os"
"path"
"github.com/1Panel-dev/1Panel/agent/app/model"
"github.com/1Panel-dev/1Panel/agent/constant"
"github.com/1Panel-dev/1Panel/agent/global"
"gorm.io/gorm"
)
func ensureCurrentWebsiteDirectory(directory string) error {
changed := errors.New("website directory changed; inspect the installation before retrying")
if directory == "" || global.DB == nil {
return changed
}
return global.DB.Transaction(func(tx *gorm.DB) error {
ctx := context.WithValue(tx.Statement.Context, constant.DB, tx)
setting, err := settingRepo.GetWithContext(ctx, settingRepo.WithByKey("WEBSITE_DIR"))
missing := errors.Is(err, gorm.ErrRecordNotFound)
if err != nil && !missing {
return err
}
current := setting.Value
if current == "" {
current = path.Join(global.Dir.BaseDir, "1panel", "www")
}
if directory != current {
return changed
}
if missing {
if err := settingRepo.CreateWithContext(ctx, &model.Setting{Key: "WEBSITE_DIR", Value: directory}); err != nil {
return err
}
} else if setting.Value == "" {
updated, err := settingRepo.UpdateIfMatch(ctx, setting.ID, "", directory)
if err != nil {
return err
}
if updated != 1 {
return changed
}
}
return os.MkdirAll(path.Join(directory, "conf.d"), constant.DirPerm)
})
}
+5 -1
View File
@@ -191,7 +191,11 @@ func (c *appSyncContext) processOneApp(item appWorkItem) appWorkResult {
if _, ok := InitTypes[app.Type]; ok { if _, ok := InitTypes[app.Type]; ok {
dockerComposeUrl := fmt.Sprintf("%s/%s", versionUrl, "docker-compose.yml") dockerComposeUrl := fmt.Sprintf("%s/%s", versionUrl, "docker-compose.yml")
_, composeRes, err := req_helper.HandleRequestWithClient(&c.httpClient, dockerComposeUrl, http.MethodGet, constant.TimeOut20s) dockerComposeUrl, err := appDownloadURL(dockerComposeUrl, strconv.Itoa(c.list.LastModified))
var composeRes []byte
if err == nil {
_, composeRes, err = req_helper.HandleRequestWithClient(&c.httpClient, dockerComposeUrl, http.MethodGet, constant.TimeOut20s)
}
if err == nil { if err == nil {
detail.DockerCompose = string(composeRes) detail.DockerCompose = string(composeRes)
} else { } else {
+99 -54
View File
@@ -40,14 +40,13 @@ const (
appUpgradeDown appUpgradeDown
appUpgradeMutated appUpgradeMutated
appUpgradeStarted appUpgradeStarted
appUpgradeReady
appUpgradeCommitted appUpgradeCommitted
) )
const composeServiceLabel = "com.docker.compose.service"
var appUpgradeLocks sync.Map var appUpgradeLocks sync.Map
const composeServiceLabel = "com.docker.compose.service"
type appUpgradeSnapshot interface { type appUpgradeSnapshot interface {
Restore() error Restore() error
Cleanup() Cleanup()
@@ -61,6 +60,12 @@ type upgradeFileSnapshot struct {
} }
type appUpgradeContext struct { type appUpgradeContext struct {
openclawOriginal []byte
feishuOriginal []byte
feishuImage string
feishuOldImage string
feishuEnabled bool
feishuLockPath string
req request.AppInstallUpgrade req request.AppInstallUpgrade
original model.AppInstall original model.AppInstall
candidate model.AppInstall candidate model.AppInstall
@@ -103,10 +108,31 @@ func upgradeInstall(req request.AppInstallUpgrade) error {
} }
}() }()
feishuLockPath := ""
if install.App.Key == constant.AppOpenclaw {
feishuLockPath = path.Join(install.GetPath(), "data/conf/openclaw.json")
if _, busy := feishuSwitches.LoadOrStore(feishuLockPath, true); busy {
return buserr.New("TaskIsExecuting")
}
defer func() {
if releaseLock {
feishuSwitches.Delete(feishuLockPath)
}
}()
}
detail, err := appDetailRepo.GetFirst(repo.WithByID(req.DetailID)) detail, err := appDetailRepo.GetFirst(repo.WithByID(req.DetailID))
if err != nil { if err != nil {
return err return err
} }
if !isAIGatewayUpgradeVersionAllowed(install.App.Key, install.Version, detail.Version) {
return buserr.New("ErrAIGatewayDatabaseTypeMismatch")
}
if isHermesApp(install.App.Key) &&
(!isNewerAppVersion(install.App.Key, detail.Version, install.Version) ||
(isCrossAppVersion(install.App.Key, install.Version, detail.Version) && !install.App.CrossVersionUpdate)) {
return buserr.New("ErrHermesUpgradeVersion")
}
if err = checkVllmVersionAccess(install.App.Key, detail.Version); err != nil { if err = checkVllmVersionAccess(install.App.Key, detail.Version); err != nil {
return err return err
} }
@@ -122,6 +148,7 @@ func upgradeInstall(req request.AppInstallUpgrade) error {
return err return err
} }
ctx := &appUpgradeContext{ ctx := &appUpgradeContext{
feishuLockPath: feishuLockPath,
req: req, req: req,
original: install, original: install,
candidate: install, candidate: install,
@@ -215,6 +242,10 @@ func (u *appUpgradeContext) prepare(t *task.Task) error {
if err = copyUpgradeStageFile(u.original.GetPath(), u.stageDir, path.Join("data", "conf", "openclaw.json")); err != nil { if err = copyUpgradeStageFile(u.original.GetPath(), u.stageDir, path.Join("data", "conf", "openclaw.json")); err != nil {
return err return err
} }
u.openclawOriginal, err = os.ReadFile(path.Join(u.stageDir, "data/conf/openclaw.json"))
if err != nil {
return err
}
} }
if u.original.App.Key == constant.AppOpenresty { if u.original.App.Key == constant.AppOpenresty {
for _, relativePath := range []string{ for _, relativePath := range []string{
@@ -323,6 +354,9 @@ func (u *appUpgradeContext) prepare(t *task.Task) error {
if u.original.App.Resource == constant.AppResourceRemote { if u.original.App.Resource == constant.AppResourceRemote {
go RequestDownloadCallBack(u.detail.DownloadCallBackUrl) go RequestDownloadCallBack(u.detail.DownloadCallBackUrl)
} }
if err = u.prepareFeishuUpgrade(); err != nil {
return err
}
u.phase = appUpgradePrepared u.phase = appUpgradePrepared
return nil return nil
} }
@@ -380,6 +414,9 @@ func (u *appUpgradeContext) cutover(t *task.Task) error {
} }
t.LogSuccess(i18n.GetMsgByKey("UpgradeStop")) t.LogSuccess(i18n.GetMsgByKey("UpgradeStop"))
u.phase = appUpgradeStopped u.phase = appUpgradeStopped
if err := u.checkFeishuUpgradeSource(); err != nil {
return err
}
var err error var err error
if u.original.App.Key == constant.AppOpenresty { if u.original.App.Key == constant.AppOpenresty {
@@ -439,15 +476,17 @@ func (u *appUpgradeContext) cutover(t *task.Task) error {
t.LogSuccess(logStr) t.LogSuccess(logStr)
u.phase = appUpgradeStarted u.phase = appUpgradeStarted
t.LogStart(i18n.GetMsgByKey("UpgradeWaitReady")) containerNames, discoverErr := discoverUpgradeContainerNames(u.candidate, u.envContent)
containerNames, err := waitAppContainersReady(context.Background(), u.candidate) if discoverErr != nil {
if err != nil { t.Logf("WARNING: discover upgraded application containers failed: %v", discoverErr)
t.LogFailedWithErr(i18n.GetMsgByKey("UpgradeWaitReady"), err) } else if len(containerNames) > 0 {
u.candidate.ContainerName = strings.Join(containerNames, ",")
} else {
t.Log("WARNING: no containers found for the upgraded application")
}
if err = u.verifyFeishuUpgrade(t); err != nil {
return err return err
} }
t.LogSuccess(i18n.GetMsgByKey("UpgradeWaitReady"))
u.phase = appUpgradeReady
u.candidate.ContainerName = strings.Join(containerNames, ",")
u.candidate.Status = constant.StatusRunning u.candidate.Status = constant.StatusRunning
u.candidate.Message = "" u.candidate.Message = ""
@@ -472,6 +511,11 @@ func (u *appUpgradeContext) cutover(t *task.Task) error {
} else if err = appInstallRepo.Save(context.Background(), &u.candidate); err != nil { } else if err = appInstallRepo.Save(context.Background(), &u.candidate); err != nil {
return err return err
} }
if discoverErr == nil && len(containerNames) > 0 {
if syncErr := syncAppInstallStatus(&u.candidate, true); syncErr != nil {
t.Logf("WARNING: sync upgraded application status failed: %v", syncErr)
}
}
u.phase = appUpgradeCommitted u.phase = appUpgradeCommitted
u.deleteOldImages(t) u.deleteOldImages(t)
return nil return nil
@@ -568,8 +612,34 @@ func (u *appUpgradeContext) rollback(t *task.Task) (rollbackErr error) {
if out != "" { if out != "" {
err = fmt.Errorf("%s: %w", out, err) err = fmt.Errorf("%s: %w", out, err)
} }
if u.feishuOriginal != nil {
return err
}
rollbackErr = err rollbackErr = err
} }
if u.feishuOriginal != nil {
if u.backupFile == "" {
return buserr.New("ErrFeishuUpgradeBackup")
}
if err := u.restoreManagedFiles(); err != nil {
return err
}
if err := handleAppRecover(&u.original, t, u.backupFile, true, "", "", false); err != nil {
return err
}
if err := pinFeishuRollbackImage(u.original.GetComposePath(), u.feishuOldImage); err != nil {
return err
}
restoredCompose, err := os.ReadFile(u.original.GetComposePath())
if err != nil {
return err
}
u.original.DockerCompose = string(restoredCompose)
if _, err := compose.UpWithoutBuild(u.original.GetComposePath()); err != nil {
return err
}
return u.finishRollback()
}
if u.backupFile != "" { if u.backupFile != "" {
_ = u.restoreManagedFiles() _ = u.restoreManagedFiles()
if err := handleAppRecover(&u.original, t, u.backupFile, true, "", ""); err != nil { if err := handleAppRecover(&u.original, t, u.backupFile, true, "", ""); err != nil {
@@ -591,9 +661,11 @@ func (u *appUpgradeContext) rollback(t *task.Task) (rollbackErr error) {
} }
func (u *appUpgradeContext) finishRollback() error { func (u *appUpgradeContext) finishRollback() error {
if _, err := waitAppContainersReady(context.Background(), u.original); err != nil { if u.feishuOriginal != nil {
if err := waitOpenclawReady(u.original.ContainerName); err != nil {
return err return err
} }
}
restored := u.original restored := u.original
if err := appInstallRepo.Save(context.Background(), &restored); err != nil { if err := appInstallRepo.Save(context.Background(), &restored); err != nil {
return err return err
@@ -635,6 +707,12 @@ func (u *appUpgradeContext) deleteOldImages(t *task.Task) {
} }
func (u *appUpgradeContext) cleanup() { func (u *appUpgradeContext) cleanup() {
if u.feishuLockPath != "" {
feishuSwitches.Delete(u.feishuLockPath)
}
if u.feishuOriginal != nil && u.rollbackErr != nil {
return
}
if u.snapshot != nil { if u.snapshot != nil {
u.snapshot.Cleanup() u.snapshot.Cleanup()
} }
@@ -881,28 +959,7 @@ func (s *upgradeFileSnapshot) Cleanup() {
} }
} }
type appContainerReadinessClient interface { func discoverUpgradeContainerNames(install model.AppInstall, envContent []byte) ([]string, error) {
ContainerList(context.Context, container.ListOptions) ([]container.Summary, error)
ContainerInspect(context.Context, string) (container.InspectResponse, error)
}
func waitAppContainersReady(ctx context.Context, install model.AppInstall) ([]string, error) {
client, err := docker.NewDockerClient()
if err != nil {
return nil, err
}
defer client.Close()
return waitAppContainersReadyWithClient(ctx, client, install)
}
func waitAppContainersReadyWithClient(ctx context.Context, client appContainerReadinessClient, install model.AppInstall) ([]string, error) {
envContent, err := os.ReadFile(install.GetEnvPath())
if err != nil {
envContent, err = renderUpgradeEnv(&install, nil)
if err != nil {
return nil, err
}
}
project, err := docker.GetComposeProject(install.Name, install.GetPath(), []byte(install.DockerCompose), envContent, false) project, err := docker.GetComposeProject(install.Name, install.GetPath(), []byte(install.DockerCompose), envContent, false)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -916,36 +973,24 @@ func waitAppContainersReadyWithClient(ctx context.Context, client appContainerRe
if len(expectedServices) == 0 { if len(expectedServices) == 0 {
return strings.Split(install.ContainerName, ","), nil return strings.Split(install.ContainerName, ","), nil
} }
options := container.ListOptions{ client, err := docker.NewDockerClient()
if err != nil {
return nil, err
}
defer client.Close()
containers, err := client.ContainerList(context.Background(), container.ListOptions{
All: true, All: true,
Filters: filters.NewArgs( Filters: filters.NewArgs(filters.Arg("label", composeWorkdirLabel+"="+install.GetPath())),
filters.Arg("label", composeWorkdirLabel+"="+install.GetPath()), })
),
}
containers, err := client.ContainerList(ctx, options)
if err != nil { if err != nil {
return nil, err return nil, err
} }
foundServices := make(map[string]bool, len(expectedServices))
containerNames := make([]string, 0, len(containers)) containerNames := make([]string, 0, len(containers))
for _, item := range containers { for _, item := range containers {
serviceName := item.Labels[composeServiceLabel] if _, ok := expectedServices[item.Labels[composeServiceLabel]]; ok && len(item.Names) > 0 {
if _, ok := expectedServices[serviceName]; !ok {
continue
}
if err = waitContainerReady(ctx, client, item.ID); err != nil {
return nil, fmt.Errorf("container %s is not ready: %w", serviceName, err)
}
foundServices[serviceName] = true
if len(item.Names) > 0 {
containerNames = append(containerNames, strings.TrimPrefix(item.Names[0], "/")) containerNames = append(containerNames, strings.TrimPrefix(item.Names[0], "/"))
} }
} }
for serviceName := range expectedServices {
if !foundServices[serviceName] {
return nil, fmt.Errorf("container for service %s was not created", serviceName)
}
}
sort.Strings(containerNames) sort.Strings(containerNames)
return containerNames, nil return containerNames, nil
} }
+336
View File
@@ -0,0 +1,336 @@
package service
import (
"bytes"
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"time"
"github.com/1Panel-dev/1Panel/agent/app/dto"
"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/i18n"
"github.com/1Panel-dev/1Panel/agent/utils/cmd"
"github.com/1Panel-dev/1Panel/agent/utils/docker"
"gopkg.in/yaml.v3"
)
func classifyLegacyFeishu(conf map[string]interface{}, installPath string) (legacy, configured bool, err error) {
for _, field := range []string{"channels", "channels.feishu", "plugins", "plugins.entries", "plugins.entries.openclaw-lark", "plugins.entries.feishu"} {
parts := strings.Split(field, ".")
parent := conf
for _, part := range parts[:len(parts)-1] {
parent = childMap(parent, part)
}
if raw, exists := parent[parts[len(parts)-1]]; exists {
if _, ok := raw.(map[string]interface{}); !ok {
return false, false, feishuConfigError(field)
}
}
}
plugins := childMap(conf, "plugins")
for _, key := range []string{"allow", "deny"} {
if raw, exists := plugins[key]; exists {
if _, ok := raw.([]string); ok {
continue
}
ids, ok := raw.([]interface{})
if !ok {
return false, false, feishuConfigError("plugins." + key)
}
for _, id := range ids {
if _, ok := id.(string); !ok {
return false, false, feishuConfigError("plugins." + key)
}
}
}
}
for _, id := range []string{"openclaw-lark", "feishu"} {
if raw, exists := childMap(childMap(plugins, "entries"), id)["enabled"]; exists {
if _, ok := raw.(bool); !ok {
return false, false, feishuConfigError("plugins.entries." + id + ".enabled")
}
}
}
if usesBundledFeishu(conf) {
return false, false, nil
}
f := getChannelConfig(conf, "feishu")
configured = len(f) > 0
if raw, exists := conf["bindings"]; exists {
bindings, ok := raw.([]interface{})
if !ok {
return false, false, feishuConfigError("bindings")
}
for _, raw := range bindings {
binding, ok := raw.(map[string]interface{})
if !ok {
return false, false, feishuConfigError("bindings")
}
if match, exists := binding["match"]; exists {
if _, ok := match.(map[string]interface{}); !ok {
return false, false, feishuConfigError("bindings.match")
}
}
if extractStringValue(childMap(binding, "match")["channel"]) == "feishu" {
configured = true
}
}
}
entries := childMap(childMap(conf, "plugins"), "entries")
entry, selected := entries["openclaw-lark"]
if selected {
old, ok := entry.(map[string]interface{})
if !ok {
return false, configured, feishuConfigError("plugins.entries.openclaw-lark")
}
// A completed disable-only repair must not repeat on every upgrade.
if enabled, ok := old["enabled"].(bool); ok && !enabled && !configured {
return false, false, nil
}
}
installed, err := checkPluginInstalled(installPath, "feishu")
if err != nil {
return false, configured, err
}
_, oldThread := f["threadSession"]
_, oldReply := f["replyMode"]
_, oldStreaming := f["streaming"].(bool)
return selected || installed || oldThread || oldReply || oldStreaming, configured, nil
}
func prepareLegacyFeishu(conf map[string]interface{}, configured bool, transition *dto.AgentFeishuTransition) error {
if !configured {
ensureChildMap(ensureChildMap(ensureChildMap(conf, "plugins"), "entries"), "openclaw-lark")["enabled"] = false
return nil
}
if transition == nil || !transition.Confirmed {
return buserr.New("ErrFeishuUpgradeConfirm")
}
if !validFeishuTransition(transition.GroupSessionScope, transition.RequireMention) {
return feishuConfigError("feishuTransition")
}
return convertLegacyFeishu(conf, dto.AgentFeishuSwitchReq{Streaming: transition.Streaming, GroupSessionScope: transition.GroupSessionScope, RequireMention: transition.RequireMention})
}
func validFeishuTransition(scope, mention string) bool {
return (scope == "group" || scope == "group_sender" || scope == "group_topic" || scope == "group_topic_sender") && (mention == "true" || mention == "false")
}
func feishuRecoveryPayload(candidate []byte, configured bool) ([]byte, error) {
var conf map[string]interface{}
if err := json.Unmarshal(candidate, &conf); err != nil {
return nil, err
}
if configured {
ensureChildMap(ensureChildMap(conf, "channels"), "feishu")["enabled"] = false
selectBundledFeishu(conf, false)
}
ensureChildMap(ensureChildMap(ensureChildMap(conf, "plugins"), "entries"), "openclaw-lark")["enabled"] = false
return json.MarshalIndent(conf, "", " ")
}
func (u *appUpgradeContext) prepareFeishuUpgrade() error {
if u.original.App.Key != constant.AppOpenclaw || !isOpenclawBundledFeishuVersion(u.candidate.Version) {
return nil
}
source := filepath.Join(u.original.GetPath(), "data/conf/openclaw.json")
staged := filepath.Join(u.stageDir, "data/conf/openclaw.json")
// The staged copy predates protocol migration and is the exact cutover comparison source.
original := u.openclawOriginal
current, err := os.ReadFile(source)
if err != nil {
return err
}
if !bytes.Equal(original, current) {
return buserr.New("ErrFeishuConfigChanged")
}
_, conf, err := readFeishuConfigBytes(staged)
if err != nil {
return err
}
legacy, configured, err := classifyLegacyFeishu(conf, u.original.GetPath())
if err != nil || !legacy {
return err
}
if !u.req.Backup {
return buserr.New("ErrFeishuUpgradeBackup")
}
if err = prepareLegacyFeishu(conf, configured, u.req.FeishuTransition); err != nil {
return err
}
project, err := docker.GetComposeProject(u.original.Name, u.original.GetPath(), []byte(u.candidate.DockerCompose), u.envContent, false)
if err != nil {
return err
}
if len(project.Services) != 1 {
return buserr.New("ErrFeishuUpgradeMounts")
}
for _, service := range project.Services {
if service.Build != nil {
return buserr.New("ErrFeishuImageRequired")
}
u.feishuImage, err = bundledFeishuImage(service.Image)
if err != nil {
return err
}
configMounted := false
for _, v := range service.Volumes {
if v.Target == "/etc/localtime" {
continue
}
if v.Type != "bind" {
return buserr.New("ErrFeishuUpgradeMounts")
}
if err = feishuBackupPath(u.original.GetPath(), v.Source); err != nil {
return err
}
if v.Target == "/home/node/.openclaw" {
configMounted = filepath.Clean(v.Source) == filepath.Dir(source)
}
}
if !configMounted {
return buserr.New("ErrFeishuUpgradeMounts")
}
}
cli, err := docker.NewDockerClient()
if err != nil {
return err
}
defer cli.Close()
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
info, err := cli.ContainerInspect(ctx, u.original.ContainerName)
if err != nil {
return err
}
u.feishuOldImage = info.Image
for _, m := range info.Mounts {
if m.Destination == "/etc/localtime" {
continue
}
if string(m.Type) != "bind" {
return buserr.New("ErrFeishuUpgradeMounts")
}
if err = feishuBackupPath(u.original.GetPath(), m.Source); err != nil {
return err
}
}
// Tar preserves symlinks: an external target would not be in the application backup.
if err = filepath.WalkDir(filepath.Join(u.original.GetPath(), "data"), func(file string, d os.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if d.Type()&os.ModeSymlink != 0 {
return feishuBackupPath(u.original.GetPath(), file)
}
return nil
}); err != nil {
return err
}
u.feishuOriginal = original
u.feishuEnabled = configured && extractBoolValue(getChannelConfig(conf, "feishu")["enabled"], true)
payload, err := json.MarshalIndent(conf, "", " ")
if err != nil {
return err
}
before, err := os.ReadFile(staged)
if err != nil {
return err
}
if err = replaceFeishuConfig(staged, before, payload); err != nil {
return err
}
targetCompose := filepath.Join(u.stageDir, "docker-compose.yml")
if err = pinFeishuRollbackImage(targetCompose, u.feishuImage); err != nil {
return err
}
pinned, err := os.ReadFile(targetCompose)
if err != nil {
return err
}
u.candidate.DockerCompose = string(pinned)
return nil
}
func feishuBackupPath(root, source string) error {
root, err := filepath.EvalSymlinks(root)
if err != nil {
return err
}
resolved, err := filepath.EvalSymlinks(source)
if err != nil {
return buserr.New("ErrFeishuUpgradeMounts")
}
rel, err := filepath.Rel(root, resolved)
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
return buserr.New("ErrFeishuUpgradeMounts")
}
return nil
}
func (u *appUpgradeContext) checkFeishuUpgradeSource() error {
if u.feishuOriginal == nil {
return nil
}
current, err := os.ReadFile(filepath.Join(u.original.GetPath(), "data/conf/openclaw.json"))
if err != nil {
return err
}
if !bytes.Equal(current, u.feishuOriginal) {
return buserr.New("ErrFeishuConfigChanged")
}
return nil
}
func (u *appUpgradeContext) verifyFeishuUpgrade(t *task.Task) error {
if u.feishuOriginal == nil {
return nil
}
if err := verifyFeishuActivation(u.candidate.ContainerName, u.feishuEnabled); err != nil {
return err
}
if u.feishuEnabled {
probeFeishuConnection(u.candidate.ContainerName, t)
}
return nil
}
func probeFeishuConnection(name string, t *task.Task) {
output, err := cmd.RunDockerExecWithStdout(30*time.Second, name, "openclaw", "channels", "status", "--channel", "feishu", "--probe", "--json")
if err != nil || !feishuProbeOK(output) {
t.Log(i18n.GetMsgByKey("AgentFeishuProbeWarning"))
}
}
// Pin the restored service to the actual old image, even if its registry tag moved.
func pinFeishuRollbackImage(composeFile string, image string) error {
raw, err := os.ReadFile(composeFile)
if err != nil {
return err
}
var conf map[string]interface{}
if err = yaml.Unmarshal(raw, &conf); err != nil {
return err
}
services := childMap(conf, "services")
if len(services) != 1 {
return buserr.New("ErrFeishuUpgradeMounts")
}
for _, raw := range services {
service, ok := raw.(map[string]interface{})
if !ok {
return buserr.New("ErrFeishuUpgradeMounts")
}
service["image"] = image
}
payload, err := yaml.Marshal(conf)
if err != nil {
return err
}
return writeUpgradeFile(composeFile, payload, constant.FilePerm)
}
+118 -31
View File
@@ -8,6 +8,7 @@ import (
"maps" "maps"
"math" "math"
"net/http" "net/http"
"net/url"
"os" "os"
"path" "path"
"path/filepath" "path/filepath"
@@ -352,22 +353,34 @@ func deleteAppInstall(deleteReq request.AppInstallDelete) error {
if dir != nil { if dir != nil {
logStr := i18n.GetMsgByKey("Stop") + i18n.GetMsgByKey("App") logStr := i18n.GetMsgByKey("Stop") + i18n.GetMsgByKey("App")
t.Log(logStr) t.Log(logStr)
cleanupFailed := false
if deleteReq.UseLifecycleScripts { if deleteReq.UseLifecycleScripts {
if err = runScript(t, &install, "uninstall"); err != nil { if scriptErr := runScript(t, &install, "uninstall"); scriptErr != nil {
return err cleanupFailed = true
if !deleteReq.ForceDelete {
return scriptErr
}
} }
} else { } else {
out, err := compose.Down(install.GetComposePath()) out, downErr := compose.Down(install.GetComposePath())
if err != nil && !deleteReq.ForceDelete { if downErr != nil {
return handleErr(install, err, out) cleanupFailed = true
if !deleteReq.ForceDelete {
return handleErr(install, downErr, out)
} }
if err = runScript(t, &install, "uninstall"); err != nil { }
if scriptErr := runScript(t, &install, "uninstall"); scriptErr != nil {
cleanupFailed = true
if !deleteReq.ForceDelete {
_, _ = compose.Up(install.GetComposePath()) _, _ = compose.Up(install.GetComposePath())
return err return scriptErr
} }
} }
}
if !cleanupFailed {
t.LogSuccess(logStr) t.LogSuccess(logStr)
}
if deleteReq.DeleteImage { if deleteReq.DeleteImage {
content, err := op.GetContent(install.GetEnvPath()) content, err := op.GetContent(install.GetEnvPath())
if err != nil { if err != nil {
@@ -378,12 +391,27 @@ func deleteAppInstall(deleteReq request.AppInstallDelete) error {
} }
} }
} }
if install.App.Key == "ollama" {
ollamaModelMu.Lock()
defer ollamaModelMu.Unlock()
}
tx, ctx := helper.GetTxAndContext() tx, ctx := helper.GetTxAndContext()
defer tx.Rollback() defer tx.Rollback()
if err = appInstallRepo.Delete(ctx, install); err != nil { if err = appInstallRepo.Delete(ctx, install); err != nil {
return err return err
} }
appKey := install.App.Key appKey := install.App.Key
if appKey == "ollama" {
remaining, listErr := appInstallRepo.ListBy(ctx, appInstallRepo.WithAppId(install.AppId))
if listErr != nil {
return listErr
}
if len(remaining) == 0 {
if err = aiRepo.DeleteAll(ctx); err != nil {
return err
}
}
}
if isAgentAppKey(appKey) { if isAgentAppKey(appKey) {
_ = agentRepo.DeleteByAppInstallIDWithCtx(ctx, install.ID) _ = agentRepo.DeleteByAppInstallIDWithCtx(ctx, install.ID)
} }
@@ -456,7 +484,12 @@ func deleteAppInstall(deleteReq request.AppInstallDelete) error {
if err == nil && len(entries) == 0 { if err == nil && len(entries) == 0 {
_ = op.DeleteDir(parentDir) _ = op.DeleteDir(parentDir)
} }
tx.Commit() if err := tx.Commit().Error; err != nil {
return err
}
if install.App.Key == "ollama" || install.App.Key == "vllm" {
CleanupLocalModelAccount(install.ID)
}
existApps, _ := appInstallRepo.ListBy(context.Background(), appInstallRepo.WithAppId(install.AppId)) existApps, _ := appInstallRepo.ListBy(context.Background(), appInstallRepo.WithAppId(install.AppId))
if len(existApps) == 0 { if len(existApps) == 0 {
@@ -466,8 +499,9 @@ func deleteAppInstall(deleteReq request.AppInstallDelete) error {
} }
uninstallTask.AddSubTask(task.GetTaskName(install.Name, task.TaskUninstall, task.TaskScopeApp), uninstall, nil) uninstallTask.AddSubTask(task.GetTaskName(install.Name, task.TaskUninstall, task.TaskScopeApp), uninstall, nil)
go func() { go func() {
if err := uninstallTask.Execute(); err != nil && !deleteReq.ForceDelete { if err := uninstallTask.Execute(); err != nil {
install.Status = constant.StatusError install.Status = constant.StatusError
install.Message = err.Error()
_ = appInstallRepo.Save(context.Background(), &install) _ = appInstallRepo.Save(context.Background(), &install)
} }
}() }()
@@ -878,16 +912,52 @@ func handleMap(params map[string]interface{}, envParams map[string]string) {
} }
} }
func appDownloadURL(rawURL, version string) (string, error) {
parsedURL, err := url.Parse(rawURL)
if err != nil {
return "", err
}
query := parsedURL.Query()
query.Set("v", version)
parsedURL.RawQuery = query.Encode()
return parsedURL.String(), nil
}
func currentAppDownloadURL(rawURL string) (string, error) {
storeVersion, err := settingRepo.GetValueByKey("AppStoreLastModified")
if err != nil {
return "", err
}
return appDownloadURL(rawURL, storeVersion)
}
func downloadApp(app model.App, appDetail model.AppDetail, appInstall *model.AppInstall, logger *logrus.Logger) (err error) { func downloadApp(app model.App, appDetail model.AppDetail, appInstall *model.AppInstall, logger *logrus.Logger) (err error) {
if app.IsLocalApp() || app.IsCustomApp() { if app.IsLocalApp() || app.IsCustomApp() {
return nil return nil
} }
defer func() {
if err != nil && appInstall != nil {
appInstall.Status = constant.StatusDownloadErr
appInstall.Message = err.Error()
}
}()
appResourceDir := path.Join(global.Dir.AppResourceDir, app.Resource) appResourceDir := path.Join(global.Dir.AppResourceDir, app.Resource)
appDownloadDir := app.GetAppResourcePath() appDownloadDir := app.GetAppResourcePath()
appVersionDir := path.Join(appDownloadDir, appDetail.Version) appVersionDir := path.Join(appDownloadDir, appDetail.Version)
fileOp := files.NewFileOp() fileOp := files.NewFileOp()
storeVersion, err := settingRepo.GetValueByKey("AppStoreLastModified")
if err != nil {
return err
}
cacheVersionPath := path.Join(appVersionDir, ".1panel-appstore-version")
if !appDetail.Update && fileOp.Stat(appVersionDir) { if !appDetail.Update && fileOp.Stat(appVersionDir) {
return if cachedVersion, readErr := os.ReadFile(cacheVersionPath); readErr == nil && string(cachedVersion) == storeVersion {
return nil
}
}
downloadURL, err := appDownloadURL(appDetail.DownloadUrl, storeVersion)
if err != nil {
return err
} }
if !fileOp.Stat(appDownloadDir) { if !fileOp.Stat(appDownloadDir) {
_ = fileOp.CreateDir(appDownloadDir, constant.DirPerm) _ = fileOp.CreateDir(appDownloadDir, constant.DirPerm)
@@ -896,23 +966,14 @@ func downloadApp(app model.App, appDetail model.AppDetail, appInstall *model.App
_ = fileOp.CreateDir(appVersionDir, constant.DirPerm) _ = fileOp.CreateDir(appVersionDir, constant.DirPerm)
} }
if logger == nil { if logger == nil {
global.LOG.Infof("download app [%s] from %s", app.Name, appDetail.DownloadUrl) global.LOG.Infof("download app [%s] from %s", app.Name, downloadURL)
} else { } else {
logger.Printf("download app [%s] from %s", app.Name, appDetail.DownloadUrl) logger.Printf("download app [%s] from %s", app.Name, downloadURL)
} }
filePath := path.Join(appVersionDir, app.Key+"-"+appDetail.Version+".tar.gz") filePath := path.Join(appVersionDir, app.Key+"-"+appDetail.Version+".tar.gz")
defer func() { if err = files.DownloadFileWithProxy(downloadURL, filePath); err != nil {
if err != nil {
if appInstall != nil {
appInstall.Status = constant.StatusDownloadErr
appInstall.Message = err.Error()
}
}
}()
if err = files.DownloadFileWithProxy(appDetail.DownloadUrl, filePath); err != nil {
if logger == nil { if logger == nil {
global.LOG.Errorf("download app [%s] error %v", app.Name, err) global.LOG.Errorf("download app [%s] error %v", app.Name, err)
} else { } else {
@@ -929,6 +990,9 @@ func downloadApp(app model.App, appDetail model.AppDetail, appInstall *model.App
return return
} }
_ = fileOp.DeleteFile(filePath) _ = fileOp.DeleteFile(filePath)
if err = os.WriteFile(cacheVersionPath, []byte(storeVersion), constant.FilePerm); err != nil {
return err
}
appDetail.Update = false appDetail.Update = false
_ = appDetailRepo.Update(context.Background(), appDetail) _ = appDetailRepo.Update(context.Background(), appDetail)
return return
@@ -1393,9 +1457,8 @@ func handleErr(install model.AppInstall, err error, out string) error {
} }
func doNotNeedSync(installed model.AppInstall) bool { func doNotNeedSync(installed model.AppInstall) bool {
return installed.Status == constant.StatusInstalling || installed.Status == constant.StatusRebuilding || installed.Status == constant.StatusUpgrading || return appInstallOperationPending(installed.Status) || installed.Status == constant.StatusSyncing ||
installed.Status == constant.StatusSyncing || installed.Status == constant.StatusUninstalling || installed.Status == constant.StatusInstallErr || installed.Status == constant.StatusInstallErr || installed.Status == constant.StatusUpgradeErr || installed.Status == constant.StatusUpErr
installed.Status == constant.StatusStarting || installed.Status == constant.StatusRestarting || installed.Status == constant.StatusWaiting
} }
func synAppInstall(containers map[string]container.Summary, appInstall *model.AppInstall, force bool) { func synAppInstall(containers map[string]container.Summary, appInstall *model.AppInstall, force bool) {
@@ -1489,6 +1552,11 @@ func handleInstalled(appInstallList []model.AppInstall, updated, sync, checkUpda
} }
for _, installed := range appInstallList { for _, installed := range appInstallList {
if sync && appInstallOperationPending(installed.Status) {
if err := syncAppInstallStatus(&installed, false); err != nil {
return nil, err
}
}
if updated && ignoreUpdate(installed) { if updated && ignoreUpdate(installed) {
continue continue
} }
@@ -1554,11 +1622,14 @@ func handleInstalled(appInstallList []model.AppInstall, updated, sync, checkUpda
var versions []string var versions []string
for _, appDetail := range details { for _, appDetail := range details {
if !isAIGatewayUpgradeVersionAllowed(installed.App.Key, installed.Version, appDetail.Version) {
continue
}
ignores, _ := appIgnoreUpgradeRepo.List(runtimeRepo.WithDetailId(appDetail.ID), appIgnoreUpgradeRepo.WithScope("version")) ignores, _ := appIgnoreUpgradeRepo.List(runtimeRepo.WithDetailId(appDetail.ID), appIgnoreUpgradeRepo.WithScope("version"))
if len(ignores) > 0 { if len(ignores) > 0 {
continue continue
} }
if common.IsCrossVersion(installed.Version, appDetail.Version) && !installed.App.CrossVersionUpdate { if isCrossAppVersion(installed.App.Key, installed.Version, appDetail.Version) && !installed.App.CrossVersionUpdate {
continue continue
} }
versions = append(versions, appDetail.Version) versions = append(versions, appDetail.Version)
@@ -1573,7 +1644,7 @@ func handleInstalled(appInstallList []model.AppInstall, updated, sync, checkUpda
continue continue
} }
versions = common.GetSortedVersions(versions) versions = sortAppVersions(installed.App.Key, versions)
lastVersion := versions[0] lastVersion := versions[0]
if installed.App.Key == constant.AppMysql || installed.App.Key == constant.AppMysqlCluster { if installed.App.Key == constant.AppMysql || installed.App.Key == constant.AppMysqlCluster {
@@ -1588,10 +1659,10 @@ func handleInstalled(appInstallList []model.AppInstall, updated, sync, checkUpda
} }
} }
if common.IsCrossVersion(installed.Version, lastVersion) { if isCrossAppVersion(installed.App.Key, installed.Version, lastVersion) {
installDTO.CanUpdate = installed.App.CrossVersionUpdate installDTO.CanUpdate = installed.App.CrossVersionUpdate
} else { } else {
installDTO.CanUpdate = common.CompareVersion(lastVersion, installed.Version) installDTO.CanUpdate = isNewerAppVersion(installed.App.Key, lastVersion, installed.Version)
} }
if updated { if updated {
@@ -1962,8 +2033,19 @@ func getAppTags(appID uint, lang string) ([]response.TagDTO, error) {
return res, nil return res, nil
} }
func prepareAppInstallSiteDirectory(app model.App, appDetail model.AppDetail, req request.AppInstallCreate, t *task.Task, hooks *appInstallHooks) error {
if hooks != nil && hooks.PrepareSiteDir != nil {
return hooks.PrepareSiteDir(app, appDetail, req, t)
}
return handleSiteDir(app, appDetail, req, t)
}
func managesOpenrestySiteDirectory(app model.App, appDetail model.AppDetail) bool {
return app.Key == "openresty" && (app.Resource == "remote" || app.Resource == "custom") && common.CompareVersion(appDetail.Version, "1.27")
}
func handleSiteDir(app model.App, appDetail model.AppDetail, req request.AppInstallCreate, t *task.Task) error { func handleSiteDir(app model.App, appDetail model.AppDetail, req request.AppInstallCreate, t *task.Task) error {
if app.Key == "openresty" && (app.Resource == "remote" || app.Resource == "custom") && common.CompareVersion(appDetail.Version, "1.27") { if managesOpenrestySiteDirectory(app, appDetail) {
if dir, ok := req.Params["WEBSITE_DIR"]; ok { if dir, ok := req.Params["WEBSITE_DIR"]; ok {
siteDir := dir.(string) siteDir := dir.(string)
if siteDir == "" || !strings.HasPrefix(siteDir, "/") { if siteDir == "" || !strings.HasPrefix(siteDir, "/") {
@@ -2262,7 +2344,12 @@ func getAppVersions(key string, details []model.AppDetail) []string {
} }
versionsRaw = append(versionsRaw, detail.Version) versionsRaw = append(versionsRaw, detail.Version)
} }
versions = common.GetSortedVersions(versionsRaw) if isAIGatewayApp(key) {
sortAIGatewayVersions(versionsRaw)
versions = versionsRaw
} else {
versions = sortAppVersions(key, versionsRaw)
}
if hasLatest { if hasLatest {
versions = append([]string{latestVersion}, versions...) versions = append([]string{latestVersion}, versions...)
} }
+37 -9
View File
@@ -132,11 +132,9 @@ func (u *BackupService) AppRecover(req dto.CommonRecover) error {
if !fileOp.Stat(req.File) { if !fileOp.Stat(req.File) {
return buserr.WithName("ErrFileNotFound", req.File) return buserr.WithName("ErrFileNotFound", req.File)
} }
if _, err := compose.Down(install.GetComposePath()); err != nil {
return err
}
if err := handleAppRecover(&install, nil, req.File, false, req.Secret, req.TaskID); err != nil { if err := handleAppRecover(&install, nil, req.File, false, req.Secret, req.TaskID); err != nil {
global.LOG.Errorf("recover app %s failed, err: %v", req.DetailName, err) global.LOG.Errorf("recover app %s failed, err: %v", req.DetailName, err)
return err
} }
return nil return nil
} }
@@ -199,9 +197,22 @@ func handleAppBackup(install *model.AppInstall, parentTask *task.Task, recordID
return nil return nil
} }
func handleAppRecover(install *model.AppInstall, parentTask *task.Task, recoverFile string, isRollback bool, secret, taskID string) error { func handleAppRecover(install *model.AppInstall, parentTask *task.Task, recoverFile string, isRollback bool, secret, taskID string, startAfterRecover ...bool) error {
startAfter := len(startAfterRecover) == 0 || startAfterRecover[0]
// Validate AI Gateway backups before stopping the app, creating rollback
// backups, or touching its files/database. Keep the validated extraction for
// the restore so uploaded/encrypted archives are not unpacked twice.
preparedPath, cleanup, err := prepareAIGatewayAppRecover(install, recoverFile, secret)
if err != nil {
return err
}
if parentTask == nil && !isRollback {
if _, err := compose.Down(install.GetComposePath()); err != nil {
cleanup()
return err
}
}
var ( var (
err error
recoverTask *task.Task recoverTask *task.Task
isOk = false isOk = false
rollbackFile string rollbackFile string
@@ -210,11 +221,13 @@ func handleAppRecover(install *model.AppInstall, parentTask *task.Task, recoverF
if parentTask == nil { if parentTask == nil {
recoverTask, err = task.NewTaskWithOps(install.Name, task.TaskRecover, task.TaskScopeBackup, taskID, install.ID) recoverTask, err = task.NewTaskWithOps(install.Name, task.TaskRecover, task.TaskScopeBackup, taskID, install.ID)
if err != nil { if err != nil {
cleanup()
return err return err
} }
} }
recoverApp := func(t *task.Task) error { recoverApp := func(t *task.Task) error {
defer cleanup()
fileOp := files.NewFileOp() fileOp := files.NewFileOp()
if !isRollback { if !isRollback {
rollbackFile = path.Join(global.Dir.TmpDir, fmt.Sprintf("app/%s_%s.tar.gz", install.Name, time.Now().Format(constant.DateTimeSlimLayout))) rollbackFile = path.Join(global.Dir.TmpDir, fmt.Sprintf("app/%s_%s.tar.gz", install.Name, time.Now().Format(constant.DateTimeSlimLayout)))
@@ -223,20 +236,26 @@ func handleAppRecover(install *model.AppInstall, parentTask *task.Task, recoverF
} }
} }
tmpPath := preparedPath
if tmpPath == "" {
if err := fileOp.TarGzExtractPro(recoverFile, path.Dir(recoverFile), secret); err != nil { if err := fileOp.TarGzExtractPro(recoverFile, path.Dir(recoverFile), secret); err != nil {
return err return err
} }
tmpPath := strings.ReplaceAll(recoverFile, ".tar.gz", "") tmpPath = strings.ReplaceAll(recoverFile, ".tar.gz", "")
}
if err := fileOp.TarGzExtractPro(tmpPath+"/app.tar.gz", tmpPath, ""); err != nil { if err := fileOp.TarGzExtractPro(tmpPath+"/app.tar.gz", tmpPath, ""); err != nil {
return err return err
} }
defer func() { defer func() {
defer os.RemoveAll(tmpPath)
if !startAfter {
return
}
if isRollback { if isRollback {
_, _ = compose.UpWithoutBuild(install.GetComposePath()) _, _ = compose.UpWithoutBuild(install.GetComposePath())
} else { } else {
_, _ = compose.Up(install.GetComposePath()) _, _ = compose.Up(install.GetComposePath())
} }
_ = os.RemoveAll(strings.ReplaceAll(recoverFile, ".tar.gz", ""))
}() }()
if !fileOp.Stat(tmpPath+"/app.json") || !fileOp.Stat(tmpPath+"/app.tar.gz") { if !fileOp.Stat(tmpPath+"/app.json") || !fileOp.Stat(tmpPath+"/app.tar.gz") {
@@ -337,8 +356,12 @@ func handleAppRecover(install *model.AppInstall, parentTask *task.Task, recoverF
appDir := install.GetPath() appDir := install.GetPath()
backPath := fmt.Sprintf("%s_bak", appDir) backPath := fmt.Sprintf("%s_bak", appDir)
_ = fileOp.Rename(appDir, backPath) if err := fileOp.Rename(appDir, backPath); err != nil && !startAfter {
_ = fileOp.CreateDir(appDir, constant.DirPerm) return err
}
if err := fileOp.CreateDir(appDir, constant.DirPerm); err != nil && !startAfter {
return err
}
deCompressName := i18n.GetWithName("DeCompressFile", "app.tar.gz") deCompressName := i18n.GetWithName("DeCompressFile", "app.tar.gz")
t.LogStart(deCompressName) t.LogStart(deCompressName)
@@ -361,6 +384,10 @@ func handleAppRecover(install *model.AppInstall, parentTask *task.Task, recoverF
_, _ = file.WriteString(mergedEnvContent) _, _ = file.WriteString(mergedEnvContent)
} }
// The upgrade transaction verifies readiness before saving its restored install.
if !startAfter {
return nil
}
backupInstall.ID = install.ID backupInstall.ID = install.ID
backupInstall.Status = constant.StatusRunning backupInstall.Status = constant.StatusRunning
backupInstall.AppId = install.AppId backupInstall.AppId = install.AppId
@@ -397,6 +424,7 @@ func handleAppRecover(install *model.AppInstall, parentTask *task.Task, recoverF
} }
recoverTask.AddSubTask(task.GetTaskName(install.Name, task.TaskRecover, task.TaskScopeBackup), recoverApp, rollBackApp) recoverTask.AddSubTask(task.GetTaskName(install.Name, task.TaskRecover, task.TaskScopeBackup), recoverApp, rollBackApp)
go func() { go func() {
defer cleanup()
_ = recoverTask.Execute() _ = recoverTask.Execute()
}() }()
return nil return nil
+80 -44
View File
@@ -371,43 +371,39 @@ func loadComposePathAndFiles(composeName string, dockerClient *client.Client) (s
} }
config := targetContainer.Labels[composeConfigLabel] config := targetContainer.Labels[composeConfigLabel]
workdir := targetContainer.Labels[composeWorkdirLabel] workdir := targetContainer.Labels[composeWorkdirLabel]
if len(config) != 0 && len(workdir) != 0 && strings.Contains(config, workdir) { files, err := resolveComposeFiles(workdir, config)
composePath = config if err != nil {
} else { return "", nil, err
composePath = workdir
} }
composePath = strings.Join(files, ",")
} }
composeFiles := normalizeComposeFiles(composePath) composeFiles, err := normalizeComposeFiles(composePath)
if len(composeFiles) == 0 { if err != nil {
return "", nil, fmt.Errorf("compose file not found for %s", composeName) return "", nil, err
} }
return composePath, composeFiles, nil return strings.Join(composeFiles, ","), composeFiles, nil
} }
func normalizeComposeFiles(composePath string) []string { func normalizeComposeFiles(composePath string) ([]string, error) {
items := strings.Split(composePath, ",") files, err := resolveComposeFiles("", composePath)
result := make([]string, 0) if err != nil {
seen := make(map[string]struct{}) return nil, err
for _, item := range items {
item = strings.TrimSpace(item)
if item == "" {
continue
} }
stat, err := os.Stat(item) for i, file := range files {
if err == nil && stat.IsDir() { info, err := os.Stat(file)
item = path.Join(item, "docker-compose.yml") if err == nil && info.IsDir() {
file = filepath.Join(file, "docker-compose.yml")
info, err = os.Stat(file)
files[i] = file
} }
if _, err := os.Stat(item); err != nil { if err != nil {
continue return nil, err
} }
if _, ok := seen[item]; ok { if !info.Mode().IsRegular() {
continue return nil, fmt.Errorf("compose path %s is not a regular file", file)
} }
seen[item] = struct{}{}
result = append(result, item)
} }
sort.Strings(result) return files, nil
return result
} }
func (c *composeBackupContext) close() { func (c *composeBackupContext) close() {
@@ -621,20 +617,46 @@ func safeJoinWithinBase(baseDir, name string) (string, error) {
} }
func stepRestoreComposeFiles(recoverCtx *composeRecoverContext) error { func stepRestoreComposeFiles(recoverCtx *composeRecoverContext) error {
if recoverCtx.targetDir != "" { composeFiles := recoverCtx.meta.Files
_ = os.RemoveAll(recoverCtx.targetDir) if strings.TrimSpace(recoverCtx.meta.ComposePath) != "" {
filesByPath := make(map[string]composeBackupFile, len(composeFiles))
for _, item := range composeFiles {
filesByPath[filepath.Clean(item.OriginalPath)] = item
} }
if err := os.MkdirAll(recoverCtx.targetDir, os.ModePerm); err != nil { orderedFiles := make([]composeBackupFile, 0, len(composeFiles))
return err for _, file := range strings.Split(recoverCtx.meta.ComposePath, ",") {
file = strings.TrimSpace(file)
if file == "" {
continue
} }
restored := make([]string, 0, len(recoverCtx.meta.Files)) file = filepath.Clean(file)
for _, item := range recoverCtx.meta.Files { item, ok := filesByPath[file]
if !ok {
if len(composeFiles) != 1 || file != filepath.Dir(composeFiles[0].OriginalPath) {
return fmt.Errorf("compose file %s is missing from backup metadata", file)
}
item = composeFiles[0]
}
orderedFiles = append(orderedFiles, item)
}
composeFiles = orderedFiles
}
if len(composeFiles) == 0 {
return fmt.Errorf("compose files are missing from backup metadata")
}
restored := make([]string, 0, len(composeFiles))
sourceByTarget := make(map[string]string, len(composeFiles))
for _, item := range composeFiles {
backupPath, err := safeJoinWithinBase(recoverCtx.tmpDir, item.BackupPath) backupPath, err := safeJoinWithinBase(recoverCtx.tmpDir, item.BackupPath)
if err != nil { if err != nil {
return fmt.Errorf("invalid compose backup path %q, err: %v", item.BackupPath, err) return fmt.Errorf("invalid compose backup path %q, err: %v", item.BackupPath, err)
} }
if !recoverCtx.fileOp.Stat(backupPath) { info, err := os.Stat(backupPath)
continue if err != nil {
return err
}
if !info.Mode().IsRegular() {
return fmt.Errorf("compose backup path %s is not a regular file", backupPath)
} }
targetName := item.FileName targetName := item.FileName
if item.RelativePath != "" { if item.RelativePath != "" {
@@ -650,6 +672,29 @@ func stepRestoreComposeFiles(recoverCtx *composeRecoverContext) error {
if err != nil { if err != nil {
return fmt.Errorf("invalid compose target path %q, err: %v", targetName, err) return fmt.Errorf("invalid compose target path %q, err: %v", targetName, err)
} }
sourcePath := item.OriginalPath
if sourcePath == "" {
sourcePath = backupPath
}
sourcePath = filepath.Clean(sourcePath)
if previous, ok := sourceByTarget[targetPath]; ok && previous != sourcePath {
return fmt.Errorf("compose files %q and %q resolve to the same restore path %q", previous, sourcePath, targetPath)
}
sourceByTarget[targetPath] = sourcePath
restored = append(restored, targetPath)
}
if recoverCtx.targetDir != "" {
_ = os.RemoveAll(recoverCtx.targetDir)
}
if err := os.MkdirAll(recoverCtx.targetDir, os.ModePerm); err != nil {
return err
}
for i, item := range composeFiles {
backupPath, err := safeJoinWithinBase(recoverCtx.tmpDir, item.BackupPath)
if err != nil {
return fmt.Errorf("invalid compose backup path %q, err: %v", item.BackupPath, err)
}
targetPath := restored[i]
if err := os.MkdirAll(path.Dir(targetPath), os.ModePerm); err != nil { if err := os.MkdirAll(path.Dir(targetPath), os.ModePerm); err != nil {
return err return err
} }
@@ -660,7 +705,6 @@ func stepRestoreComposeFiles(recoverCtx *composeRecoverContext) error {
if err := recoverCtx.fileOp.SaveFile(targetPath, string(content), fs.ModePerm); err != nil { if err := recoverCtx.fileOp.SaveFile(targetPath, string(content), fs.ModePerm); err != nil {
return err return err
} }
restored = append(restored, targetPath)
} }
envPath := path.Join(recoverCtx.tmpDir, "compose_files", ".env") envPath := path.Join(recoverCtx.tmpDir, "compose_files", ".env")
if recoverCtx.fileOp.Stat(envPath) { if recoverCtx.fileOp.Stat(envPath) {
@@ -672,14 +716,6 @@ func stepRestoreComposeFiles(recoverCtx *composeRecoverContext) error {
return err 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, ",") recoverCtx.composePath = strings.Join(restored, ",")
return nil return nil
} }
+1 -1
View File
@@ -261,7 +261,7 @@ func doWebsiteBackup(website *model.Website, parentTask *task.Task, backupDir, f
return err return err
} }
nginxConfFile := GetWebsiteConfigPath(*website) nginxConfFile := GetWebsiteConfigPath(*website)
if err := fileOp.CopyFile(nginxConfFile, tmpDir); err != nil { if err := exportWebsiteNginxConfig(*website, nginxConfFile, tmpDir); err != nil {
return err return err
} }
parentTask.Log(i18n.GetMsgByKey("BackupNginxConfig")) parentTask.Log(i18n.GetMsgByKey("BackupNginxConfig"))
+124 -81
View File
@@ -58,10 +58,10 @@ type ContainerService struct{}
var containerLogAnsiRegex = regexp.MustCompile("\x1b\\[[0-9;?]*[A-Za-z]|\x1b=|\x1b>") var containerLogAnsiRegex = regexp.MustCompile("\x1b\\[[0-9;?]*[A-Za-z]|\x1b=|\x1b>")
type IContainerService interface { type IContainerService interface {
Page(req dto.PageContainer) (int64, interface{}, error) Page(ctx context.Context, req dto.PageContainer) (int64, interface{}, error)
List() []dto.ContainerOptions List() []dto.ContainerOptions
ListByImage(imageName string) []dto.ContainerOptions ListByImage(imageName string) []dto.ContainerOptions
LoadStatus() (dto.ContainerStatus, error) LoadStatus(ctx context.Context, containersOnly bool) (dto.ContainerStatus, error)
PageNetwork(req dto.SearchWithPage) (int64, interface{}, error) PageNetwork(req dto.SearchWithPage) (int64, interface{}, error)
ListNetwork() ([]dto.Options, error) ListNetwork() ([]dto.Options, error)
PageVolume(req dto.SearchWithPage) (int64, interface{}, error) PageVolume(req dto.SearchWithPage) (int64, interface{}, error)
@@ -80,7 +80,7 @@ type IContainerService interface {
ContainerUpdate(req dto.ContainerOperate) error ContainerUpdate(req dto.ContainerOperate) error
ContainerUpgrade(req dto.ContainerUpgrade) error ContainerUpgrade(req dto.ContainerUpgrade) error
ContainerInfo(req dto.OperationWithName) (*dto.ContainerOperate, error) ContainerInfo(req dto.OperationWithName) (*dto.ContainerOperate, error)
ContainerListStats() ([]dto.ContainerListStats, error) ContainerListStats(ctx context.Context, ids []string) ([]dto.ContainerListStats, error)
ContainerItemStats(ctx context.Context, req dto.OperationWithName) (dto.ContainerItemStats, error) ContainerItemStats(ctx context.Context, req dto.OperationWithName) (dto.ContainerItemStats, error)
LoadResourceLimit() (*dto.ResourceLimit, error) LoadResourceLimit() (*dto.ResourceLimit, error)
ContainerRename(req dto.ContainerRename) error ContainerRename(req dto.ContainerRename) error
@@ -112,7 +112,7 @@ func NewIContainerService() IContainerService {
return &ContainerService{} return &ContainerService{}
} }
func (u *ContainerService) Page(req dto.PageContainer) (int64, interface{}, error) { func (u *ContainerService) Page(ctx context.Context, req dto.PageContainer) (int64, interface{}, error) {
client, err := docker.NewDockerClient() client, err := docker.NewDockerClient()
if err != nil { if err != nil {
return 0, nil, err return 0, nil, err
@@ -123,7 +123,7 @@ func (u *ContainerService) Page(req dto.PageContainer) (int64, interface{}, erro
options.Filters = filters.NewArgs() options.Filters = filters.NewArgs()
options.Filters.Add("label", req.Filters) options.Filters.Add("label", req.Filters)
} }
containers, err := client.ContainerList(context.Background(), options) containers, err := client.ContainerList(ctx, options)
if err != nil { if err != nil {
return 0, nil, err return 0, nil, err
} }
@@ -206,27 +206,30 @@ func (u *ContainerService) ListByImage(imageName string) []dto.ContainerOptions
return options return options
} }
func (u *ContainerService) LoadStatus() (dto.ContainerStatus, error) { func (u *ContainerService) LoadStatus(ctx context.Context, containersOnly bool) (dto.ContainerStatus, error) {
var data dto.ContainerStatus var data dto.ContainerStatus
client, err := docker.NewDockerClient() client, err := docker.NewDockerClient()
if err != nil { if err != nil {
return data, err return data, err
} }
defer client.Close() defer client.Close()
c := context.Background() if !containersOnly {
images, _ := client.ImageList(ctx, image.ListOptions{All: true})
images, _ := client.ImageList(c, image.ListOptions{All: true})
data.ImageCount = len(images) data.ImageCount = len(images)
repo, _ := imageRepoRepo.List() repo, _ := imageRepoRepo.List()
data.RepoCount = len(repo) data.RepoCount = len(repo)
templates, _ := composeRepo.List() templates, _ := composeRepo.List()
data.ComposeTemplateCount = len(templates) data.ComposeTemplateCount = len(templates)
networks, _ := client.NetworkList(c, network.ListOptions{}) networks, _ := client.NetworkList(ctx, network.ListOptions{})
data.NetworkCount = len(networks) data.NetworkCount = len(networks)
volumes, _ := client.VolumeList(c, volume.ListOptions{}) volumes, _ := client.VolumeList(ctx, volume.ListOptions{})
data.VolumeCount = len(volumes.Volumes) data.VolumeCount = len(volumes.Volumes)
data.ComposeCount = loadComposeCount(client) data.ComposeCount = loadComposeCount(client)
containers, _ := client.ContainerList(c, container.ListOptions{All: true}) }
containers, err := client.ContainerList(ctx, container.ListOptions{All: true})
if err != nil {
return data, err
}
data.ContainerCount = len(containers) data.ContainerCount = len(containers)
for _, item := range containers { for _, item := range containers {
switch item.State { switch item.State {
@@ -292,25 +295,48 @@ func (u *ContainerService) ContainerItemStats(ctx context.Context, req dto.Opera
} }
return data, nil return data, nil
} }
func (u *ContainerService) ContainerListStats() ([]dto.ContainerListStats, error) { func (u *ContainerService) ContainerListStats(ctx context.Context, ids []string) ([]dto.ContainerListStats, error) {
client, err := docker.NewDockerClient() client, err := docker.NewDockerClient()
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer client.Close() defer client.Close()
list, err := client.ContainerList(context.Background(), container.ListOptions{All: true}) options := container.ListOptions{All: true}
if ids != nil {
if len(ids) == 0 {
return []dto.ContainerListStats{}, nil
}
options.Filters = filters.NewArgs()
for _, id := range ids {
options.Filters.Add("id", id)
}
}
list, err := client.ContainerList(ctx, options)
if err != nil { if err != nil {
return nil, err return nil, err
} }
datas := make([]dto.ContainerListStats, len(list)) datas := make([]dto.ContainerListStats, len(list))
jobs := make(chan int)
var wg sync.WaitGroup var wg sync.WaitGroup
wg.Add(len(list)) for worker := 0; worker < min(8, len(list)); worker++ {
for i := 0; i < len(list); i++ { wg.Add(1)
go func(index int, item container.Summary) { go func() {
datas[index] = loadCpuAndMem(client, item.ID) defer wg.Done()
wg.Done() for index := range jobs {
}(i, list[i]) datas[index].ContainerID = list[index].ID
if ctx.Err() == nil && list[index].State == "running" {
datas[index] = loadCpuAndMem(ctx, client, list[index].ID)
} }
if len(list[index].Names) > 0 {
datas[index].ContainerName = strings.TrimPrefix(list[index].Names[0], "/")
}
}
}()
}
for index := range list {
jobs <- index
}
close(jobs)
wg.Wait() wg.Wait()
return datas, nil return datas, nil
} }
@@ -326,40 +352,47 @@ func (u *ContainerService) Inspect(req dto.InspectReq) (string, error) {
case "container": case "container":
inspectInfo, err = client.ContainerInspect(context.Background(), req.ID) inspectInfo, err = client.ContainerInspect(context.Background(), req.ID)
case "compose": case "compose":
filePath := "" config, workdir := "", ""
cli, err := docker.NewDockerClient()
if err != nil {
return "", err
}
defer cli.Close()
options := container.ListOptions{All: true} options := container.ListOptions{All: true}
options.Filters = filters.NewArgs() options.Filters = filters.NewArgs()
options.Filters.Add("label", fmt.Sprintf("%s=%s", composeProjectLabel, req.ID)) options.Filters.Add("label", fmt.Sprintf("%s=%s", composeProjectLabel, req.ID))
containers, err := cli.ContainerList(context.Background(), options) containers, err := client.ContainerList(context.Background(), options)
if err != nil { if err != nil {
return "", err return "", err
} }
for _, container := range containers {
config := container.Labels[composeConfigLabel]
if len(req.Detail) != 0 && strings.Contains(config, req.Detail) {
config = req.Detail
}
workdir := container.Labels[composeWorkdirLabel]
if len(config) != 0 && len(workdir) != 0 && strings.Contains(config, workdir) {
filePath = config
break
} else {
filePath = workdir
break
}
}
if len(containers) == 0 { if len(containers) == 0 {
composeItem, _ := composeRepo.GetRecord(repo.WithByName(req.ID)) composeItem, err := composeRepo.GetRecord(repo.WithByName(req.ID))
filePath = composeItem.Path if err != nil {
}
if _, err := os.Stat(filePath); err != nil {
return "", err return "", err
} }
config = composeItem.Path
workdir = filepath.Dir(primaryComposePath(config))
} else {
config = containers[0].Labels[composeConfigLabel]
workdir = containers[0].Labels[composeWorkdirLabel]
}
composeFiles, err := resolveComposeFiles(workdir, config)
if err != nil {
return "", err
}
filePath := composeFiles[0]
if strings.TrimSpace(req.Detail) != "" {
selectedFiles, err := resolveComposeFiles(workdir, req.Detail)
if err != nil {
return "", err
}
if len(selectedFiles) != 1 || !slices.Contains(composeFiles, selectedFiles[0]) {
return "", fmt.Errorf("compose file %s does not belong to project %s", req.Detail, req.ID)
}
filePath = selectedFiles[0]
}
info, err := os.Stat(filePath)
if err != nil {
return "", err
}
if !info.Mode().IsRegular() {
return "", fmt.Errorf("compose path %s is not a regular file", filePath)
}
content, err := os.ReadFile(filePath) content, err := os.ReadFile(filePath)
if err != nil { if err != nil {
return "", err return "", err
@@ -383,11 +416,6 @@ func (u *ContainerService) Inspect(req dto.InspectReq) (string, error) {
} }
func (u *ContainerService) Prune(req dto.ContainerPrune) error { func (u *ContainerService) Prune(req dto.ContainerPrune) error {
client, err := docker.NewDockerClient()
if err != nil {
return err
}
defer client.Close()
name := "" name := ""
switch req.PruneType { switch req.PruneType {
case "container": case "container":
@@ -408,6 +436,14 @@ func (u *ContainerService) Prune(req dto.ContainerPrune) error {
} }
taskItem.AddSubTask(i18n.GetMsgByKey("TaskClean"), func(t *task.Task) error { taskItem.AddSubTask(i18n.GetMsgByKey("TaskClean"), func(t *task.Task) error {
if err := t.TaskCtx.Err(); err != nil {
return err
}
client, err := docker.NewDockerClient()
if err != nil {
return err
}
defer client.Close()
pruneFilters := filters.NewArgs() pruneFilters := filters.NewArgs()
if req.WithTagAll { if req.WithTagAll {
pruneFilters.Add("dangling", "false") pruneFilters.Add("dangling", "false")
@@ -431,10 +467,7 @@ func (u *ContainerService) Prune(req dto.ContainerPrune) error {
} }
SpaceReclaimed = int(rep.SpaceReclaimed) SpaceReclaimed = int(rep.SpaceReclaimed)
case "network": case "network":
_, err := client.NetworksPrune(context.Background(), pruneFilters) return cleanUnusedNetworks(t, client)
if err != nil {
return err
}
case "volume": case "volume":
versions, err := client.ServerVersion(context.Background()) versions, err := client.ServerVersion(context.Background())
if err != nil { if err != nil {
@@ -803,33 +836,33 @@ func (u *ContainerService) ContainerLogClean(req dto.OperationWithName) error {
func (u *ContainerService) StreamLogs(ctx *gin.Context, params dto.StreamLog) { func (u *ContainerService) StreamLogs(ctx *gin.Context, params dto.StreamLog) {
messageChan := make(chan string, 1024) messageChan := make(chan string, 1024)
errorChan := make(chan error, 1) errorChan := make(chan error, 1)
doneChan := make(chan struct{}) logCtx, cancel := context.WithCancel(ctx.Request.Context())
defer cancel()
go func() { doneChan := logCtx.Done()
<-ctx.Request.Context().Done()
close(doneChan)
}()
go collectLogs(doneChan, params, messageChan, errorChan) go collectLogs(doneChan, params, messageChan, errorChan)
ctx.Stream(func(w io.Writer) bool { ctx.Stream(func(w io.Writer) bool {
select { return writeContainerLogMessage(w, doneChan, messageChan, errorChan)
case msg, ok := <-messageChan:
if !ok {
return false
}
_, err := fmt.Fprintf(w, "data: %s\n\n", msg)
if err != nil {
return false
}
return true
case err := <-errorChan:
if err != nil {
_, _ = fmt.Fprintf(w, "event: error\ndata: %v\n\n", err.Error())
}
return false
}
}) })
} }
func writeContainerLogMessage(w io.Writer, done <-chan struct{}, messages <-chan string, errors <-chan error) bool {
select {
case <-done:
return false
case message, ok := <-messages:
if ok {
_, err := fmt.Fprintf(w, "data: %s\n\n", message)
return err == nil
}
// The collector closes both channels when it finishes. Drain all buffered
// logs before handling its result, or a ready error channel can drop the tail.
if err := <-errors; err != nil {
_, _ = fmt.Fprintf(w, "event: error\ndata: %v\n\n", err)
}
return false
}
}
func collectLogs(done <-chan struct{}, params dto.StreamLog, messageChan chan<- string, errorChan chan<- error) { func collectLogs(done <-chan struct{}, params dto.StreamLog, messageChan chan<- string, errorChan chan<- error) {
defer close(messageChan) defer close(messageChan)
defer close(errorChan) defer close(errorChan)
@@ -1627,6 +1660,10 @@ func calculateNetwork(network map[string]container.NetworkStats) (float64, float
} }
func checkImageExist(client *client.Client, imageItem string) bool { func checkImageExist(client *client.Client, imageItem string) bool {
return checkImageExistContext(context.Background(), client, imageItem)
}
func checkImageExistContext(ctx context.Context, client *client.Client, imageItem string) bool {
if client == nil { if client == nil {
var err error var err error
client, err = docker.NewDockerClient() client, err = docker.NewDockerClient()
@@ -1634,7 +1671,7 @@ func checkImageExist(client *client.Client, imageItem string) bool {
return false return false
} }
} }
images, err := client.ImageList(context.Background(), image.ListOptions{}) images, err := client.ImageList(ctx, image.ListOptions{})
if err != nil { if err != nil {
return false return false
} }
@@ -1676,6 +1713,10 @@ func checkImageLike(client *client.Client, imageName string) bool {
} }
func pullImages(task *task.Task, client *client.Client, imageName string) error { func pullImages(task *task.Task, client *client.Client, imageName string) error {
return pullImagesContext(context.Background(), task, client, imageName)
}
func pullImagesContext(ctx context.Context, task *task.Task, client *client.Client, imageName string) error {
dockerCli := docker.NewClientWithExist(client) dockerCli := docker.NewClientWithExist(client)
repos, err := imageRepoRepo.List() repos, err := imageRepoRepo.List()
if err != nil { if err != nil {
@@ -1683,7 +1724,7 @@ func pullImages(task *task.Task, client *client.Client, imageName string) error
} }
imageRepo := selectImageRepo(imageName, repos) imageRepo := selectImageRepo(imageName, repos)
if imageRepo == nil || !imageRepo.Auth { if imageRepo == nil || !imageRepo.Auth {
return dockerCli.PullImageWithProcess(task, imageName) return dockerCli.PullImageWithProcessContext(ctx, task, imageName)
} }
options := image.PullOptions{} options := image.PullOptions{}
@@ -1696,7 +1737,7 @@ func pullImages(task *task.Task, client *client.Client, imageName string) error
return err return err
} }
options.RegistryAuth = base64.URLEncoding.EncodeToString(encodedJSON) options.RegistryAuth = base64.URLEncoding.EncodeToString(encodedJSON)
return dockerCli.PullImageWithProcessAndOptions(task, imageName, options) return dockerCli.PullImageWithProcessAndOptionsContext(ctx, task, imageName, options)
} }
func selectImageRepo(imageName string, repos []model.ImageRepo) *model.ImageRepo { func selectImageRepo(imageName string, repos []model.ImageRepo) *model.ImageRepo {
@@ -1715,11 +1756,13 @@ func selectImageRepo(imageName string, repos []model.ImageRepo) *model.ImageRepo
return selected return selected
} }
func loadCpuAndMem(client *client.Client, containerItem string) dto.ContainerListStats { func loadCpuAndMem(ctx context.Context, client *client.Client, containerItem string) dto.ContainerListStats {
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
data := dto.ContainerListStats{ data := dto.ContainerListStats{
ContainerID: containerItem, ContainerID: containerItem,
} }
res, err := client.ContainerStats(context.Background(), containerItem, false) res, err := client.ContainerStats(ctx, containerItem, false)
if err != nil { if err != nil {
return data return data
} }
+71 -49
View File
@@ -71,10 +71,9 @@ func (u *ContainerService) PageCompose(req dto.SearchWithPage) (int64, interface
ContainerCount: 0, ContainerCount: 0,
CreatedAt: localItem.CreatedAt.Format(constant.DateTimeLayout), CreatedAt: localItem.CreatedAt.Format(constant.DateTimeLayout),
ConfigFile: localItem.Path, ConfigFile: localItem.Path,
Workdir: strings.TrimSuffix(localItem.Path, "/docker-compose.yml"), Workdir: filepath.Dir(primaryComposePath(localItem.Path)),
} }
composeItemLocal.CreatedBy = "1Panel" composeItemLocal.CreatedBy = "1Panel"
composeItemLocal.Path = localItem.Path
composeLocalMap[localItem.Name] = composeItemLocal composeLocalMap[localItem.Name] = composeItemLocal
} }
@@ -112,11 +111,6 @@ func (u *ContainerService) PageCompose(req dto.SearchWithPage) (int64, interface
if ok { if ok {
composeItem.CreatedBy = createdBy composeItem.CreatedBy = createdBy
} }
if len(config) != 0 && len(workdir) != 0 && strings.Contains(config, workdir) {
composeItem.Path = config
} else {
composeItem.Path = workdir
}
for i := 0; i < len(composeCreatedByLocal); i++ { for i := 0; i < len(composeCreatedByLocal); i++ {
if composeCreatedByLocal[i].Name == name { if composeCreatedByLocal[i].Name == name {
composeItem.CreatedBy = "1Panel" composeItem.CreatedBy = "1Panel"
@@ -147,7 +141,9 @@ func (u *ContainerService) PageCompose(req dto.SearchWithPage) (int64, interface
for key, value := range mergedMap { for key, value := range mergedMap {
value.Name = key value.Name = key
value.ComposeFileExists = composeFileExists(value.Workdir, value.ConfigFile) composeFiles, err := resolveComposeFiles(value.Workdir, value.ConfigFile)
value.Path = strings.Join(composeFiles, ",")
value.ComposeFileExists = err == nil && composeFileExists(composeFiles)
value.IsPinned = pinnedByName[key] value.IsPinned = pinnedByName[key]
records = append(records, value) records = append(records, value)
} }
@@ -192,29 +188,6 @@ func (u *ContainerService) PageCompose(req dto.SearchWithPage) (int64, interface
return int64(total), listItem, nil 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) { func (u *ContainerService) TestCompose(req dto.ComposeCreate) (bool, error) {
if err := validateComposeCreateName(req); err != nil { if err := validateComposeCreateName(req); err != nil {
return false, err return false, err
@@ -522,10 +495,6 @@ func isComposeProjectNameEmptyError(err error) bool {
} }
func (u *ContainerService) ComposeOperation(req dto.ComposeOperation) error { func (u *ContainerService) ComposeOperation(req dto.ComposeOperation) error {
if len(req.Path) == 0 && req.Operation == "delete" {
_ = composeRepo.DeleteRecord(repo.WithByName(req.Name))
return nil
}
if cmd.CheckIllegal(req.Path, req.Operation) { if cmd.CheckIllegal(req.Path, req.Operation) {
return buserr.New("ErrCmdIllegal") return buserr.New("ErrCmdIllegal")
} }
@@ -535,13 +504,19 @@ func (u *ContainerService) ComposeOperation(req dto.ComposeOperation) error {
} }
if req.WithFile { if req.WithFile {
for _, item := range strings.Split(req.Path, ",") { for _, item := range strings.Split(req.Path, ",") {
if len(item) != 0 { item = strings.TrimSpace(item)
_ = os.RemoveAll(path.Dir(item)) if !filepath.IsAbs(item) || filepath.Dir(item) == "/" {
continue
}
info, err := os.Stat(item)
if err == nil && info.Mode().IsRegular() {
if err := os.RemoveAll(filepath.Dir(item)); err != nil && !req.Force {
return err
} }
} }
} }
_ = composeRepo.DeleteRecord(repo.WithByName(req.Name)) }
return nil return composeRepo.DeleteRecord(repo.WithByName(req.Name))
} }
if req.Operation == "up" { if req.Operation == "up" {
if stdout, err := compose.Up(req.Path, req.Name); err != nil { if stdout, err := compose.Up(req.Path, req.Name); err != nil {
@@ -584,13 +559,13 @@ func (u *ContainerService) ComposeUpdate(req dto.ComposeUpdate) error {
write.Flush() write.Flush()
global.LOG.Infof("docker-compose.yml %s has been replaced, now start to docker-compose restart", req.DetailPath) 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 { if err := newComposeEnv(primaryComposePath(req.Path), req.Env); err != nil {
return err return err
} }
if err := compose.UpWithTask(req.Path, t, req.ForcePull, req.Name); err != nil { if err := compose.UpWithTask(req.Path, t, req.ForcePull, req.Name); err != nil {
global.LOG.Errorf("update failed when handle compose up, err: %s, now try to recreate the old compose file", err) 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, req.Name); err != nil { if err := recreateCompose(string(oldFile), req.Path, req.DetailPath, req.Name); err != nil {
return fmt.Errorf("update failed and recreate old compose file also failed, err: %v", err) 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 fmt.Errorf("update failed when handle compose up, err: %s", err)
@@ -702,9 +677,18 @@ func (u *ContainerService) loadPath(req *dto.ComposeCreate) error {
} }
func removeContainerForCompose(composeName, composePath string) error { func removeContainerForCompose(composeName, composePath string) error {
if _, err := os.Stat(composePath); err == nil { composeFiles, err := resolveComposeFiles("", composePath)
if stdout, err := compose.Operate(composePath, "down", composeName); err != nil { filesAvailable := err == nil
return errors.New(stdout) for _, file := range composeFiles {
info, err := os.Stat(file)
if err != nil || !info.Mode().IsRegular() {
filesAvailable = false
break
}
}
if filesAvailable {
if stdout, err := compose.Operate(strings.Join(composeFiles, ","), "down", composeName); err != nil {
return fmt.Errorf("docker-compose down failed, std: %s, err: %w", stdout, err)
} }
return nil return nil
} }
@@ -722,14 +706,17 @@ func removeContainerForCompose(composeName, composePath string) error {
if err != nil { if err != nil {
return err return err
} }
var removeErrors []error
for _, c := range containers { for _, c := range containers {
_ = client.ContainerRemove(ctx, c.ID, container.RemoveOptions{RemoveVolumes: true, Force: true}) if err := client.ContainerRemove(ctx, c.ID, container.RemoveOptions{RemoveVolumes: true, Force: true}); err != nil {
removeErrors = append(removeErrors, fmt.Errorf("remove container %s failed: %w", c.ID, err))
} }
return nil }
return errors.Join(removeErrors...)
} }
func recreateCompose(content, path, projectName string) error { func recreateCompose(content, composePath, detailPath, projectName string) error {
file, err := os.OpenFile(path, os.O_WRONLY|os.O_TRUNC, 0640) file, err := os.OpenFile(detailPath, os.O_WRONLY|os.O_TRUNC, 0640)
if err != nil { if err != nil {
return err return err
} }
@@ -738,7 +725,7 @@ func recreateCompose(content, path, projectName string) error {
_, _ = write.WriteString(content) _, _ = write.WriteString(content)
write.Flush() write.Flush()
if stdout, err := compose.Up(path, projectName); err != nil { if stdout, err := compose.Up(composePath, projectName); err != nil {
return errors.New(string(stdout)) return errors.New(string(stdout))
} }
return nil return nil
@@ -746,6 +733,9 @@ func recreateCompose(content, path, projectName string) error {
func loadEnv(list []dto.ComposeInfo) []dto.ComposeInfo { func loadEnv(list []dto.ComposeInfo) []dto.ComposeInfo {
for i := 0; i < len(list); i++ { for i := 0; i < len(list); i++ {
if list[i].Path == "" {
continue
}
tmpPath := list[i].Path tmpPath := list[i].Path
if strings.Contains(list[i].Path, ",") { if strings.Contains(list[i].Path, ",") {
tmpPath = strings.Split(list[i].Path, ",")[0] tmpPath = strings.Split(list[i].Path, ",")[0]
@@ -774,3 +764,35 @@ func newComposeEnv(pathItem string, env string) error {
} }
return nil return nil
} }
func resolveComposeFiles(workdir, configFile string) ([]string, error) {
workdir = strings.TrimSpace(workdir)
var files []string
for _, item := range strings.Split(configFile, ",") {
file := strings.TrimSpace(item)
if file == "" {
continue
}
if !filepath.IsAbs(file) {
if !filepath.IsAbs(workdir) {
return nil, fmt.Errorf("cannot resolve compose file %s without an absolute working directory", file)
}
file = filepath.Join(workdir, file)
}
files = append(files, filepath.Clean(file))
}
if len(files) == 0 {
return nil, fmt.Errorf("compose config file is not specified")
}
return files, nil
}
func composeFileExists(files []string) bool {
for _, file := range files {
info, err := os.Stat(file)
if err == nil && info.Mode().IsRegular() {
return true
}
}
return false
}
+119
View File
@@ -2,16 +2,24 @@ package service
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"sort" "sort"
"strings" "strings"
"github.com/1Panel-dev/1Panel/agent/app/dto" "github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/app/task"
"github.com/1Panel-dev/1Panel/agent/buserr" "github.com/1Panel-dev/1Panel/agent/buserr"
"github.com/1Panel-dev/1Panel/agent/i18n"
"github.com/1Panel-dev/1Panel/agent/utils/docker" "github.com/1Panel-dev/1Panel/agent/utils/docker"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/network" "github.com/docker/docker/api/types/network"
"github.com/docker/docker/client"
"github.com/docker/docker/errdefs"
) )
var networkCleanupSlot = make(chan struct{}, 1)
func (u *ContainerService) PageNetwork(req dto.SearchWithPage) (int64, interface{}, error) { func (u *ContainerService) PageNetwork(req dto.SearchWithPage) (int64, interface{}, error) {
client, err := docker.NewDockerClient() client, err := docker.NewDockerClient()
if err != nil { if err != nil {
@@ -172,3 +180,114 @@ func (u *ContainerService) CreateNetwork(req dto.NetworkCreate) error {
} }
return nil return nil
} }
func cleanUnusedNetworks(t *task.Task, cli *client.Client) error {
ctx := t.TaskCtx
select {
case networkCleanupSlot <- struct{}{}:
defer func() { <-networkCleanupSlot }()
case <-ctx.Done():
return ctx.Err()
}
if err := ctx.Err(); err != nil {
return err
}
networks, err := cli.NetworkList(ctx, network.ListOptions{})
if err != nil {
if ctx.Err() != nil {
return ctx.Err()
}
return err
}
deleted, skipped, failed := 0, 0, 0
defer func() {
t.Log(i18n.GetMsgWithMap("NetworkCleanupSummary", map[string]interface{}{
"deleted": deleted, "skipped": skipped, "failed": failed,
}))
}()
var used map[string]bool
sort.Slice(networks, func(i, j int) bool { return networks[i].Name < networks[j].Name })
for _, n := range networks {
if err := ctx.Err(); err != nil {
return err
}
switch n.Name {
case "none", "host", "bridge", "1panel-network":
skipped++
continue
}
if n.Scope != "local" || n.Ingress || n.ConfigOnly {
skipped++
continue
}
values := map[string]interface{}{"name": n.Name, "id": n.ID}
if used == nil {
containers, err := cli.ContainerList(ctx, container.ListOptions{All: true})
if err != nil {
if ctx.Err() != nil {
return ctx.Err()
}
return err
}
used = make(map[string]bool)
for _, c := range containers {
if c.NetworkSettings == nil {
continue
}
for name, endpoint := range c.NetworkSettings.Networks {
used[name] = true
if endpoint != nil {
used[endpoint.NetworkID] = true
}
}
}
}
if used[n.Name] || used[n.ID] {
skipped++
t.Log(i18n.GetMsgWithMap("NetworkCleanupConnected", values))
continue
}
inspected, err := cli.NetworkInspect(ctx, n.ID, network.InspectOptions{})
if err != nil {
if ctx.Err() != nil {
return ctx.Err()
}
if errdefs.IsNotFound(err) {
skipped++
} else {
failed++
t.Logf("Failed to inspect network [%s] (%s): %v", n.Name, n.ID, err)
}
continue
}
if len(inspected.Containers) > 0 {
skipped++
t.Log(i18n.GetMsgWithMap("NetworkCleanupConnected", values))
continue
}
if err := cli.NetworkRemove(ctx, n.ID); err != nil {
if ctx.Err() != nil {
return ctx.Err()
}
switch {
case errdefs.IsNotFound(err):
skipped++
case errdefs.IsConflict(err):
skipped++
t.Log(i18n.GetMsgWithMap("NetworkCleanupConnected", values))
default:
failed++
t.Logf("Failed to remove network [%s] (%s): %v", n.Name, n.ID, err)
}
continue
}
deleted++
}
if err := ctx.Err(); err != nil {
return err
}
if failed > 0 {
return errors.New(i18n.GetMsgByKey("NetworkCleanupPartialFailure"))
}
return nil
}
+224 -25
View File
@@ -91,28 +91,29 @@ func (u *ContainerService) ContainerUpdate(req dto.ContainerOperate) error {
return nil return nil
} }
const containerUpgradeConcurrency = 2
// Shared by all upgrade requests handled by this agent.
var containerUpgradeSlots = make(chan struct{}, containerUpgradeConcurrency)
func (u *ContainerService) ContainerUpgrade(req dto.ContainerUpgrade) error { func (u *ContainerService) ContainerUpgrade(req dto.ContainerUpgrade) error {
client, err := docker.NewDockerClient() client, err := docker.NewDockerClient()
if err != nil { if err != nil {
return err return err
} }
ctx := context.Background()
taskItem, err := task.NewTaskWithOps(req.Image, task.TaskUpgrade, task.TaskScopeImage, req.TaskID, 1) taskItem, err := task.NewTaskWithOps(req.Image, task.TaskUpgrade, task.TaskScopeImage, req.TaskID, 1)
if err != nil { if err != nil {
_ = client.Close() _ = client.Close()
global.LOG.Errorf("new task for create container failed, err: %v", err) global.LOG.Errorf("new task for create container failed, err: %v", err)
return err return err
} }
go func() {
defer client.Close()
taskItem.AddSubTask(i18n.GetWithName("ContainerImagePull", req.Image), func(t *task.Task) error { taskItem.AddSubTask(i18n.GetWithName("ContainerImagePull", req.Image), func(t *task.Task) error {
ctx, cancel := context.WithTimeout(context.Background(), containerUpgradePullTimeout)
defer cancel()
taskItem.LogStart(i18n.GetWithName("ContainerImagePull", req.Image)) taskItem.LogStart(i18n.GetWithName("ContainerImagePull", req.Image))
if !checkImageExist(client, req.Image) || req.ForcePull { if req.ForcePull || !checkImageExistContext(ctx, client, req.Image) {
if err := pullImages(taskItem, client, req.Image); err != nil { if err := pullImagesContext(ctx, taskItem, client, req.Image); err != nil {
if !req.ForcePull { return fmt.Errorf("pull image %s failed: %w", req.Image, err)
return err
}
return fmt.Errorf("pull image %s failed, err: %v", req.Image, err)
} }
} }
return nil return nil
@@ -123,7 +124,13 @@ func (u *ContainerService) ContainerUpgrade(req dto.ContainerUpgrade) error {
taskItem.AddSubTaskWithIgnoreErr(i18n.GetWithName("ContainerUpgradeItem", item), func(t *task.Task) error { taskItem.AddSubTaskWithIgnoreErr(i18n.GetWithName("ContainerUpgradeItem", item), func(t *task.Task) error {
t.Logf("----------------- %s -----------------", item) t.Logf("----------------- %s -----------------", item)
t.LogStart(i18n.GetWithName("ContainerAcquireLock", item)) t.LogStart(i18n.GetWithName("ContainerAcquireLock", item))
unlock := containerOperationLock.lock(item) ctx, cancel := context.WithTimeout(context.Background(), containerUpgradeReplaceTimeout)
defer cancel()
unlock, lockErr := containerOperationLock.lockContext(ctx, item)
if lockErr != nil {
upgradeErrors = append(upgradeErrors, fmt.Errorf("wait for container %s lock: %w", item, lockErr))
return lockErr
}
defer unlock() defer unlock()
t.LogWithStatus(i18n.GetWithName("ContainerAcquireLock", item), nil) t.LogWithStatus(i18n.GetWithName("ContainerAcquireLock", item), nil)
@@ -136,6 +143,10 @@ func (u *ContainerService) ContainerUpgrade(req dto.ContainerUpgrade) error {
} }
config := cloneContainerConfig(oldContainer.Config) config := cloneContainerConfig(oldContainer.Config)
config.Image = req.Image config.Image = req.Image
if config.Labels == nil {
config.Labels = make(map[string]string)
}
config.Labels[containerUpgradeOwnerLabel] = oldContainer.ID
hostConf := cloneContainerHostConfig(oldContainer.HostConfig) hostConf := cloneContainerHostConfig(oldContainer.HostConfig)
preserveContainerVolumeMounts(hostConf, oldContainer.Mounts) preserveContainerVolumeMounts(hostConf, oldContainer.Mounts)
networkConf, extraNetworks := buildContainerRecoverNetworkConfig(oldContainer.NetworkSettings, hostConf) networkConf, extraNetworks := buildContainerRecoverNetworkConfig(oldContainer.NetworkSettings, hostConf)
@@ -144,9 +155,9 @@ func (u *ContainerService) ContainerUpgrade(req dto.ContainerUpgrade) error {
upgradeErrors = append(upgradeErrors, upgradeErr) upgradeErrors = append(upgradeErrors, upgradeErr)
return upgradeErr return upgradeErr
} }
cleanupErr, err := switchContainer(ctx, client, item, oldContainer, func() (container.CreateResponse, error) { cleanupErr, err := switchContainerWithRecovery(ctx, client, item, oldContainer, func() (container.CreateResponse, error) {
return createContainerWithNetworks(ctx, client, config, hostConf, networkConf, extraNetworks, item) return createContainerWithNetworks(ctx, client, config, hostConf, networkConf, extraNetworks, item)
}, config.Tty, t) }, config.Tty, t, true)
if err != nil { if err != nil {
upgradeErr := fmt.Errorf("upgrade container %s failed: %w", item, err) upgradeErr := fmt.Errorf("upgrade container %s failed: %w", item, err)
upgradeErrors = append(upgradeErrors, upgradeErr) upgradeErrors = append(upgradeErrors, upgradeErr)
@@ -161,11 +172,14 @@ func (u *ContainerService) ContainerUpgrade(req dto.ContainerUpgrade) error {
taskItem.AddSubTask(i18n.GetMsgByKey("ContainerUpgradeSummary"), func(t *task.Task) error { taskItem.AddSubTask(i18n.GetMsgByKey("ContainerUpgradeSummary"), func(t *task.Task) error {
return errors.Join(upgradeErrors...) return errors.Join(upgradeErrors...)
}, nil) }, nil)
if err := taskItem.Execute(); err != nil { // Stage contexts own the deadlines, including a separate recovery budget.
global.LOG.Error(err.Error()) for _, subTask := range taskItem.SubTasks {
subTask.Timeout = 0
}
if err := taskItem.ExecuteQueued(containerUpgradeSlots, "ContainerUpgradeWaiting", func() { _ = client.Close() }); err != nil {
_ = client.Close()
return err
} }
}()
return nil return nil
} }
@@ -192,11 +206,16 @@ type containerOperationMutex struct {
var containerOperationLock containerOperationMutex var containerOperationLock containerOperationMutex
type containerOperationLockEntry struct { type containerOperationLockEntry struct {
mutex sync.Mutex token chan struct{}
references int references int
} }
func (l *containerOperationMutex) lock(names ...string) func() { func (l *containerOperationMutex) lock(names ...string) func() {
unlock, _ := l.lockContext(context.Background(), names...)
return unlock
}
func (l *containerOperationMutex) lockContext(ctx context.Context, names ...string) (func(), error) {
nameSet := make(map[string]struct{}, len(names)) nameSet := make(map[string]struct{}, len(names))
for _, name := range names { for _, name := range names {
if name != "" { if name != "" {
@@ -209,7 +228,7 @@ func (l *containerOperationMutex) lock(names ...string) func() {
} }
sort.Strings(orderedNames) sort.Strings(orderedNames)
if len(orderedNames) == 0 { if len(orderedNames) == 0 {
return func() {} return func() {}, nil
} }
l.mutex.Lock() l.mutex.Lock()
@@ -220,7 +239,7 @@ func (l *containerOperationMutex) lock(names ...string) func() {
for _, name := range orderedNames { for _, name := range orderedNames {
entry := l.locks[name] entry := l.locks[name]
if entry == nil { if entry == nil {
entry = &containerOperationLockEntry{} entry = &containerOperationLockEntry{token: make(chan struct{}, 1)}
l.locks[name] = entry l.locks[name] = entry
} }
entry.references++ entry.references++
@@ -228,12 +247,10 @@ func (l *containerOperationMutex) lock(names ...string) func() {
} }
l.mutex.Unlock() l.mutex.Unlock()
for _, entry := range entries { acquired := 0
entry.mutex.Lock() release := func() {
} for index := acquired - 1; index >= 0; index-- {
return func() { <-entries[index].token
for index := len(entries) - 1; index >= 0; index-- {
entries[index].mutex.Unlock()
} }
l.mutex.Lock() l.mutex.Lock()
defer l.mutex.Unlock() defer l.mutex.Unlock()
@@ -245,6 +262,20 @@ func (l *containerOperationMutex) lock(names ...string) func() {
} }
} }
} }
for _, entry := range entries {
select {
case entry.token <- struct{}{}:
acquired++
case <-ctx.Done():
release()
return nil, ctx.Err()
}
}
if err := ctx.Err(); err != nil {
release()
return nil, err
}
return release, nil
} }
type containerNetworkAttachment struct { type containerNetworkAttachment struct {
@@ -272,6 +303,131 @@ func switchContainer(
createNew func() (container.CreateResponse, error), createNew func() (container.CreateResponse, error),
tty bool, tty bool,
logger containerSwitchLogger, logger containerSwitchLogger,
) (cleanupErr error, err error) {
return switchContainerWithRecovery(ctx, cli, name, oldContainer, createNew, tty, logger, false)
}
const (
containerUpgradePullTimeout = 30 * time.Minute
containerUpgradeReplaceTimeout = 15 * time.Minute
containerUpgradeRecoveryTimeout = 5 * time.Minute
containerUpgradeOwnerLabel = "io.1panel.upgrade.original-id"
)
// Reconcile against Docker's actual state: a failed request may already have
// changed names, created a container, or disconnected a network on the daemon.
func recoverContainerUpgrade(ctx context.Context, cli containerSwitchClient, name string, old container.InspectResponse, newID string, logger containerSwitchLogger) error {
original, err := cli.ContainerInspect(ctx, old.ID)
if err != nil {
return fmt.Errorf("inspect original %s: %w", old.ID, err)
}
if original.State == nil {
return fmt.Errorf("original container state unavailable")
}
replacement, inspectErr := cli.ContainerInspect(ctx, name)
if inspectErr != nil && !client.IsErrNotFound(inspectErr) {
return fmt.Errorf("inspect replacement by name: %w", inspectErr)
}
if inspectErr == nil && replacement.ID != old.ID {
if replacement.Config == nil || replacement.Config.Labels[containerUpgradeOwnerLabel] != old.ID {
return fmt.Errorf("container %s belongs to another operation; refusing to remove it", name)
}
if newID != "" && replacement.ID != newID {
return fmt.Errorf("replacement ID changed; refusing to remove it")
}
newID = replacement.ID
}
if newID != "" {
replacement, inspectErr = cli.ContainerInspect(ctx, newID)
if inspectErr != nil && !client.IsErrNotFound(inspectErr) {
return fmt.Errorf("inspect replacement %s: %w", newID, inspectErr)
}
if inspectErr == nil {
if replacement.ID == old.ID || replacement.Config == nil || replacement.Config.Labels[containerUpgradeOwnerLabel] != old.ID {
return fmt.Errorf("replacement identity could not be confirmed")
}
err = cli.ContainerRemove(ctx, newID, container.RemoveOptions{Force: true, RemoveVolumes: false})
logContainerSwitchStep(logger, "ContainerRollbackRemoveReplacement", name, err)
if err != nil && !client.IsErrNotFound(err) {
return fmt.Errorf("remove replacement: %w", err)
}
}
}
if strings.TrimPrefix(original.Name, "/") != name {
err = cli.ContainerRename(ctx, old.ID, name)
logContainerSwitchStep(logger, "ContainerRollbackRenameOld", name, err)
if err != nil {
return fmt.Errorf("restore original name: %w", err)
}
}
// Reload after mutations; connect only networks which are actually missing.
original, err = cli.ContainerInspect(ctx, old.ID)
if err != nil {
return err
}
primary, extras := buildContainerRecoverNetworkConfig(old.NetworkSettings, old.HostConfig)
wanted := make(map[string]*network.EndpointSettings)
if primary != nil {
for n, endpoint := range primary.EndpointsConfig {
if n != "bridge" {
wanted[n] = endpoint
}
}
}
for n, endpoint := range extras {
if n != "bridge" {
wanted[n] = endpoint
}
}
var reconnectErr error
for n, endpoint := range wanted {
if original.NetworkSettings != nil && original.NetworkSettings.Networks[n] != nil {
continue
}
err = cli.NetworkConnect(ctx, n, old.ID, endpoint)
logContainerSwitchStep(logger, "ContainerRollbackReconnectOld", n, err)
if err != nil {
reconnectErr = errors.Join(reconnectErr, fmt.Errorf("reconnect network %s: %w", n, err))
}
}
if reconnectErr != nil {
return reconnectErr
}
if old.State != nil && old.State.Running {
if original.State == nil {
return fmt.Errorf("original state unavailable after recovery")
}
if !original.State.Running {
err = restartOriginalContainer(ctx, cli, old.ID)
logContainerSwitchStep(logger, "ContainerRollbackRestartOld", name, err)
if err != nil {
return err
}
}
}
verified, err := cli.ContainerInspect(ctx, old.ID)
if err != nil {
return fmt.Errorf("verify recovery: %w", err)
}
if strings.TrimPrefix(verified.Name, "/") != name {
return fmt.Errorf("original name was not restored")
}
if old.State != nil && old.State.Running && (verified.State == nil || !verified.State.Running) {
return fmt.Errorf("original container is not running after recovery")
}
for n := range wanted {
if verified.NetworkSettings == nil || verified.NetworkSettings.Networks[n] == nil {
return fmt.Errorf("network %s was not restored", n)
}
}
return nil
}
func switchContainerWithRecovery(ctx context.Context, cli containerSwitchClient, name string,
oldContainer container.InspectResponse, createNew func() (container.CreateResponse, error),
tty bool, logger containerSwitchLogger, boundedRecovery bool,
) (cleanupErr error, err error) { ) (cleanupErr error, err error) {
if oldContainer.ID == "" { if oldContainer.ID == "" {
return nil, fmt.Errorf("original container ID is empty") return nil, fmt.Errorf("original container ID is empty")
@@ -281,9 +437,32 @@ func switchContainer(
return nil, fmt.Errorf("cannot safely replace container %s with auto-remove enabled", name) return nil, fmt.Errorf("cannot safely replace container %s with auto-remove enabled", name)
} }
recoverUpgrade := func(newID string, cause error) error {
recoveryCtx, cancel := context.WithTimeout(context.Background(), containerUpgradeRecoveryTimeout)
defer cancel()
if newID != "" {
logContainerStartupLogs(recoveryCtx, cli, newID, name, tty, logger)
}
recoveryErr := recoverContainerUpgrade(recoveryCtx, cli, name, oldContainer, newID, logger)
if ctx.Err() != nil {
recoveryErr = errors.Join(recoveryErr, fmt.Errorf("Docker request timed out or was canceled; daemon-side completion cannot be confirmed: %w", ctx.Err()))
}
if recoveryErr != nil {
message := fmt.Sprintf("Recovery unconfirmed for container %s (original ID %s, backup %s): %v", name, oldContainer.ID, containerSwitchBackupName(oldContainer.ID), recoveryErr)
if logger != nil {
logger.Log(message)
}
return errors.Join(cause, errors.New(message))
}
return cause
}
if wasRunning { if wasRunning {
if err := cli.ContainerStop(ctx, oldContainer.ID, container.StopOptions{}); err != nil { if err := cli.ContainerStop(ctx, oldContainer.ID, container.StopOptions{}); err != nil {
logContainerSwitchStep(logger, "ContainerStopOld", name, err) logContainerSwitchStep(logger, "ContainerStopOld", name, err)
if boundedRecovery {
return nil, recoverUpgrade("", fmt.Errorf("stop original container failed: %w", err))
}
current, inspectErr := cli.ContainerInspect(ctx, oldContainer.ID) current, inspectErr := cli.ContainerInspect(ctx, oldContainer.ID)
if inspectErr == nil && current.State != nil && !current.State.Running { if inspectErr == nil && current.State != nil && !current.State.Running {
restartErr := restartOriginalContainer(ctx, cli, oldContainer.ID) restartErr := restartOriginalContainer(ctx, cli, oldContainer.ID)
@@ -298,6 +477,9 @@ func switchContainer(
backupName := containerSwitchBackupName(oldContainer.ID) backupName := containerSwitchBackupName(oldContainer.ID)
if err := cli.ContainerRename(ctx, oldContainer.ID, backupName); err != nil { if err := cli.ContainerRename(ctx, oldContainer.ID, backupName); err != nil {
logContainerSwitchStep(logger, "ContainerRenameOld", name, err) logContainerSwitchStep(logger, "ContainerRenameOld", name, err)
if boundedRecovery {
return nil, recoverUpgrade("", fmt.Errorf("rename original container failed: %w", err))
}
if wasRunning { if wasRunning {
restartErr := restartOriginalContainer(ctx, cli, oldContainer.ID) restartErr := restartOriginalContainer(ctx, cli, oldContainer.ID)
logContainerSwitchStep(logger, "ContainerRollbackRestartOld", name, restartErr) logContainerSwitchStep(logger, "ContainerRollbackRestartOld", name, restartErr)
@@ -309,6 +491,9 @@ func switchContainer(
disconnectedNetworks, disconnectErr := disconnectOriginalContainerNetworks(ctx, cli, oldContainer) disconnectedNetworks, disconnectErr := disconnectOriginalContainerNetworks(ctx, cli, oldContainer)
logContainerSwitchStep(logger, "ContainerDisconnectOld", backupName, disconnectErr) logContainerSwitchStep(logger, "ContainerDisconnectOld", backupName, disconnectErr)
if disconnectErr != nil { if disconnectErr != nil {
if boundedRecovery {
return nil, recoverUpgrade("", disconnectErr)
}
rollbackErr := restoreOriginalContainer(ctx, cli, oldContainer.ID, name, wasRunning, "", disconnectedNetworks, logger) rollbackErr := restoreOriginalContainer(ctx, cli, oldContainer.ID, name, wasRunning, "", disconnectedNetworks, logger)
return nil, errors.Join(disconnectErr, rollbackErr) return nil, errors.Join(disconnectErr, rollbackErr)
} }
@@ -316,11 +501,17 @@ func switchContainer(
created, createErr := createNew() created, createErr := createNew()
logContainerSwitchStep(logger, "ContainerCreateReplacement", name, createErr) logContainerSwitchStep(logger, "ContainerCreateReplacement", name, createErr)
if createErr != nil { if createErr != nil {
if boundedRecovery {
return nil, recoverUpgrade(created.ID, createErr)
}
rollbackErr := restoreOriginalContainer(ctx, cli, oldContainer.ID, name, wasRunning, created.ID, disconnectedNetworks, logger) rollbackErr := restoreOriginalContainer(ctx, cli, oldContainer.ID, name, wasRunning, created.ID, disconnectedNetworks, logger)
return nil, errors.Join(createErr, rollbackErr) return nil, errors.Join(createErr, rollbackErr)
} }
if err := cli.ContainerStart(ctx, created.ID, container.StartOptions{}); err != nil { if err := cli.ContainerStart(ctx, created.ID, container.StartOptions{}); err != nil {
logContainerSwitchStep(logger, "ContainerStartReplacement", name, err) logContainerSwitchStep(logger, "ContainerStartReplacement", name, err)
if boundedRecovery {
return nil, recoverUpgrade(created.ID, fmt.Errorf("start new container failed: %w", err))
}
logContainerStartupLogs(ctx, cli, created.ID, name, tty, logger) logContainerStartupLogs(ctx, cli, created.ID, name, tty, logger)
rollbackErr := restoreOriginalContainer(ctx, cli, oldContainer.ID, name, wasRunning, created.ID, disconnectedNetworks, logger) rollbackErr := restoreOriginalContainer(ctx, cli, oldContainer.ID, name, wasRunning, created.ID, disconnectedNetworks, logger)
return nil, errors.Join(fmt.Errorf("start new container failed: %w", err), rollbackErr) return nil, errors.Join(fmt.Errorf("start new container failed: %w", err), rollbackErr)
@@ -329,6 +520,9 @@ func switchContainer(
if wasRunning { if wasRunning {
if err := waitContainerReady(ctx, cli, created.ID); err != nil { if err := waitContainerReady(ctx, cli, created.ID); err != nil {
logContainerSwitchStep(logger, "ContainerWaitReplacement", name, err) logContainerSwitchStep(logger, "ContainerWaitReplacement", name, err)
if boundedRecovery {
return nil, recoverUpgrade(created.ID, fmt.Errorf("new container readiness check failed: %w", err))
}
logContainerStartupLogs(ctx, cli, created.ID, name, tty, logger) logContainerStartupLogs(ctx, cli, created.ID, name, tty, logger)
rollbackErr := restoreOriginalContainer(ctx, cli, oldContainer.ID, name, wasRunning, created.ID, disconnectedNetworks, logger) rollbackErr := restoreOriginalContainer(ctx, cli, oldContainer.ID, name, wasRunning, created.ID, disconnectedNetworks, logger)
return nil, errors.Join(fmt.Errorf("new container readiness check failed: %w", err), rollbackErr) return nil, errors.Join(fmt.Errorf("new container readiness check failed: %w", err), rollbackErr)
@@ -336,6 +530,11 @@ func switchContainer(
logContainerSwitchStep(logger, "ContainerWaitReplacement", name, nil) logContainerSwitchStep(logger, "ContainerWaitReplacement", name, nil)
} }
if boundedRecovery {
cleanupCtx, cancel := context.WithTimeout(context.Background(), containerUpgradeRecoveryTimeout)
defer cancel()
ctx = cleanupCtx
}
cleanupErr = cli.ContainerRemove(ctx, oldContainer.ID, container.RemoveOptions{Force: true, RemoveVolumes: false}) cleanupErr = cli.ContainerRemove(ctx, oldContainer.ID, container.RemoveOptions{Force: true, RemoveVolumes: false})
logContainerSwitchStep(logger, "ContainerRemoveOld", backupName, cleanupErr) logContainerSwitchStep(logger, "ContainerRemoveOld", backupName, cleanupErr)
return cleanupErr, nil return cleanupErr, nil
+75 -9
View File
@@ -16,6 +16,7 @@ import (
"github.com/1Panel-dev/1Panel/agent/buserr" "github.com/1Panel-dev/1Panel/agent/buserr"
"github.com/1Panel-dev/1Panel/agent/constant" "github.com/1Panel-dev/1Panel/agent/constant"
"github.com/1Panel-dev/1Panel/agent/global" "github.com/1Panel-dev/1Panel/agent/global"
alertUtil "github.com/1Panel-dev/1Panel/agent/utils/alert"
"github.com/1Panel-dev/1Panel/agent/utils/docker" "github.com/1Panel-dev/1Panel/agent/utils/docker"
"github.com/jinzhu/copier" "github.com/jinzhu/copier"
"github.com/pkg/errors" "github.com/pkg/errors"
@@ -75,6 +76,7 @@ func (u *CronjobService) SearchWithPage(search dto.PageCronjob) (int64, interfac
EntryID: cronjob.ID, EntryID: cronjob.ID,
} }
alertInfo, _ := alertRepo.Get(alertRepo.WithByType(alertBase.AlertType), alertRepo.WithByProject(strconv.Itoa(int(alertBase.EntryID))), repo.WithByStatus(constant.AlertEnable)) alertInfo, _ := alertRepo.Get(alertRepo.WithByType(alertBase.AlertType), alertRepo.WithByProject(strconv.Itoa(int(alertBase.EntryID))), repo.WithByStatus(constant.AlertEnable))
item.AlertTriggerMode, _ = alertUtil.CronJobAlertTriggerMode(alertInfo.AdvancedParams)
if alertInfo.SendCount != 0 { if alertInfo.SendCount != 0 {
item.AlertCount = alertInfo.SendCount item.AlertCount = alertInfo.SendCount
} else { } else {
@@ -98,9 +100,11 @@ func (u *CronjobService) LoadInfo(req dto.OperateByID) (*dto.CronjobOperate, err
AlertType: cronjob.Type, AlertType: cronjob.Type,
EntryID: cronjob.ID, EntryID: cronjob.ID,
} }
alertInfo, _ := alertRepo.Get(alertRepo.WithByType(alertBase.AlertType), alertRepo.WithByProject(strconv.Itoa(int(alertBase.EntryID))), repo.WithByStatus(constant.AlertEnable)) alertInfo, _ := alertRepo.Get(alertRepo.WithByType(alertBase.AlertType), alertRepo.WithByProject(strconv.Itoa(int(alertBase.EntryID))))
item.AlertMethod = alertInfo.Method item.AlertMethod = alertInfo.Method
if alertInfo.SendCount != 0 { item.AlertTitle = alertInfo.Title
item.AlertTriggerMode, _ = alertUtil.CronJobAlertTriggerMode(alertInfo.AdvancedParams)
if alertInfo.Status == constant.AlertEnable {
item.AlertCount = alertInfo.SendCount item.AlertCount = alertInfo.SendCount
} else { } else {
item.AlertCount = 0 item.AlertCount = 0
@@ -195,11 +199,12 @@ func (u *CronjobService) Export(req dto.OperateByIDs) (string, error) {
} }
} }
item.SourceAccounts, item.DownloadAccount, _ = loadBackupNamesByID(cronjob.SourceAccountIDs, cronjob.DownloadAccountID) item.SourceAccounts, item.DownloadAccount, _ = loadBackupNamesByID(cronjob.SourceAccountIDs, cronjob.DownloadAccountID)
alertInfo, _ := alertRepo.Get(alertRepo.WithByType(cronjob.Type), alertRepo.WithByProject(strconv.Itoa(int(cronjob.ID))), repo.WithByStatus(constant.AlertEnable)) alertInfo, _ := alertRepo.Get(alertRepo.WithByType(cronjob.Type), alertRepo.WithByProject(strconv.Itoa(int(cronjob.ID))))
if alertInfo.SendCount != 0 {
item.AlertCount = alertInfo.SendCount
item.AlertTitle = alertInfo.Title item.AlertTitle = alertInfo.Title
item.AlertMethod = alertInfo.Method item.AlertMethod = alertInfo.Method
item.AlertTriggerMode, _ = alertUtil.CronJobAlertTriggerMode(alertInfo.AdvancedParams)
if alertInfo.Status == constant.AlertEnable {
item.AlertCount = alertInfo.SendCount
} else { } else {
item.AlertCount = 0 item.AlertCount = 0
} }
@@ -213,6 +218,17 @@ func (u *CronjobService) Export(req dto.OperateByIDs) (string, error) {
} }
func (u *CronjobService) Import(req []dto.CronjobTrans, operator string) error { func (u *CronjobService) Import(req []dto.CronjobTrans, operator string) error {
for _, item := range req {
advanced, err := cronJobAlertAdvancedParams(item.AlertTriggerMode)
if err != nil {
return err
}
if item.AlertCount != 0 {
if err := (AlertService{}).validateCronJobAlertChannels(item.Type, advanced, item.AlertMethod); err != nil {
return err
}
}
}
for _, item := range req { for _, item := range req {
cronjobItem, _ := cronjobRepo.Get(repo.WithByName(item.Name)) cronjobItem, _ := cronjobRepo.Get(repo.WithByName(item.Name))
if cronjobItem.ID != 0 { if cronjobItem.ID != 0 {
@@ -395,17 +411,27 @@ func (u *CronjobService) Import(req []dto.CronjobTrans, operator string) error {
} else { } else {
cronjob.Status = constant.StatusDisable cronjob.Status = constant.StatusDisable
} }
_ = cronjobRepo.Create(&cronjob) if err := cronjobRepo.Create(&cronjob); err != nil {
if item.AlertCount != 0 && item.AlertTitle != "" && item.AlertMethod != "" { return err
}
if item.AlertTitle != "" && item.AlertMethod != "" {
advanced, _ := cronJobAlertAdvancedParams(item.AlertTriggerMode)
status := constant.AlertEnable
if item.AlertCount == 0 {
status = constant.AlertDisable
}
createAlert := dto.AlertCreate{ createAlert := dto.AlertCreate{
Title: item.AlertTitle, Title: item.AlertTitle,
SendCount: item.AlertCount, SendCount: item.AlertCount,
Method: item.AlertMethod, Method: item.AlertMethod,
Type: cronjob.Type, Type: cronjob.Type,
Project: strconv.Itoa(int(cronjob.ID)), Project: strconv.Itoa(int(cronjob.ID)),
Status: constant.AlertEnable, Status: status,
AdvancedParams: advanced,
}
if err := NewIAlertService().CreateAlert(createAlert, operator); err != nil {
return err
} }
_ = NewIAlertService().CreateAlert(createAlert, operator)
} }
} }
return nil return nil
@@ -562,6 +588,15 @@ func (u *CronjobService) HandleOnce(id uint) error {
} }
func (u *CronjobService) Create(req dto.CronjobOperate, operator string) error { func (u *CronjobService) Create(req dto.CronjobOperate, operator string) error {
advanced, err := cronJobAlertAdvancedParams(req.AlertTriggerMode)
if err != nil {
return err
}
if req.AlertCount != 0 {
if err := (AlertService{}).validateCronJobAlertChannels(req.Type, advanced, req.AlertMethod); err != nil {
return err
}
}
cronjob, _ := cronjobRepo.Get(repo.WithByName(req.Name)) cronjob, _ := cronjobRepo.Get(repo.WithByName(req.Name))
if cronjob.ID != 0 { if cronjob.ID != 0 {
return buserr.New("ErrRecordExist") return buserr.New("ErrRecordExist")
@@ -609,6 +644,7 @@ func (u *CronjobService) Create(req dto.CronjobOperate, operator string) error {
Type: cronjob.Type, Type: cronjob.Type,
Project: strconv.Itoa(int(cronjob.ID)), Project: strconv.Itoa(int(cronjob.ID)),
Status: constant.AlertEnable, Status: constant.AlertEnable,
AdvancedParams: advanced,
} }
err := NewIAlertService().CreateAlert(createAlert, operator) err := NewIAlertService().CreateAlert(createAlert, operator)
if err != nil { if err != nil {
@@ -682,6 +718,10 @@ func (u *CronjobService) Delete(req dto.CronjobBatchDelete) error {
} }
func (u *CronjobService) Update(id uint, req dto.CronjobOperate, operator string) error { func (u *CronjobService) Update(id uint, req dto.CronjobOperate, operator string) error {
advanced, err := cronJobAlertAdvancedParams(req.AlertTriggerMode)
if err != nil {
return err
}
var cronjob model.Cronjob var cronjob model.Cronjob
if err := copier.Copy(&cronjob, &req); err != nil { if err := copier.Copy(&cronjob, &req); err != nil {
return buserr.WithDetail("ErrStructTransform", err.Error(), nil) return buserr.WithDetail("ErrStructTransform", err.Error(), nil)
@@ -697,6 +737,20 @@ func (u *CronjobService) Update(id uint, req dto.CronjobOperate, operator string
if err != nil { if err != nil {
return buserr.New("ErrRecordNotFound") return buserr.New("ErrRecordNotFound")
} }
if req.AlertCount != 0 {
previous, _ := alertRepo.Get(alertRepo.WithByType(cronModel.Type), alertRepo.WithByProject(strconv.Itoa(int(id))))
merged, err := prepareCronJobAlertParams(cronModel.Type, previous.AdvancedParams, advanced)
if err != nil {
return err
}
method := req.AlertMethod
if method == "" {
method = previous.Method
}
if err := (AlertService{}).validateCronJobAlertChannels(cronModel.Type, merged, method); err != nil {
return err
}
}
upMap := make(map[string]interface{}) upMap := make(map[string]interface{})
cronjob.EntryIDs = cronModel.EntryIDs cronjob.EntryIDs = cronModel.EntryIDs
cronjob.Type = cronModel.Type cronjob.Type = cronModel.Type
@@ -758,6 +812,7 @@ func (u *CronjobService) Update(id uint, req dto.CronjobOperate, operator string
Method: req.AlertMethod, Method: req.AlertMethod,
Type: cronjob.Type, Type: cronjob.Type,
Project: strconv.Itoa(int(cronModel.ID)), Project: strconv.Itoa(int(cronModel.ID)),
AdvancedParams: advanced,
} }
err = NewIAlertService().ExternalUpdateAlert(updateAlert, operator) err = NewIAlertService().ExternalUpdateAlert(updateAlert, operator)
if err != nil { if err != nil {
@@ -766,6 +821,17 @@ func (u *CronjobService) Update(id uint, req dto.CronjobOperate, operator string
return nil return nil
} }
func cronJobAlertAdvancedParams(mode string) (string, error) {
if mode == "" {
return "", nil
}
data, err := json.Marshal(map[string]string{"alertTriggerMode": mode})
if err != nil {
return "", err
}
return alertUtil.MergeCronJobAlertParams("", string(data))
}
func (u *CronjobService) UpdateStatus(id uint, status string) error { func (u *CronjobService) UpdateStatus(id uint, status string) error {
cronjob, _ := cronjobRepo.Get(repo.WithByID(id)) cronjob, _ := cronjobRepo.Get(repo.WithByID(id))
if cronjob.ID == 0 { if cronjob.ID == 0 {
+1
View File
@@ -412,6 +412,7 @@ func addSkipTask(source string, taskItem *task.Task) {
taskItem.Log(i18n.GetMsgByKey("NoSuchResource")) taskItem.Log(i18n.GetMsgByKey("NoSuchResource"))
return nil return nil
}, nil) }, nil)
taskItem.SubTasks[len(taskItem.SubTasks)-1].StepAlias = cronJobSkippedStep
} }
func loadDbsForJob(cronjob model.Cronjob) []DatabaseHelper { func loadDbsForJob(cronjob model.Cronjob) []DatabaseHelper {
+35 -6
View File
@@ -4,6 +4,7 @@ import (
"bufio" "bufio"
"context" "context"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
@@ -23,6 +24,7 @@ import (
"github.com/1Panel-dev/1Panel/agent/constant" "github.com/1Panel-dev/1Panel/agent/constant"
"github.com/1Panel-dev/1Panel/agent/global" "github.com/1Panel-dev/1Panel/agent/global"
"github.com/1Panel-dev/1Panel/agent/i18n" "github.com/1Panel-dev/1Panel/agent/i18n"
alertUtil "github.com/1Panel-dev/1Panel/agent/utils/alert"
"github.com/1Panel-dev/1Panel/agent/utils/cmd" "github.com/1Panel-dev/1Panel/agent/utils/cmd"
"github.com/1Panel-dev/1Panel/agent/utils/files" "github.com/1Panel-dev/1Panel/agent/utils/files"
"github.com/1Panel-dev/1Panel/agent/utils/ntp" "github.com/1Panel-dev/1Panel/agent/utils/ntp"
@@ -56,10 +58,11 @@ func (u *CronjobService) HandleJob(cronjob *model.Cronjob) {
_ = taskRepo.Save(context.Background(), taskItem.Task) _ = taskRepo.Save(context.Background(), taskItem.Task)
} }
cronjobRepo.EndRecords(record, constant.StatusFailed, err.Error(), record.Records) cronjobRepo.EndRecords(record, constant.StatusFailed, err.Error(), record.Records)
handleCronJobAlert(cronjob) handleCronJobAlert(cronjob, cronJobAlertResult(taskItem, err))
return return
} }
cronjobRepo.EndRecords(record, constant.StatusSuccess, "", record.Records) cronjobRepo.EndRecords(record, constant.StatusSuccess, "", record.Records)
handleCronJobAlert(cronjob, cronJobAlertResult(taskItem, nil))
}() }()
return return
} }
@@ -70,19 +73,20 @@ func (u *CronjobService) HandleJob(cronjob *model.Cronjob) {
record.TaskID = "" record.TaskID = ""
} }
cronjobRepo.EndRecords(record, constant.StatusFailed, err.Error(), record.Records) cronjobRepo.EndRecords(record, constant.StatusFailed, err.Error(), record.Records)
handleCronJobAlert(cronjob) handleCronJobAlert(cronjob, cronJobAlertResult(taskItem, err))
return return
} }
go func() { go func() {
if err := taskItem.Execute(); err != nil { if err := taskItem.Execute(); err != nil {
taskItem, _ := taskRepo.GetFirst(taskRepo.WithByID(record.TaskID)) storedTask, _ := taskRepo.GetFirst(taskRepo.WithByID(record.TaskID))
if len(taskItem.ID) == 0 { if len(storedTask.ID) == 0 {
record.TaskID = "" record.TaskID = ""
} }
cronjobRepo.EndRecords(record, constant.StatusFailed, err.Error(), record.Records) cronjobRepo.EndRecords(record, constant.StatusFailed, err.Error(), record.Records)
handleCronJobAlert(cronjob) handleCronJobAlert(cronjob, cronJobAlertResult(taskItem, err))
} else { } else {
cronjobRepo.EndRecords(record, constant.StatusSuccess, "", record.Records) cronjobRepo.EndRecords(record, constant.StatusSuccess, "", record.Records)
handleCronJobAlert(cronjob, cronJobAlertResult(taskItem, nil))
} }
}() }()
} }
@@ -482,8 +486,33 @@ func hasBackup(cronjobType string) bool {
return cronjobType == "app" || cronjobType == "database" || cronjobType == "website" || cronjobType == "directory" || cronjobType == "snapshot" || cronjobType == "log" || cronjobType == "cutWebsiteLog" return cronjobType == "app" || cronjobType == "database" || cronjobType == "website" || cronjobType == "directory" || cronjobType == "snapshot" || cronjobType == "log" || cronjobType == "cutWebsiteLog"
} }
func handleCronJobAlert(cronjob *model.Cronjob) { const cronJobSkippedStep = "cronjob-skipped"
func cronJobAlertResult(taskItem *task.Task, err error) string {
if errors.Is(err, context.Canceled) || taskItem.Task.Status == constant.StatusCanceled ||
(taskItem.TaskCtx != nil && taskItem.TaskCtx.Err() != nil) {
return ""
}
if err != nil {
return alertUtil.CronJobAlertFailed
}
if taskItem.Task.Status != constant.StatusSuccess {
return ""
}
for _, subTask := range taskItem.SubTasks {
if subTask.StepAlias != cronJobSkippedStep {
return alertUtil.CronJobAlertSuccess
}
}
return ""
}
func handleCronJobAlert(cronjob *model.Cronjob, result string) {
if result == "" {
return
}
pushAlert := dto.PushAlert{ pushAlert := dto.PushAlert{
Result: result,
TaskName: cronjob.Name, TaskName: cronjob.Name,
AlertType: cronjob.Type, AlertType: cronjob.Type,
EntryID: cronjob.ID, EntryID: cronjob.ID,
+102 -58
View File
@@ -43,6 +43,10 @@ type IDashboardService interface {
LoadQuickOptions() []dto.QuickJump LoadQuickOptions() []dto.QuickJump
ChangeQuick(req dto.ChangeQuicks) error ChangeQuick(req dto.ChangeQuicks) error
ListQuickDirectories() ([]model.QuickDirectory, error)
CreateQuickDirectory(req dto.QuickDirectoryCreate) (*model.QuickDirectory, error)
UpdateQuickDirectory(req dto.QuickDirectoryUpdate) (*model.QuickDirectory, error)
DeleteQuickDirectory(req dto.QuickDirectoryDelete) error
LoadAppLauncher(ctx *gin.Context) ([]dto.AppLauncher, error) LoadAppLauncher(ctx *gin.Context) ([]dto.AppLauncher, error)
ChangeShow(req dto.SettingUpdate) error ChangeShow(req dto.SettingUpdate) error
@@ -242,7 +246,7 @@ func (u *DashboardService) LoadCurrentInfo(ioOption string, netOption string) *d
currentInfo.SwapMemoryUsed = swapInfo.Used currentInfo.SwapMemoryUsed = swapInfo.Used
currentInfo.SwapMemoryUsedPercent = swapInfo.UsedPercent currentInfo.SwapMemoryUsedPercent = swapInfo.UsedPercent
currentInfo.DiskData = loadDiskInfo() currentInfo.DiskData = loadDiskInfo(false)
currentInfo.GPUData, currentInfo.NPUData, currentInfo.XPUData = loadAcceleratorInfo() currentInfo.GPUData, currentInfo.NPUData, currentInfo.XPUData = loadAcceleratorInfo()
if ioOption == "all" { if ioOption == "all" {
@@ -397,16 +401,15 @@ func (u *DashboardService) LoadQuickOptions() []dto.QuickJump {
} }
func (u *DashboardService) ChangeQuick(req dto.ChangeQuicks) error { func (u *DashboardService) ChangeQuick(req dto.ChangeQuicks) error {
showCount := 0 showCount := 0
var quicks []model.QuickJump quicks := make([]model.QuickJump, 0, len(req.Quicks))
for _, item := range req.Quicks { for _, item := range req.Quicks {
var quick model.QuickJump
if item.IsShow { if item.IsShow {
showCount++ showCount++
} }
if err := copier.Copy(&quick, &item); err != nil { quicks = append(quicks, model.QuickJump{
return err BaseModel: model.BaseModel{ID: item.ID},
} IsShow: item.IsShow,
quicks = append(quicks, quick) })
} }
if showCount == 0 { if showCount == 0 {
return buserr.New("ErrMinQuickJump") return buserr.New("ErrMinQuickJump")
@@ -455,24 +458,9 @@ type diskInfo struct {
Device string Device string
} }
func loadDiskInfo() []dto.DiskInfo { func loadDiskInfo(forceRefresh bool) []dto.DiskInfo {
var datas []dto.DiskInfo var datas []dto.DiskInfo
cmdMgr := cmd.NewCommandMgr(cmd.WithTimeout(2 * time.Second)) stdout := loadDiskMounts()
format := `NR>1 && !/tmpfs|snap\/core|udev/ {printf "%s\t%s\t%s\t%s\t%s\t%s\t%s\n", $1, $2, $3, $4, $5, $6, $7}`
stdout, err := cmdMgr.RunPipe(
cmd.PipeCommand{Name: "df", Args: []string{"-hT", "-P"}},
cmd.PipeCommand{Name: "awk", Args: []string{format}},
)
if err != nil {
cmdMgr2 := cmd.NewCommandMgr(cmd.WithTimeout(1 * time.Second))
stdout, err = cmdMgr2.RunPipe(
cmd.PipeCommand{Name: "df", Args: []string{"-lhT", "-P"}},
cmd.PipeCommand{Name: "awk", Args: []string{format}},
)
if err != nil {
return datas
}
}
lines := strings.Split(stdout, "\n") lines := strings.Split(stdout, "\n")
var mounts []diskInfo var mounts []diskInfo
@@ -520,43 +508,22 @@ func loadDiskInfo() []dto.DiskInfo {
itemData.Type = mount.Type itemData.Type = mount.Type
itemData.Device = mount.Device itemData.Device = mount.Device
type diskResult struct { state, err := loadDiskUsageWithTimeout(mount.Mount, forceRefresh)
state *disk.UsageStat if err != nil {
err error global.LOG.Errorf("load disk info from %s failed, err: %v", mount.Mount, err)
} else {
itemData.Total = state.Total
itemData.Free = state.Free
itemData.Used = state.Used
itemData.UsedPercent = state.UsedPercent
itemData.InodesTotal = state.InodesTotal
itemData.InodesUsed = state.InodesUsed
itemData.InodesFree = state.InodesFree
itemData.InodesUsedPercent = state.InodesUsedPercent
} }
resultCh := make(chan diskResult, 1)
go func() {
state, err := psutil.DISK.GetUsage(mount.Mount, false)
resultCh <- diskResult{state: state, err: err}
}()
select {
case <-time.After(5 * time.Second):
mu.Lock() mu.Lock()
datas = append(datas, itemData) datas = append(datas, itemData)
mu.Unlock() mu.Unlock()
global.LOG.Errorf("load disk info from %s failed, err: timeout", mount.Mount)
case result := <-resultCh:
if result.err != nil {
mu.Lock()
datas = append(datas, itemData)
mu.Unlock()
global.LOG.Errorf("load disk info from %s failed, err: %v", mount.Mount, result.err)
return
}
itemData.Total = result.state.Total
itemData.Free = result.state.Free
itemData.Used = result.state.Used
itemData.UsedPercent = result.state.UsedPercent
itemData.InodesTotal = result.state.InodesTotal
itemData.InodesUsed = result.state.InodesUsed
itemData.InodesFree = result.state.InodesFree
itemData.InodesUsedPercent = result.state.InodesUsedPercent
mu.Lock()
datas = append(datas, itemData)
mu.Unlock()
}
}(mounts[i]) }(mounts[i])
} }
wg.Wait() wg.Wait()
@@ -567,6 +534,69 @@ func loadDiskInfo() []dto.DiskInfo {
return datas return datas
} }
var diskMountsMu sync.Mutex
func loadDiskMounts() string {
if !diskMountsMu.TryLock() {
return ""
}
resultCh := make(chan string, 1)
go func() {
var stdout string
defer func() {
diskMountsMu.Unlock()
resultCh <- stdout
}()
cmdMgr := cmd.NewCommandMgr(cmd.WithTimeout(2 * time.Second))
format := `NR>1 && !/tmpfs|snap\/core|udev/ {printf "%s\t%s\t%s\t%s\t%s\t%s\t%s\n", $1, $2, $3, $4, $5, $6, $7}`
output, err := cmdMgr.RunPipe(
cmd.PipeCommand{Name: "df", Args: []string{"-hT", "-P"}},
cmd.PipeCommand{Name: "awk", Args: []string{format}},
)
if err != nil {
global.LOG.Errorf("load disk info with df -hT -P failed, err: %v", err)
cmdMgr2 := cmd.NewCommandMgr(cmd.WithTimeout(1 * time.Second))
output, err = cmdMgr2.RunPipe(
cmd.PipeCommand{Name: "df", Args: []string{"-lhT", "-P"}},
cmd.PipeCommand{Name: "awk", Args: []string{format}},
)
if err != nil {
global.LOG.Errorf("load disk info with df -lhT -P failed, err: %v", err)
return
}
}
stdout = output
}()
timer := time.NewTimer(3 * time.Second)
defer timer.Stop()
select {
case stdout := <-resultCh:
return stdout
case <-timer.C:
global.LOG.Error("load disk mounts timed out; df collection is still running")
return ""
}
}
func loadDiskUsageWithTimeout(path string, forceRefresh bool) (*disk.UsageStat, error) {
type diskResult struct {
state *disk.UsageStat
err error
}
resultCh := make(chan diskResult, 1)
go func() {
state, err := psutil.DISK.GetUsage(path, forceRefresh)
resultCh <- diskResult{state: state, err: err}
}()
select {
case <-time.After(5 * time.Second):
return nil, fmt.Errorf("load disk usage from %s: timeout", path)
case result := <-resultCh:
return result.state, result.err
}
}
func loadAcceleratorInfo() ([]dto.GPUInfo, []dto.NPUInfo, []dto.XPUInfo) { func loadAcceleratorInfo() ([]dto.GPUInfo, []dto.NPUInfo, []dto.XPUInfo) {
ok, client := accelerator.New() ok, client := accelerator.New()
if !ok { if !ok {
@@ -586,6 +616,9 @@ func loadAcceleratorInfo() ([]dto.GPUInfo, []dto.NPUInfo, []dto.XPUInfo) {
xpuData []dto.XPUInfo xpuData []dto.XPUInfo
) )
for _, device := range snapshot.Devices { for _, device := range snapshot.Devices {
if device.ParentID != "" {
continue
}
switch device.Kind { switch device.Kind {
case accelerator.KindGPU: case accelerator.KindGPU:
if device.GPU == nil { if device.GPU == nil {
@@ -595,7 +628,11 @@ func loadAcceleratorInfo() ([]dto.GPUInfo, []dto.NPUInfo, []dto.XPUInfo) {
if err := copier.Copy(&dataItem, device.GPU); err != nil { if err := copier.Copy(&dataItem, device.GPU); err != nil {
continue continue
} }
dataItem.PowerUsage = dataItem.PowerDraw + " / " + dataItem.MaxPowerLimit dataItem.MaxPowerLimit = device.GPU.PowerLimit
dataItem.PowerUsage = dataItem.PowerDraw
if dataItem.MaxPowerLimit != "" {
dataItem.PowerUsage += " / " + dataItem.MaxPowerLimit
}
dataItem.MemoryUsage = dataItem.MemUsed + " / " + dataItem.MemTotal dataItem.MemoryUsage = dataItem.MemUsed + " / " + dataItem.MemTotal
gpuData = append(gpuData, dataItem) gpuData = append(gpuData, dataItem)
case accelerator.KindNPU: case accelerator.KindNPU:
@@ -672,6 +709,13 @@ func loadQuickJump(base *dto.DashboardBase) {
quicks := launcherRepo.ListQuickJump(false) quicks := launcherRepo.ListQuickJump(false)
for i := 0; i < len(quicks); i++ { for i := 0; i < len(quicks); i++ {
switch quicks[i].Name { switch quicks[i].Name {
case "File":
count, err := launcherRepo.CountQuickDirectories()
if err != nil {
global.LOG.Errorf("load quick directory count: %v", err)
}
quicks[i].Detail = fmt.Sprintf("%d", count)
quicks[i].Alias = ""
case "Agent": case "Agent":
quicks[i].Detail = fmt.Sprintf("%d", base.AgentNumber) quicks[i].Detail = fmt.Sprintf("%d", base.AgentNumber)
case "Website": case "Website":
+11
View File
@@ -5,12 +5,14 @@ import (
"fmt" "fmt"
"os" "os"
"path" "path"
"strings"
"github.com/1Panel-dev/1Panel/agent/app/dto" "github.com/1Panel-dev/1Panel/agent/app/dto"
"github.com/1Panel-dev/1Panel/agent/app/repo" "github.com/1Panel-dev/1Panel/agent/app/repo"
"github.com/1Panel-dev/1Panel/agent/buserr" "github.com/1Panel-dev/1Panel/agent/buserr"
"github.com/1Panel-dev/1Panel/agent/constant" "github.com/1Panel-dev/1Panel/agent/constant"
"github.com/1Panel-dev/1Panel/agent/global" "github.com/1Panel-dev/1Panel/agent/global"
"github.com/1Panel-dev/1Panel/agent/utils/cmd"
"github.com/1Panel-dev/1Panel/agent/utils/encrypt" "github.com/1Panel-dev/1Panel/agent/utils/encrypt"
"github.com/1Panel-dev/1Panel/agent/utils/mysql" "github.com/1Panel-dev/1Panel/agent/utils/mysql"
"github.com/1Panel-dev/1Panel/agent/utils/mysql/client" "github.com/1Panel-dev/1Panel/agent/utils/mysql/client"
@@ -126,6 +128,9 @@ func (u *DatabaseService) LoadItems(dbType string) ([]dto.DatabaseItem, error) {
} }
func (u *DatabaseService) CheckDatabase(req dto.DatabaseCreate) bool { func (u *DatabaseService) CheckDatabase(req dto.DatabaseCreate) bool {
if (req.Type == constant.AppMysql || req.Type == constant.AppMariaDB || req.Type == constant.AppPostgresql || req.Type == constant.AppMongodb) && (cmd.CheckIllegal(req.Username, req.Password) || strings.Contains(req.Password, " ")) {
return false
}
if req.Timeout == 0 { if req.Timeout == 0 {
req.Timeout = 30 req.Timeout = 30
} }
@@ -180,6 +185,9 @@ func (u *DatabaseService) CheckDatabase(req dto.DatabaseCreate) bool {
} }
func (u *DatabaseService) Create(req dto.DatabaseCreate) error { func (u *DatabaseService) Create(req dto.DatabaseCreate) error {
if (req.Type == constant.AppMysql || req.Type == constant.AppMariaDB || req.Type == constant.AppPostgresql || req.Type == constant.AppMongodb) && (cmd.CheckIllegal(req.Username, req.Password) || strings.Contains(req.Password, " ")) {
return buserr.New("ErrCmdIllegal")
}
if req.Timeout == 0 { if req.Timeout == 0 {
req.Timeout = 30 req.Timeout = 30
} }
@@ -303,6 +311,9 @@ func (u *DatabaseService) Delete(req dto.DatabaseDelete) error {
} }
func (u *DatabaseService) Update(req dto.DatabaseUpdate) error { func (u *DatabaseService) Update(req dto.DatabaseUpdate) error {
if (req.Type == constant.AppMysql || req.Type == constant.AppMariaDB || req.Type == constant.AppPostgresql || req.Type == constant.AppMongodb) && (cmd.CheckIllegal(req.Username, req.Password) || strings.Contains(req.Password, " ")) {
return buserr.New("ErrCmdIllegal")
}
switch req.Type { switch req.Type {
case constant.AppPostgresql: case constant.AppPostgresql:
if _, err := postgresql.NewPostgresqlClient(pgclient.DBInfo{ if _, err := postgresql.NewPostgresqlClient(pgclient.DBInfo{

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