Compare commits

..

8 Commits

Author SHA1 Message Date
filtered
628b44051b nit - fix TS type 2025-01-22 08:26:22 +11:00
filtered
a7a5e3cf67 [Refactor] Task state updates to TaskRunner 2025-01-22 08:18:42 +11:00
filtered
64e218a9f3 [Refactor] Task execution into task runner class 2025-01-22 08:18:42 +11:00
filtered
0b69d3cbfe [Desktop] Startup maintenance screen (#2253)
Co-authored-by: github-actions <github-actions@github.com>
2025-01-21 16:10:15 -05:00
bymyself
8257e848c6 Update SearchBox vue features (#2310) 2025-01-21 10:32:46 -05:00
Chenlei Hu
a07b7693b6 [chore] Update vue to 3.5 (#2308)
Co-authored-by: github-actions <github-actions@github.com>
2025-01-20 21:21:13 -05:00
Chenlei Hu
26ddf69451 Fix validation message locale (#2309) 2025-01-20 21:11:23 -05:00
bymyself
ed6ece2099 Add forms plugin to issue report component (#2302) 2025-01-20 20:20:59 -05:00
31 changed files with 1810 additions and 449 deletions

253
package-lock.json generated
View File

@@ -12,6 +12,7 @@
"@atlaskit/pragmatic-drag-and-drop": "^1.3.1",
"@comfyorg/comfyui-electron-types": "^0.4.11",
"@comfyorg/litegraph": "^0.8.61",
"@primevue/forms": "^4.2.5",
"@primevue/themes": "^4.2.5",
"@sentry/vue": "^8.48.0",
"@tiptap/core": "^2.10.4",
@@ -35,7 +36,7 @@
"primevue": "^4.2.5",
"three": "^0.170.0",
"tiptap-markdown": "^0.8.10",
"vue": "^3.4.31",
"vue": "^3.5.13",
"vue-i18n": "^9.13.1",
"vue-router": "^4.4.3",
"zod": "^3.23.8",
@@ -3961,6 +3962,25 @@
"node": ">=12"
}
},
"node_modules/@primeuix/forms": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/@primeuix/forms/-/forms-0.0.2.tgz",
"integrity": "sha512-DpecPQd/Qf/kav4LKCaIeGuT3AkwhJzuHCkLANTVlN/zBvo8KIj3OZHsCkm0zlIMVVnaJdtx1ULNlRQdudef+A==",
"dependencies": {
"@primeuix/utils": "^0.3.0"
},
"engines": {
"node": ">=12.11.0"
}
},
"node_modules/@primeuix/forms/node_modules/@primeuix/utils": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/@primeuix/utils/-/utils-0.3.2.tgz",
"integrity": "sha512-B+nphqTQeq+i6JuICLdVWnDMjONome2sNz0xI65qIOyeB4EF12CoKRiCsxuZ5uKAkHi/0d1LqlQ9mIWRSdkavw==",
"engines": {
"node": ">=12.11.0"
}
},
"node_modules/@primeuix/styled": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/@primeuix/styled/-/styled-0.3.2.tgz",
@@ -3998,6 +4018,53 @@
"vue": "^3.3.0"
}
},
"node_modules/@primevue/forms": {
"version": "4.2.5",
"resolved": "https://registry.npmjs.org/@primevue/forms/-/forms-4.2.5.tgz",
"integrity": "sha512-5jarJQ9Qv32bOo/0tY5bqR3JZI6+YmmoUQ2mjhVSbVElQsE4FNfhT7a7JwF+xgBPMPc8KWGNA1QB248HhPNVAg==",
"dependencies": {
"@primeuix/forms": "^0.0.2",
"@primeuix/utils": "^0.3.2",
"@primevue/core": "4.2.5"
},
"engines": {
"node": ">=12.11.0"
}
},
"node_modules/@primevue/forms/node_modules/@primeuix/styled": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/@primeuix/styled/-/styled-0.3.2.tgz",
"integrity": "sha512-ColZes0+/WKqH4ob2x8DyNYf1NENpe5ZguOvx5yCLxaP8EIMVhLjWLO/3umJiDnQU4XXMLkn2mMHHw+fhTX/mw==",
"dependencies": {
"@primeuix/utils": "^0.3.2"
},
"engines": {
"node": ">=12.11.0"
}
},
"node_modules/@primevue/forms/node_modules/@primeuix/utils": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/@primeuix/utils/-/utils-0.3.2.tgz",
"integrity": "sha512-B+nphqTQeq+i6JuICLdVWnDMjONome2sNz0xI65qIOyeB4EF12CoKRiCsxuZ5uKAkHi/0d1LqlQ9mIWRSdkavw==",
"engines": {
"node": ">=12.11.0"
}
},
"node_modules/@primevue/forms/node_modules/@primevue/core": {
"version": "4.2.5",
"resolved": "https://registry.npmjs.org/@primevue/core/-/core-4.2.5.tgz",
"integrity": "sha512-+oWBIQs5dLd2Ini4KEVOlvPILk989EHAskiFS3R/dz3jeOllJDMZFcSp8V9ddV0R3yDaPdLVkfHm2Q5t42kU2Q==",
"dependencies": {
"@primeuix/styled": "^0.3.2",
"@primeuix/utils": "^0.3.2"
},
"engines": {
"node": ">=12.11.0"
},
"peerDependencies": {
"vue": "^3.3.0"
}
},
"node_modules/@primevue/icons": {
"version": "4.2.5",
"resolved": "https://registry.npmjs.org/@primevue/icons/-/icons-4.2.5.tgz",
@@ -5726,49 +5793,53 @@
}
},
"node_modules/@vue/compiler-core": {
"version": "3.4.31",
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.4.31.tgz",
"integrity": "sha512-skOiodXWTV3DxfDhB4rOf3OGalpITLlgCeOwb+Y9GJpfQ8ErigdBUHomBzvG78JoVE8MJoQsb+qhZiHfKeNeEg==",
"version": "3.5.13",
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.13.tgz",
"integrity": "sha512-oOdAkwqUfW1WqpwSYJce06wvt6HljgY3fGeM9NcVA1HaYOij3mZG9Rkysn0OHuyUAGMbEbARIpsG+LPVlBJ5/Q==",
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.24.7",
"@vue/shared": "3.4.31",
"@babel/parser": "^7.25.3",
"@vue/shared": "3.5.13",
"entities": "^4.5.0",
"estree-walker": "^2.0.2",
"source-map-js": "^1.2.0"
}
},
"node_modules/@vue/compiler-dom": {
"version": "3.4.31",
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.4.31.tgz",
"integrity": "sha512-wK424WMXsG1IGMyDGyLqB+TbmEBFM78hIsOJ9QwUVLGrcSk0ak6zYty7Pj8ftm7nEtdU/DGQxAXp0/lM/2cEpQ==",
"version": "3.5.13",
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.13.tgz",
"integrity": "sha512-ZOJ46sMOKUjO3e94wPdCzQ6P1Lx/vhp2RSvfaab88Ajexs0AHeV0uasYhi99WPaogmBlRHNRuly8xV75cNTMDA==",
"license": "MIT",
"dependencies": {
"@vue/compiler-core": "3.4.31",
"@vue/shared": "3.4.31"
"@vue/compiler-core": "3.5.13",
"@vue/shared": "3.5.13"
}
},
"node_modules/@vue/compiler-sfc": {
"version": "3.4.31",
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.4.31.tgz",
"integrity": "sha512-einJxqEw8IIJxzmnxmJBuK2usI+lJonl53foq+9etB2HAzlPjAS/wa7r0uUpXw5ByX3/0uswVSrjNb17vJm1kQ==",
"version": "3.5.13",
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.13.tgz",
"integrity": "sha512-6VdaljMpD82w6c2749Zhf5T9u5uLBWKnVue6XWxprDobftnletJ8+oel7sexFfM3qIxNmVE7LSFGTpv6obNyaQ==",
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.24.7",
"@vue/compiler-core": "3.4.31",
"@vue/compiler-dom": "3.4.31",
"@vue/compiler-ssr": "3.4.31",
"@vue/shared": "3.4.31",
"@babel/parser": "^7.25.3",
"@vue/compiler-core": "3.5.13",
"@vue/compiler-dom": "3.5.13",
"@vue/compiler-ssr": "3.5.13",
"@vue/shared": "3.5.13",
"estree-walker": "^2.0.2",
"magic-string": "^0.30.10",
"postcss": "^8.4.38",
"magic-string": "^0.30.11",
"postcss": "^8.4.48",
"source-map-js": "^1.2.0"
}
},
"node_modules/@vue/compiler-ssr": {
"version": "3.4.31",
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.4.31.tgz",
"integrity": "sha512-RtefmITAje3fJ8FSg1gwgDhdKhZVntIVbwupdyZDSifZTRMiWxWehAOTCc8/KZDnBOcYQ4/9VWxsTbd3wT0hAA==",
"version": "3.5.13",
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.13.tgz",
"integrity": "sha512-wMH6vrYHxQl/IybKJagqbquvxpWCuVYpoUJfCqFZwa/JY1GdATAQ+TgVtgrwwMZ0D07QhA99rs/EAAWfvG6KpA==",
"license": "MIT",
"dependencies": {
"@vue/compiler-dom": "3.4.31",
"@vue/shared": "3.4.31"
"@vue/compiler-dom": "3.5.13",
"@vue/shared": "3.5.13"
}
},
"node_modules/@vue/compiler-vue2": {
@@ -5812,38 +5883,6 @@
}
}
},
"node_modules/@vue/language-core/node_modules/@vue/compiler-core": {
"version": "3.5.13",
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.13.tgz",
"integrity": "sha512-oOdAkwqUfW1WqpwSYJce06wvt6HljgY3fGeM9NcVA1HaYOij3mZG9Rkysn0OHuyUAGMbEbARIpsG+LPVlBJ5/Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.25.3",
"@vue/shared": "3.5.13",
"entities": "^4.5.0",
"estree-walker": "^2.0.2",
"source-map-js": "^1.2.0"
}
},
"node_modules/@vue/language-core/node_modules/@vue/compiler-dom": {
"version": "3.5.13",
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.13.tgz",
"integrity": "sha512-ZOJ46sMOKUjO3e94wPdCzQ6P1Lx/vhp2RSvfaab88Ajexs0AHeV0uasYhi99WPaogmBlRHNRuly8xV75cNTMDA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vue/compiler-core": "3.5.13",
"@vue/shared": "3.5.13"
}
},
"node_modules/@vue/language-core/node_modules/@vue/shared": {
"version": "3.5.13",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.13.tgz",
"integrity": "sha512-/hnE/qP5ZoGpol0a5mDi45bOd7t3tjYJBjsgCsivow7D48cJeV5l05RD82lPqi7gRiphZM37rnhW1l6ZoCNNnQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@vue/language-core/node_modules/brace-expansion": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
@@ -5871,49 +5910,54 @@
}
},
"node_modules/@vue/reactivity": {
"version": "3.4.31",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.4.31.tgz",
"integrity": "sha512-VGkTani8SOoVkZNds1PfJ/T1SlAIOf8E58PGAhIOUDYPC4GAmFA2u/E14TDAFcf3vVDKunc4QqCe/SHr8xC65Q==",
"version": "3.5.13",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.13.tgz",
"integrity": "sha512-NaCwtw8o48B9I6L1zl2p41OHo/2Z4wqYGGIK1Khu5T7yxrn+ATOixn/Udn2m+6kZKB/J7cuT9DbWWhRxqixACg==",
"license": "MIT",
"dependencies": {
"@vue/shared": "3.4.31"
"@vue/shared": "3.5.13"
}
},
"node_modules/@vue/runtime-core": {
"version": "3.4.31",
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.4.31.tgz",
"integrity": "sha512-LDkztxeUPazxG/p8c5JDDKPfkCDBkkiNLVNf7XZIUnJ+66GVGkP+TIh34+8LtPisZ+HMWl2zqhIw0xN5MwU1cw==",
"version": "3.5.13",
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.13.tgz",
"integrity": "sha512-Fj4YRQ3Az0WTZw1sFe+QDb0aXCerigEpw418pw1HBUKFtnQHWzwojaukAs2X/c9DQz4MQ4bsXTGlcpGxU/RCIw==",
"license": "MIT",
"dependencies": {
"@vue/reactivity": "3.4.31",
"@vue/shared": "3.4.31"
"@vue/reactivity": "3.5.13",
"@vue/shared": "3.5.13"
}
},
"node_modules/@vue/runtime-dom": {
"version": "3.4.31",
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.4.31.tgz",
"integrity": "sha512-2Auws3mB7+lHhTFCg8E9ZWopA6Q6L455EcU7bzcQ4x6Dn4cCPuqj6S2oBZgN2a8vJRS/LSYYxwFFq2Hlx3Fsaw==",
"version": "3.5.13",
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.13.tgz",
"integrity": "sha512-dLaj94s93NYLqjLiyFzVs9X6dWhTdAlEAciC3Moq7gzAc13VJUdCnjjRurNM6uTLFATRHexHCTu/Xp3eW6yoog==",
"license": "MIT",
"dependencies": {
"@vue/reactivity": "3.4.31",
"@vue/runtime-core": "3.4.31",
"@vue/shared": "3.4.31",
"@vue/reactivity": "3.5.13",
"@vue/runtime-core": "3.5.13",
"@vue/shared": "3.5.13",
"csstype": "^3.1.3"
}
},
"node_modules/@vue/server-renderer": {
"version": "3.4.31",
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.4.31.tgz",
"integrity": "sha512-D5BLbdvrlR9PE3by9GaUp1gQXlCNadIZytMIb8H2h3FMWJd4oUfkUTEH2wAr3qxoRz25uxbTcbqd3WKlm9EHQA==",
"version": "3.5.13",
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.13.tgz",
"integrity": "sha512-wAi4IRJV/2SAW3htkTlB+dHeRmpTiVIK1OGLWV1yeStVSebSQQOwGwIq0D3ZIoBj2C2qpgz5+vX9iEBkTdk5YA==",
"license": "MIT",
"dependencies": {
"@vue/compiler-ssr": "3.4.31",
"@vue/shared": "3.4.31"
"@vue/compiler-ssr": "3.5.13",
"@vue/shared": "3.5.13"
},
"peerDependencies": {
"vue": "3.4.31"
"vue": "3.5.13"
}
},
"node_modules/@vue/shared": {
"version": "3.4.31",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.4.31.tgz",
"integrity": "sha512-Yp3wtJk//8cO4NItOPpi3QkLExAr/aLBGZMmTtW9WpdwBCJpRM6zj9WgWktXAl8IDIozwNMByT45JP3tO3ACWA=="
"version": "3.5.13",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.13.tgz",
"integrity": "sha512-/hnE/qP5ZoGpol0a5mDi45bOd7t3tjYJBjsgCsivow7D48cJeV5l05RD82lPqi7gRiphZM37rnhW1l6ZoCNNnQ==",
"license": "MIT"
},
"node_modules/@vue/test-utils": {
"version": "2.4.6",
@@ -7847,7 +7891,8 @@
"node_modules/csstype": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"license": "MIT"
},
"node_modules/data-urls": {
"version": "3.0.2",
@@ -14427,15 +14472,16 @@
}
},
"node_modules/nanoid": {
"version": "3.3.7",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz",
"integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==",
"version": "3.3.8",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz",
"integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.cjs"
},
@@ -15170,9 +15216,10 @@
}
},
"node_modules/picocolors": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.0.tgz",
"integrity": "sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw=="
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"license": "ISC"
},
"node_modules/picomatch": {
"version": "2.3.1",
@@ -15336,9 +15383,9 @@
}
},
"node_modules/postcss": {
"version": "8.4.47",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.47.tgz",
"integrity": "sha512-56rxCq7G/XfB4EkXq9Egn5GCqugWvDFjafDOThIdMBsI15iqPqR5r15TfSr1YPYeEI19YeaXMCbY6u88Y76GLQ==",
"version": "8.5.1",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.1.tgz",
"integrity": "sha512-6oz2beyjc5VMn/KV1pPw8fliQkhBXrVn1Z3TVyqZxU8kZpzEKhBdmCFqI6ZbmGtamQvQGuU1sgPTk8ZrXDD7jQ==",
"funding": [
{
"type": "opencollective",
@@ -15353,9 +15400,10 @@
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.7",
"picocolors": "^1.1.0",
"nanoid": "^3.3.8",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
"engines": {
@@ -18816,15 +18864,16 @@
"license": "MIT"
},
"node_modules/vue": {
"version": "3.4.31",
"resolved": "https://registry.npmjs.org/vue/-/vue-3.4.31.tgz",
"integrity": "sha512-njqRrOy7W3YLAlVqSKpBebtZpDVg21FPoaq1I7f/+qqBThK9ChAIjkRWgeP6Eat+8C+iia4P3OYqpATP21BCoQ==",
"version": "3.5.13",
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.13.tgz",
"integrity": "sha512-wmeiSMxkZCSc+PM2w2VRsOYAZC8GdipNFRTsLSfodVqI9mbejKeXEGr8SckuLnrQPGe3oJN5c3K0vpoU9q/wCQ==",
"license": "MIT",
"dependencies": {
"@vue/compiler-dom": "3.4.31",
"@vue/compiler-sfc": "3.4.31",
"@vue/runtime-dom": "3.4.31",
"@vue/server-renderer": "3.4.31",
"@vue/shared": "3.4.31"
"@vue/compiler-dom": "3.5.13",
"@vue/compiler-sfc": "3.5.13",
"@vue/runtime-dom": "3.5.13",
"@vue/server-renderer": "3.5.13",
"@vue/shared": "3.5.13"
},
"peerDependencies": {
"typescript": "*"

View File

@@ -85,6 +85,7 @@
"@atlaskit/pragmatic-drag-and-drop": "^1.3.1",
"@comfyorg/comfyui-electron-types": "^0.4.11",
"@comfyorg/litegraph": "^0.8.61",
"@primevue/forms": "^4.2.5",
"@primevue/themes": "^4.2.5",
"@sentry/vue": "^8.48.0",
"@tiptap/core": "^2.10.4",
@@ -108,7 +109,7 @@
"primevue": "^4.2.5",
"three": "^0.170.0",
"tiptap-markdown": "^0.8.10",
"vue": "^3.4.31",
"vue": "^3.5.13",
"vue-i18n": "^9.13.1",
"vue-router": "^4.4.3",
"zod": "^3.23.8",

View File

@@ -1,40 +0,0 @@
<template>
<div :class="['flex flex-wrap', $attrs.class]">
<div
v-for="checkbox in checkboxes"
:key="checkbox.value"
class="flex items-center gap-2"
>
<Checkbox
v-model="internalSelection"
:inputId="checkbox.value"
:value="checkbox.value"
/>
<label :for="checkbox.value" class="ml-2">{{ checkbox.label }}</label>
</div>
</div>
</template>
<script setup lang="ts">
import Checkbox from 'primevue/checkbox'
import { computed } from 'vue'
interface CheckboxItem {
label: string
value: string
}
const props = defineProps<{
checkboxes: CheckboxItem[]
modelValue: string[]
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: string[]): void
}>()
const internalSelection = computed({
get: () => props.modelValue,
set: (value: string[]) => emit('update:modelValue', value)
})
</script>

View File

@@ -0,0 +1,53 @@
<!--
A refresh button that disables and shows a progress spinner whilst active.
Usage:
```vue
<RefreshButton
v-model="isRefreshing"
:outlined="false"
@refresh="refresh"
/>
```
-->
<template>
<Button
class="relative p-button-icon-only"
:outlined="props.outlined"
:severity="props.severity"
:disabled="active || props.disabled"
@click="(event) => $emit('refresh', event)"
>
<span
class="p-button-icon pi pi-refresh transition-all"
:class="{ 'opacity-0': active }"
data-pc-section="icon"
></span>
<span class="p-button-label" data-pc-section="label">&nbsp;</span>
<ProgressSpinner v-show="active" class="absolute w-1/2 h-1/2" />
</Button>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import ProgressSpinner from 'primevue/progressspinner'
import { VueSeverity } from '@/types/primeVueTypes'
// Properties
interface Props {
outlined?: boolean
disabled?: boolean
severity?: VueSeverity
}
const props = withDefaults(defineProps<Props>(), {
outlined: true,
severity: 'secondary'
})
// Model
const active = defineModel<boolean>({ required: true })
// Emits
defineEmits(['refresh'])
</script>

View File

@@ -1,10 +1,10 @@
<template>
<div :class="props.class">
<div>
<IconField>
<Button
v-if="props.filterIcon"
v-if="filterIcon"
class="p-inputicon filter-button"
:icon="props.filterIcon"
:icon="filterIcon"
text
severity="contrast"
@click="$emit('showFilter', $event)"
@@ -12,12 +12,12 @@
<InputText
class="search-box-input w-full"
@input="handleInput"
:modelValue="props.modelValue"
:placeholder="props.placeholder"
:modelValue="modelValue"
:placeholder="placeholder"
/>
<InputIcon v-if="!props.modelValue" :class="props.icon" />
<InputIcon v-if="!modelValue" :class="icon" />
<Button
v-if="props.modelValue"
v-if="modelValue"
class="p-inputicon clear-button"
icon="pi pi-times"
text
@@ -47,40 +47,36 @@ import Button from 'primevue/button'
import IconField from 'primevue/iconfield'
import InputIcon from 'primevue/inputicon'
import InputText from 'primevue/inputtext'
import { toRefs } from 'vue'
import type { SearchFilter } from './SearchFilterChip.vue'
import SearchFilterChip from './SearchFilterChip.vue'
const props = withDefaults(
defineProps<{
class?: string
modelValue: string
placeholder?: string
icon?: string
debounceTime?: number
filterIcon?: string
filters?: TFilter[]
}>(),
{
placeholder: 'Search...',
icon: 'pi pi-search',
debounceTime: 300
}
)
const {
modelValue,
placeholder = 'Search...',
icon = 'pi pi-search',
debounceTime = 300,
filterIcon,
filters = []
} = defineProps<{
modelValue: string
placeholder?: string
icon?: string
debounceTime?: number
filterIcon?: string
filters?: TFilter[]
}>()
const { filters } = toRefs(props)
const emit = defineEmits([
'update:modelValue',
'search',
'showFilter',
'removeFilter'
])
const emit = defineEmits<{
(e: 'update:modelValue', value: string): void
(e: 'search', value: string, filters: TFilter[]): void
(e: 'showFilter', event: Event): void
(e: 'removeFilter', filter: TFilter): void
}>()
const emitSearch = debounce((value: string) => {
emit('search', value, props.filters)
}, props.debounceTime)
emit('search', value, filters)
}, debounceTime)
const handleInput = (event: Event) => {
const target = event.target as HTMLInputElement

View File

@@ -90,10 +90,10 @@ const stackTraceField = computed<ReportField>(() => {
label: t('issueReport.stackTrace'),
value: 'StackTrace',
optIn: true,
data: {
getData: () => ({
nodeType: props.error.node_type,
stackTrace: props.error.traceback?.join('\n')
}
})
}
})

View File

@@ -7,9 +7,9 @@
}"
>
<template #header>
<header class="flex flex-col items-center">
<header class="flex flex-col items-center w-full">
<h2 id="issue-report-title" class="text-4xl">{{ title }}</h2>
<span v-if="subtitle" class="text-muted mt-2">{{ subtitle }}</span>
<span v-if="subtitle" class="text-muted mt-0">{{ subtitle }}</span>
</header>
</template>
<ReportIssuePanel v-bind="panelProps" :pt="{ root: 'border-none' }" />
@@ -20,10 +20,9 @@
<script setup lang="ts">
import Panel from 'primevue/panel'
import ReportIssuePanel from '@/components/dialog/content/error/ReportIssuePanel.vue'
import type { IssueReportPanelProps } from '@/types/issueReportTypes'
import ReportIssuePanel from './error/ReportIssuePanel.vue'
defineProps<{
title: string
subtitle?: string

View File

@@ -1,213 +1,251 @@
<template>
<Panel>
<template #header>
<div class="flex items-center gap-2">
<span class="font-bold">{{ title }}</span>
<Form
v-slot="$form"
@submit="submit"
:resolver="zodResolver(issueReportSchema)"
>
<Panel :pt="$attrs.pt">
<template #header>
<div class="flex items-center gap-2">
<span class="font-bold">{{ title }}</span>
</div>
</template>
<template #footer>
<div class="flex justify-end gap-4">
<Button
v-tooltip="!submitted ? $t('g.reportIssueTooltip') : undefined"
:label="submitted ? $t('g.reportSent') : $t('g.reportIssue')"
:severity="submitted ? 'secondary' : 'primary'"
:icon="submitted ? 'pi pi-check' : 'pi pi-send'"
:disabled="submitted"
type="submit"
/>
</div>
</template>
<div class="p-4 mt-2 border border-round surface-border shadow-1">
<div class="flex flex-row gap-3 mb-2">
<div v-for="field in fields" :key="field.value">
<FormField
v-if="field.optIn"
v-slot="$field"
:name="field.value"
class="flex space-x-1"
>
<Checkbox
v-bind="$field"
:inputId="field.value"
:value="field.value"
v-model="selection"
/>
<label :for="field.value">{{ field.label }}</label>
</FormField>
</div>
</div>
<FormField class="mb-4" v-slot="$field" name="details">
<Textarea
v-bind="$field"
class="w-full"
rows="5"
:placeholder="$t('issueReport.provideAdditionalDetails')"
:aria-label="$t('issueReport.provideAdditionalDetails')"
/>
<Message
v-if="$field?.error && $field.touched"
severity="error"
size="small"
variant="simple"
>
{{ t('issueReport.validation.maxLength') }}
</Message>
</FormField>
<FormField v-slot="$field" name="contactInfo">
<InputText
v-bind="$field"
class="w-full"
:placeholder="$t('issueReport.provideEmail')"
/>
<Message
v-if="$field?.error && $field.touched && $field.value !== ''"
severity="error"
size="small"
variant="simple"
>
{{ t('issueReport.validation.invalidEmail') }}
</Message>
</FormField>
<div class="flex flex-row gap-3 mt-2">
<div v-for="checkbox in contactCheckboxes" :key="checkbox.value">
<FormField
v-slot="$field"
:name="checkbox.value"
class="flex space-x-1"
>
<Checkbox
v-bind="$field"
:inputId="checkbox.value"
:value="checkbox.value"
v-model="contactPrefs"
:disabled="
$form.contactInfo?.error || !$form.contactInfo?.value
"
/>
<label :for="checkbox.value">{{ checkbox.label }}</label>
</FormField>
</div>
</div>
</div>
</template>
<template #footer>
<div class="flex justify-end">
<Button
v-tooltip="$t('g.reportIssueTooltip')"
:label="submitted ? $t('g.reportSent') : $t('g.reportIssue')"
:severity="isButtonDisabled ? 'secondary' : 'primary'"
:icon="icon"
:disabled="isButtonDisabled"
@click="reportIssue"
/>
</div>
</template>
<div class="p-4 mt-4 border border-round surface-border shadow-1">
<CheckboxGroup
v-if="reportCheckboxes.length"
v-model="selection"
class="gap-4 mb-4"
:checkboxes="reportCheckboxes"
/>
<div class="mb-4">
<InputText
v-model="contactInfo"
class="w-full"
:placeholder="$t('issueReport.provideEmail')"
:maxlength="CONTACT_MAX_LEN"
:invalid="isContactInfoInvalid"
/>
<CheckboxGroup
v-model="contactPrefs"
class="gap-3 mt-2"
:checkboxes="contactCheckboxes"
/>
</div>
<div class="mb-4">
<Textarea
v-model="details"
class="w-full"
rows="4"
:maxlength="DETAILS_MAX_LEN"
:placeholder="$t('issueReport.provideAdditionalDetails')"
:aria-label="$t('issueReport.provideAdditionalDetails')"
/>
</div>
</div>
</Panel>
</Panel>
</Form>
</template>
<script setup lang="ts">
import { Form, FormField, type FormSubmitEvent } from '@primevue/forms'
// @ts-expect-error https://github.com/primefaces/primevue/issues/6722
import { zodResolver } from '@primevue/forms/resolvers/zod'
import type { CaptureContext, User } from '@sentry/core'
import { captureMessage } from '@sentry/core'
import cloneDeep from 'lodash/cloneDeep'
import Button from 'primevue/button'
import Checkbox from 'primevue/checkbox'
import InputText from 'primevue/inputtext'
import Message from 'primevue/message'
import Panel from 'primevue/panel'
import Textarea from 'primevue/textarea'
import { useToast } from 'primevue/usetoast'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import CheckboxGroup from '@/components/common/CheckboxGroup.vue'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import {
type IssueReportFormData,
type ReportField,
issueReportSchema
} from '@/types/issueReportTypes'
import type {
DefaultField,
IssueReportPanelProps
} from '@/types/issueReportTypes'
import { isElectron } from '@/utils/envUtil'
const ISSUE_NAME = 'User reported issue'
const DETAILS_MAX_LEN = 5_000
const CONTACT_MAX_LEN = 320
const props = defineProps<IssueReportPanelProps>()
const {
defaultFields = ['Workflow', 'Logs', 'SystemStats', 'Settings'],
tags = {}
} = props
const { defaultFields = ['Workflow', 'Logs', 'SystemStats', 'Settings'] } =
props
const { t } = useI18n()
const toast = useToast()
const selection = ref<string[]>([])
const contactPrefs = ref<string[]>([])
const contactInfo = ref('')
const details = ref('')
const submitting = ref(false)
const submitted = ref(false)
const followUp = computed(() => contactPrefs.value.includes('FollowUp'))
const notifyResolve = computed(() => contactPrefs.value.includes('Resolution'))
const icon = computed(() => {
if (submitting.value) return 'pi pi-spin pi-spinner'
if (submitted.value) return 'pi pi-check'
return 'pi pi-send'
})
const isFormEmpty = computed(() => !selection.value.length && !details.value)
const isContactInfoInvalid = computed(() => {
if (!contactInfo.value) return false
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
return !emailRegex.test(contactInfo.value)
})
const isButtonDisabled = computed(
() =>
submitted.value ||
submitting.value ||
isFormEmpty.value ||
isContactInfoInvalid.value
)
const contactCheckboxes = [
{ label: t('issueReport.contactFollowUp'), value: 'FollowUp' },
{ label: t('issueReport.notifyResolve'), value: 'Resolution' }
{ label: t('issueReport.contactFollowUp'), value: 'followUp' },
{ label: t('issueReport.notifyResolve'), value: 'notifyOnResolution' }
]
const defaultReportCheckboxes = [
{ label: t('g.workflow'), value: 'Workflow' },
{ label: t('g.logs'), value: 'Logs' },
{ label: t('issueReport.systemStats'), value: 'SystemStats' },
{ label: t('g.settings'), value: 'Settings' }
const defaultFieldsConfig: ReportField[] = [
{
label: t('issueReport.systemStats'),
value: 'SystemStats',
getData: () => api.getSystemStats(),
optIn: true
},
{
label: t('g.workflow'),
value: 'Workflow',
getData: () => cloneDeep(app.graph.asSerialisable()),
optIn: true
},
{
label: t('g.logs'),
value: 'Logs',
getData: () => api.getLogs(),
optIn: true
},
{
label: t('g.settings'),
value: 'Settings',
getData: () => api.getSettings(),
optIn: true
}
]
const reportCheckboxes = computed(() => [
...(props.extraFields
?.filter(({ optIn }) => optIn)
.map(({ label, value }) => ({ label, value })) ?? []),
...defaultReportCheckboxes.filter(({ value }) =>
const fields = computed(() => [
...defaultFieldsConfig.filter(({ value }) =>
defaultFields.includes(value as DefaultField)
)
),
...(props.extraFields ?? [])
])
const getUserInfo = (): User => ({ email: contactInfo.value })
const createUser = (formData: IssueReportFormData): User => ({
email: formData.contactInfo || undefined
})
const getLogs = async () =>
selection.value.includes('Logs') ? api.getLogs() : null
const createExtraData = async (formData: IssueReportFormData) => {
const result = {}
const isChecked = (fieldValue: string) => formData[fieldValue]
const getSystemStats = async () =>
selection.value.includes('SystemStats') ? api.getSystemStats() : null
await Promise.all(
fields.value
.filter((field) => !field.optIn || isChecked(field.value))
.map(async (field) => {
try {
result[field.value] = await field.getData()
} catch (error) {
console.error(`Failed to collect ${field.value}:`, error)
result[field.value] = { error: String(error) }
}
})
)
const getSettings = async () =>
selection.value.includes('Settings') ? api.getSettings() : null
const getWorkflow = () =>
selection.value.includes('Workflow')
? cloneDeep(app.graph.asSerialisable())
: null
const createDefaultFields = async () => {
const [settings, systemStats, logs, workflow] = await Promise.all([
getSettings(),
getSystemStats(),
getLogs(),
getWorkflow()
])
return { settings, systemStats, logs, workflow }
return result
}
const createExtraFields = (): Record<string, unknown> | undefined => {
if (!props.extraFields) return undefined
return props.extraFields
.filter((field) => !field.optIn || selection.value.includes(field.value))
.reduce((acc, field) => ({ ...acc, ...cloneDeep(field.data) }), {})
}
const createFeedback = () => {
const createCaptureContext = async (
formData: IssueReportFormData
): Promise<CaptureContext> => {
return {
details: details.value,
contactPreferences: {
followUp: followUp.value,
notifyOnResolution: notifyResolve.value
}
}
}
const createCaptureContext = async (): Promise<CaptureContext> => {
return {
user: getUserInfo(),
user: createUser(formData),
level: 'error',
tags: {
errorType: props.errorType,
...tags
followUp: formData.contactInfo ? formData.followUp : false,
notifyOnResolution: formData.contactInfo
? formData.notifyOnResolution
: false,
isElectron: isElectron(),
...props.tags
},
extra: {
...createFeedback(),
...(await createDefaultFields()),
...createExtraFields()
details: formData.details,
...(await createExtraData(formData))
}
}
}
const reportIssue = async () => {
if (isButtonDisabled.value) return
submitting.value = true
try {
captureMessage(ISSUE_NAME, await createCaptureContext())
submitted.value = true
toast.add({
severity: 'success',
summary: t('g.reportSent'),
life: 3000
})
} finally {
submitting.value = false
const submit = async (event: FormSubmitEvent) => {
if (event.valid) {
try {
const captureContext = await createCaptureContext(event.values)
captureMessage(ISSUE_NAME, captureContext)
submitted.value = true
toast.add({
severity: 'success',
summary: t('g.reportSent'),
life: 3000
})
} catch (error) {
toast.add({
severity: 'error',
summary: t('g.error'),
detail: error.message,
life: 3000
})
}
}
}
</script>

View File

@@ -1,28 +1,51 @@
// @ts-strict-ignore
import { createTestingPinia } from '@pinia/testing'
import { Form } from '@primevue/forms'
import { mount } from '@vue/test-utils'
import Button from 'primevue/button'
import Checkbox from 'primevue/checkbox'
import PrimeVue from 'primevue/config'
import InputText from 'primevue/inputtext'
import Panel from 'primevue/panel'
import Textarea from 'primevue/textarea'
import Tooltip from 'primevue/tooltip'
import { beforeAll, describe, expect, it, vi } from 'vitest'
import { createApp } from 'vue'
import { describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import CheckboxGroup from '@/components/common/CheckboxGroup.vue'
import enMesages from '@/locales/en/main.json'
import { DefaultField, ReportField } from '@/types/issueReportTypes'
import { IssueReportPanelProps } from '@/types/issueReportTypes'
import ReportIssuePanel from '../ReportIssuePanel.vue'
type ReportIssuePanelProps = {
errorType: string
defaultFields?: DefaultField[]
extraFields?: ReportField[]
tags?: Record<string, string>
title?: string
const DEFAULT_FIELDS = ['Workflow', 'Logs', 'Settings', 'SystemStats']
const CUSTOM_FIELDS = [
{
label: 'Custom Field',
value: 'CustomField',
optIn: true,
getData: () => 'mock data'
}
]
async function getSubmittedContext() {
const { captureMessage } = (await import('@sentry/core')) as any
return captureMessage.mock.calls[0][1]
}
async function submitForm(wrapper: any) {
await wrapper.findComponent(Form).trigger('submit')
return getSubmittedContext()
}
async function findAndUpdateCheckbox(
wrapper: any,
value: string,
checked = true
) {
const checkbox = wrapper
.findAllComponents(Checkbox)
.find((c: any) => c.props('value') === value)
if (!checkbox) throw new Error(`Checkbox with value "${value}" not found`)
await checkbox.vm.$emit('update:modelValue', checked)
return checkbox
}
const i18n = createI18n({
@@ -59,18 +82,64 @@ vi.mock('@sentry/core', () => ({
captureMessage: vi.fn()
}))
vi.mock('@primevue/forms', () => ({
Form: {
name: 'Form',
template:
'<form @submit.prevent="onSubmit"><slot :values="formValues" /></form>',
props: ['resolver'],
data() {
return {
formValues: {}
}
},
methods: {
onSubmit() {
this.$emit('submit', {
valid: true,
values: this.formValues
})
},
updateFieldValue(name: string, value: any) {
this.formValues[name] = value
}
}
},
FormField: {
name: 'FormField',
template:
'<div><slot :modelValue="modelValue" @update:modelValue="updateValue" /></div>',
props: ['name'],
data() {
return {
modelValue: ''
}
},
methods: {
updateValue(value) {
this.modelValue = value
let parent = this.$parent
while (parent && parent.$options.name !== 'Form') {
parent = parent.$parent
}
if (parent) {
parent.updateFieldValue(this.name, value)
}
}
}
}
}))
describe('ReportIssuePanel', () => {
beforeAll(() => {
const app = createApp({})
app.use(PrimeVue)
beforeEach(() => {
vi.clearAllMocks()
})
const mountComponent = (props: ReportIssuePanelProps, options = {}): any => {
const mountComponent = (props: IssueReportPanelProps, options = {}): any => {
return mount(ReportIssuePanel, {
global: {
plugins: [PrimeVue, createTestingPinia(), i18n],
directives: { tooltip: Tooltip },
components: { InputText, Button, Panel, Textarea, CheckboxGroup }
plugins: [PrimeVue, i18n],
directives: { tooltip: Tooltip }
},
props,
...options
@@ -80,44 +149,66 @@ describe('ReportIssuePanel', () => {
it('renders the panel with all required components', () => {
const wrapper = mountComponent({ errorType: 'Test Error' })
expect(wrapper.find('.p-panel').exists()).toBe(true)
expect(wrapper.findAllComponents(CheckboxGroup).length).toBe(2)
expect(wrapper.findAllComponents(Checkbox).length).toBe(6)
expect(wrapper.findComponent(InputText).exists()).toBe(true)
expect(wrapper.findComponent(Textarea).exists()).toBe(true)
expect(wrapper.findComponent(Button).exists()).toBe(true)
})
it('updates selection when checkboxes are selected', async () => {
const wrapper = mountComponent({ errorType: 'Test Error' })
const checkboxes = wrapper.findAllComponents(CheckboxGroup).at(0)
await checkboxes?.setValue(['Workflow', 'Logs'])
expect(wrapper.vm.selection).toEqual(['Workflow', 'Logs'])
const wrapper = mountComponent({
errorType: 'Test Error'
})
const checkboxes = wrapper.findAllComponents(Checkbox)
for (const field of DEFAULT_FIELDS) {
const checkbox = checkboxes.find(
(checkbox) => checkbox.props('value') === field
)
expect(checkbox).toBeDefined()
await checkbox?.vm.$emit('update:modelValue', [field])
expect(wrapper.vm.selection).toContain(field)
}
})
it('updates contactInfo when input is changed', async () => {
const wrapper = mountComponent({ errorType: 'Test Error' })
const input = wrapper.findComponent(InputText)
await input.setValue('test@example.com')
expect(wrapper.vm.contactInfo).toBe('test@example.com')
await input.vm.$emit('update:modelValue', 'test@example.com')
const context = await submitForm(wrapper)
expect(context.user.email).toBe('test@example.com')
})
it('updates additional details when textarea is changed', async () => {
const wrapper = mountComponent({ errorType: 'Test Error' })
const textarea = wrapper.findComponent(Textarea)
await textarea.setValue('This is a test detail.')
expect(wrapper.vm.details).toBe('This is a test detail.')
await textarea.vm.$emit('update:modelValue', 'This is a test detail.')
const context = await submitForm(wrapper)
expect(context.extra.details).toBe('This is a test detail.')
})
it('updates contactPrefs when preferences are selected', async () => {
it('set contact preferences back to false if email is removed', async () => {
const wrapper = mountComponent({ errorType: 'Test Error' })
const preferences = wrapper.findAllComponents(CheckboxGroup).at(1)
await preferences?.setValue(['FollowUp'])
expect(wrapper.vm.contactPrefs).toEqual(['FollowUp'])
})
const input = wrapper.findComponent(InputText)
it('does not allow submission if the form is empty', async () => {
const wrapper = mountComponent({ errorType: 'Test Error' })
await wrapper.vm.reportIssue()
expect(wrapper.vm.submitted).toBe(false)
// Set a valid email, enabling the contact preferences to be changed
await input.vm.$emit('update:modelValue', 'name@example.com')
// Enable both contact preferences
for (const pref of ['followUp', 'notifyOnResolution']) {
await findAndUpdateCheckbox(wrapper, pref)
}
// Change the email back to empty
await input.vm.$emit('update:modelValue', '')
const context = await submitForm(wrapper)
// Check that the contact preferences are back to false automatically
expect(context.tags.followUp).toBe(false)
expect(context.tags.notifyOnResolution).toBe(false)
})
it('renders with overridden default fields', () => {
@@ -125,83 +216,87 @@ describe('ReportIssuePanel', () => {
errorType: 'Test Error',
defaultFields: ['Settings']
})
const checkboxes = wrapper.findAllComponents(CheckboxGroup).at(0)
expect(checkboxes?.props('checkboxes')).toEqual([
{ label: 'Settings', value: 'Settings' }
])
// Filter out the contact preferences checkboxes
const fieldCheckboxes = wrapper
.findAllComponents(Checkbox)
.filter(
(checkbox) =>
!['followUp', 'notifyOnResolution'].includes(checkbox.props('value'))
)
expect(fieldCheckboxes.length).toBe(1)
expect(fieldCheckboxes.at(0)?.props('value')).toBe('Settings')
})
it('renders additional fields when extraFields prop is provided', () => {
const extraFields = [
{ label: 'Custom Field', value: 'CustomField', optIn: true, data: {} }
]
const wrapper = mountComponent({ errorType: 'Test Error', extraFields })
const checkboxes = wrapper.findAllComponents(CheckboxGroup).at(0)
expect(checkboxes?.props('checkboxes')).toContainEqual({
label: 'Custom Field',
value: 'CustomField'
const wrapper = mountComponent({
errorType: 'Test Error',
extraFields: CUSTOM_FIELDS
})
const customCheckbox = wrapper
.findAllComponents(Checkbox)
.find((checkbox) => checkbox.props('value') === 'CustomField')
expect(customCheckbox).toBeDefined()
})
it('allows custom fields to be selected', async () => {
const wrapper = mountComponent({
errorType: 'Test Error',
extraFields: CUSTOM_FIELDS
})
await findAndUpdateCheckbox(wrapper, 'CustomField')
const context = await submitForm(wrapper)
expect(context.extra.CustomField).toBe('mock data')
})
it('does not submit unchecked fields', async () => {
const wrapper = mountComponent({ errorType: 'Test Error' })
const textarea = wrapper.findComponent(Textarea)
await textarea.setValue('Report with only text but no fields selected')
await wrapper.vm.reportIssue()
// Set details but don't check any field checkboxes
await textarea.vm.$emit(
'update:modelValue',
'Report with only text but no fields selected'
)
const context = await submitForm(wrapper)
const { captureMessage } = (await import('@sentry/core')) as any
const captureContext = captureMessage.mock.calls[0][1]
expect(captureContext.extra.logs).toBeNull()
expect(captureContext.extra.systemStats).toBeNull()
expect(captureContext.extra.settings).toBeNull()
expect(captureContext.extra.workflow).toBeNull()
// Verify none of the optional fields were included
for (const field of DEFAULT_FIELDS) {
expect(context.extra[field]).toBeUndefined()
}
})
it.each([
{
checkbox: 'Logs',
apiMethod: 'getLogs',
expectedKey: 'logs',
expectedKey: 'Logs',
mockValue: 'mock logs'
},
{
checkbox: 'SystemStats',
apiMethod: 'getSystemStats',
expectedKey: 'systemStats',
expectedKey: 'SystemStats',
mockValue: 'mock stats'
},
{
checkbox: 'Settings',
apiMethod: 'getSettings',
expectedKey: 'settings',
expectedKey: 'Settings',
mockValue: 'mock settings'
}
])(
'submits (%s) when the (%s) checkbox is selected',
'submits $checkbox data when checkbox is selected',
async ({ checkbox, apiMethod, expectedKey, mockValue }) => {
const wrapper = mountComponent({ errorType: 'Test Error' })
const { api } = (await import('@/scripts/api')) as any
vi.spyOn(api, apiMethod).mockResolvedValue(mockValue)
const { captureMessage } = await import('@sentry/core')
// Select the checkbox
const checkboxes = wrapper.findAllComponents(CheckboxGroup).at(0)
await checkboxes?.vm.$emit('update:modelValue', [checkbox])
await wrapper.vm.reportIssue()
expect(api[apiMethod]).toHaveBeenCalled()
// Verify the message includes the associated data
expect(captureMessage).toHaveBeenCalledWith(
'User reported issue',
expect.objectContaining({
extra: expect.objectContaining({ [expectedKey]: mockValue })
})
)
await findAndUpdateCheckbox(wrapper, checkbox)
const context = await submitForm(wrapper)
expect(context.extra[expectedKey]).toBe(mockValue)
}
)
@@ -209,24 +304,12 @@ describe('ReportIssuePanel', () => {
const wrapper = mountComponent({ errorType: 'Test Error' })
const { app } = (await import('@/scripts/app')) as any
const { captureMessage } = await import('@sentry/core')
const mockWorkflow = { nodes: [], edges: [] }
vi.spyOn(app.graph, 'asSerialisable').mockReturnValue(mockWorkflow)
// Select the "Workflow" checkbox
const checkboxes = wrapper.findAllComponents(CheckboxGroup).at(0)
await checkboxes?.vm.$emit('update:modelValue', ['Workflow'])
await findAndUpdateCheckbox(wrapper, 'Workflow')
const context = await submitForm(wrapper)
await wrapper.vm.reportIssue()
expect(app.graph.asSerialisable).toHaveBeenCalled()
// Verify the message includes the workflow
expect(captureMessage).toHaveBeenCalledWith(
'User reported issue',
expect.objectContaining({
extra: expect.objectContaining({ workflow: mockWorkflow })
})
)
expect(context.extra.Workflow).toEqual(mockWorkflow)
})
})

View File

@@ -0,0 +1,40 @@
<template>
<Tag :icon :severity :value />
</template>
<script setup lang="ts">
import { PrimeIcons, type PrimeIconsOptions } from '@primevue/core/api'
import Tag, { TagProps } from 'primevue/tag'
import { ref, watch } from 'vue'
import { t } from '@/i18n'
// Properties
const props = defineProps<{
error: boolean
refreshing?: boolean
}>()
// Bindings
const icon = ref<string>(null)
const severity = ref<TagProps['severity']>(null)
const value = ref<PrimeIconsOptions[keyof PrimeIconsOptions]>(null)
const updateBindings = () => {
if (props.refreshing) {
icon.value = PrimeIcons.QUESTION
severity.value = 'info'
value.value = t('maintenance.refreshing')
} else if (props.error) {
icon.value = PrimeIcons.TIMES
severity.value = 'danger'
value.value = t('g.error')
} else {
icon.value = PrimeIcons.CHECK
severity.value = 'success'
value.value = t('maintenance.OK')
}
}
watch(props, updateBindings, { deep: true })
</script>

View File

@@ -0,0 +1,127 @@
<template>
<div
class="task-div max-w-48 min-h-52 grid relative"
:class="{ 'opacity-75': isLoading }"
>
<Card
class="max-w-48 relative h-full overflow-hidden"
:class="{ 'opacity-65': runner.state !== 'error' }"
v-bind="(({ onClick, ...rest }) => rest)($attrs)"
>
<template #header>
<i
v-if="runner.state === 'error'"
class="pi pi-exclamation-triangle text-red-500 absolute m-2 top-0 -right-14 opacity-15"
style="font-size: 10rem"
/>
<img
v-if="task.headerImg"
:src="task.headerImg"
class="object-contain w-full h-full opacity-25 pt-4 px-4"
/>
</template>
<template #title>{{ task.name }}</template>
<template #content>{{ description }}</template>
<template #footer>
<div class="flex gap-4 mt-1">
<Button
:icon="task.button?.icon"
:label="task.button?.text"
class="w-full"
raised
icon-pos="right"
@click="(event) => $emit('execute', event)"
:loading="isExecuting"
/>
</div>
</template>
</Card>
<i
v-if="!isLoading && runner.state === 'OK'"
class="task-card-ok pi pi-check"
/>
</div>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import Card from 'primevue/card'
import { computed } from 'vue'
import { useMaintenanceTaskStore } from '@/stores/maintenanceTaskStore'
import type { MaintenanceTask } from '@/types/desktop/maintenanceTypes'
import { useMinLoadingDurationRef } from '@/utils/refUtil'
const taskStore = useMaintenanceTaskStore()
const runner = computed(() => taskStore.getRunner(props.task))
// Properties
const props = defineProps<{
task: MaintenanceTask
}>()
// Events
defineEmits<{
execute: [event: MouseEvent]
}>()
// Bindings
const description = computed(() =>
runner.value.state === 'error'
? props.task.errorDescription ?? props.task.shortDescription
: props.task.shortDescription
)
// Use a minimum run time to ensure tasks "feel" like they have run
const reactiveLoading = computed(() => runner.value.refreshing)
const reactiveExecuting = computed(() => runner.value.executing)
const isLoading = useMinLoadingDurationRef(reactiveLoading, 250)
const isExecuting = useMinLoadingDurationRef(reactiveExecuting, 250)
</script>
<style scoped>
.task-card-ok {
@apply text-green-500 absolute -right-4 -bottom-4 opacity-100 row-span-full col-span-full transition-opacity;
font-size: 4rem;
text-shadow: 0.25rem 0 0.5rem black;
z-index: 10;
}
.p-card {
@apply transition-opacity;
--p-card-background: var(--p-button-secondary-background);
opacity: 0.9;
&.opacity-65 {
opacity: 0.4;
}
&:hover {
opacity: 1;
}
}
:deep(.p-card-header) {
z-index: 0;
}
:deep(.p-card-body) {
z-index: 1;
flex-grow: 1;
justify-content: space-between;
}
.task-div {
> i {
pointer-events: none;
}
&:hover > i {
opacity: 0.2;
}
}
</style>

View File

@@ -0,0 +1,86 @@
<template>
<tr
class="border-neutral-700 border-solid border-y"
:class="{
'opacity-50': runner.resolved,
'opacity-75': isLoading && runner.resolved
}"
>
<td class="text-center w-16">
<TaskListStatusIcon :state="runner.state" :loading="isLoading" />
</td>
<td>
<p class="inline-block">{{ task.name }}</p>
<Button
class="inline-block mx-2"
type="button"
:icon="PrimeIcons.INFO_CIRCLE"
severity="secondary"
:text="true"
@click="toggle"
/>
<Popover ref="infoPopover" class="block m-1 max-w-64 min-w-32">
<span class="whitespace-pre-line">{{ task.description }}</span>
</Popover>
</td>
<td class="text-right px-4">
<Button
:icon="task.button?.icon"
:label="task.button?.text"
:severity
icon-pos="right"
@click="(event) => $emit('execute', event)"
:loading="isExecuting"
/>
</td>
</tr>
</template>
<script setup lang="ts">
import { PrimeIcons } from '@primevue/core/api'
import Button from 'primevue/button'
import Popover from 'primevue/popover'
import { computed, ref } from 'vue'
import { useMaintenanceTaskStore } from '@/stores/maintenanceTaskStore'
import type { MaintenanceTask } from '@/types/desktop/maintenanceTypes'
import { VueSeverity } from '@/types/primeVueTypes'
import { useMinLoadingDurationRef } from '@/utils/refUtil'
import TaskListStatusIcon from './TaskListStatusIcon.vue'
const taskStore = useMaintenanceTaskStore()
const runner = computed(() => taskStore.getRunner(props.task))
// Properties
const props = defineProps<{
task: MaintenanceTask
}>()
// Events
defineEmits<{
execute: [event: MouseEvent]
}>()
// Binding
const severity = computed<VueSeverity>(() =>
runner.value.state === 'error' || runner.value.state === 'warning'
? 'primary'
: 'secondary'
)
// Use a minimum run time to ensure tasks "feel" like they have run
const reactiveLoading = computed(() => runner.value.refreshing)
const reactiveExecuting = computed(() => runner.value.executing)
const isLoading = useMinLoadingDurationRef(reactiveLoading, 250)
const isExecuting = useMinLoadingDurationRef(reactiveExecuting, 250)
// Popover
const infoPopover = ref()
const toggle = (event: Event) => {
infoPopover.value.toggle(event)
}
</script>

View File

@@ -0,0 +1,115 @@
<template>
<!-- Tasks -->
<section class="my-4">
<template v-if="filter.tasks.length === 0">
<!-- Empty filter -->
<Divider />
<p class="text-neutral-400 w-full text-center">
{{ $t('maintenance.allOk') }}
</p>
</template>
<template v-else>
<!-- Display: List -->
<table
v-if="displayAsList === PrimeIcons.LIST"
class="w-full border-collapse border-hidden"
>
<TaskListItem
v-for="task in filter.tasks"
:key="task.id"
:task
@execute="(event) => confirmButton(event, task)"
/>
</table>
<!-- Display: Cards -->
<template v-else>
<div class="flex flex-wrap justify-evenly gap-8 pad-y my-4">
<TaskCard
v-for="task in filter.tasks"
:key="task.id"
:task
@execute="(event) => confirmButton(event, task)"
/>
</div>
</template>
</template>
<ConfirmPopup />
</section>
</template>
<script setup lang="ts">
import { PrimeIcons } from '@primevue/core/api'
import { useConfirm, useToast } from 'primevue'
import ConfirmPopup from 'primevue/confirmpopup'
import Divider from 'primevue/divider'
import { t } from '@/i18n'
import { useMaintenanceTaskStore } from '@/stores/maintenanceTaskStore'
import type {
MaintenanceFilter,
MaintenanceTask
} from '@/types/desktop/maintenanceTypes'
import TaskCard from './TaskCard.vue'
import TaskListItem from './TaskListItem.vue'
const toast = useToast()
const confirm = useConfirm()
const taskStore = useMaintenanceTaskStore()
// Properties
const props = defineProps<{
displayAsList: string
filter: MaintenanceFilter
isRefreshing: boolean
}>()
const executeTask = async (task: MaintenanceTask) => {
let message: string | undefined
try {
// Success
if ((await taskStore.execute(task)) === true) return
message = t('maintenance.error.taskFailed')
} catch (error) {
message = (error as Error)?.message
}
toast.add({
severity: 'error',
summary: t('maintenance.error.toastTitle'),
detail: message ?? t('maintenance.error.defaultDescription'),
life: 10_000
})
}
// Commands
const confirmButton = async (event: MouseEvent, task: MaintenanceTask) => {
if (!task.requireConfirm) {
await executeTask(task)
return
}
confirm.require({
target: event.currentTarget as HTMLElement,
message: task.confirmText ?? t('maintenance.confirmTitle'),
icon: 'pi pi-exclamation-circle',
rejectProps: {
label: t('g.cancel'),
severity: 'secondary',
outlined: true
},
acceptProps: {
label: task.button?.text ?? t('g.save'),
severity: task.severity ?? 'primary'
},
// TODO: Not awaited.
accept: async () => {
await executeTask(task)
}
})
}
</script>

View File

@@ -0,0 +1,45 @@
<template>
<ProgressSpinner v-if="!state || loading" class="h-8 w-8" />
<template v-else>
<i :class="cssClasses" v-tooltip.top="{ value: tooltip, showDelay: 250 }" />
</template>
</template>
<script setup lang="ts">
import { PrimeIcons } from '@primevue/core/api'
import ProgressSpinner from 'primevue/progressspinner'
import { MaybeRef, computed } from 'vue'
import { t } from '@/i18n'
import { MaintenanceTaskState } from '@/stores/maintenanceTaskStore'
// Properties
const tooltip = computed(() => {
if (props.state === 'error') {
return t('g.error')
} else if (props.state === 'OK') {
return t('maintenance.OK')
} else {
return t('maintenance.Skipped')
}
})
const cssClasses = computed(() => {
let classes: string
if (props.state === 'error') {
classes = `${PrimeIcons.EXCLAMATION_TRIANGLE} text-red-500`
} else if (props.state === 'OK') {
classes = `${PrimeIcons.CHECK} text-green-500`
} else {
classes = PrimeIcons.MINUS
}
return `text-3xl pi ${classes}`
})
// Model
const props = defineProps<{
state?: MaintenanceTaskState
loading?: MaybeRef<boolean>
}>()
</script>

View File

@@ -0,0 +1,144 @@
import { PrimeIcons } from '@primevue/core'
import type { MaintenanceTask } from '@/types/desktop/maintenanceTypes'
import { electronAPI } from '@/utils/envUtil'
const electron = electronAPI()
const openUrl = (url: string) => {
window.open(url, '_blank')
return true
}
export const DESKTOP_MAINTENANCE_TASKS: Readonly<MaintenanceTask>[] = [
{
id: 'basePath',
execute: async () => await electron.setBasePath(),
name: 'Base path',
shortDescription: 'Change the application base path.',
errorDescription: 'Unable to open the base path. Please select a new one.',
description:
'The base path is the default location where ComfyUI stores data. It is the location fo the python environment, and may also contain models, custom nodes, and other extensions.',
isInstallationFix: true,
button: {
icon: PrimeIcons.QUESTION,
text: 'Select'
}
},
{
id: 'git',
headerImg: '/assets/images/Git-Logo-White.svg',
execute: () => openUrl('https://git-scm.com/downloads/'),
name: 'Download git',
shortDescription: 'Open the git download page.',
description:
'Git is required to download and manage custom nodes and other extensions. This fixer simply opens the download page in your browser. You must download and install git manually.',
button: {
icon: PrimeIcons.EXTERNAL_LINK,
text: 'Download'
}
},
{
id: 'vcRedist',
execute: () => openUrl('https://aka.ms/vs/17/release/vc_redist.x64.exe'),
name: 'Download VC++ Redist',
shortDescription: 'Download the latest VC++ Redistributable runtime.',
description:
'The Visual C++ runtime libraries are required to run ComfyUI. You will need to download and install this file.',
button: {
icon: PrimeIcons.EXTERNAL_LINK,
text: 'Download'
}
},
{
id: 'reinstall',
severity: 'danger',
requireConfirm: true,
execute: async () => {
await electron.reinstall()
return true
},
name: 'Reinstall ComfyUI',
shortDescription:
'Deletes the desktop app config and load the welcome screen.',
description:
'Delete the desktop app config, restart the app, and load the installation screen.',
confirmText: 'Delete all saved config and reinstall?',
button: {
icon: PrimeIcons.EXCLAMATION_TRIANGLE,
text: 'Reinstall'
}
},
{
id: 'pythonPackages',
requireConfirm: true,
execute: async () => {
try {
await electron.uv.installRequirements()
return true
} catch (error) {
return false
}
},
name: 'Install python packages',
shortDescription:
'Installs the base python packages required to run ComfyUI.',
errorDescription:
'Python packages that are required to run ComfyUI are not installed.',
description:
'This will install the python packages required to run ComfyUI. This includes torch, torchvision, and other dependencies.',
usesTerminal: true,
isInstallationFix: true,
button: {
icon: PrimeIcons.DOWNLOAD,
text: 'Install'
}
},
{
id: 'uv',
execute: () =>
openUrl('https://docs.astral.sh/uv/getting-started/installation/'),
name: 'uv executable',
shortDescription: 'uv installs and maintains the python environment.',
description:
"This will open the download page for Astral's uv tool. uv is used to install python and manage python packages.",
button: {
icon: 'pi pi-asterisk',
text: 'Download'
}
},
{
id: 'uvCache',
severity: 'danger',
requireConfirm: true,
execute: async () => await electron.uv.clearCache(),
name: 'uv cache',
shortDescription: 'Remove the Astral uv cache of python packages.',
description:
'This will remove the uv cache directory and its contents. All downloaded python packages will need to be downloaded again.',
confirmText: 'Delete uv cache of python packages?',
isInstallationFix: true,
button: {
icon: PrimeIcons.TRASH,
text: 'Clear cache'
}
},
{
id: 'venvDirectory',
severity: 'danger',
requireConfirm: true,
execute: async () => await electron.uv.resetVenv(),
name: 'Reset virtual environment',
shortDescription:
'Remove and recreate the .venv directory. This removes all python packages.',
description:
'The python environment is where ComfyUI installs python and python packages. It is used to run the ComfyUI server.',
confirmText: 'Delete the .venv directory?',
usesTerminal: true,
isInstallationFix: true,
button: {
icon: PrimeIcons.FOLDER,
text: 'Recreate'
}
}
] as const

View File

@@ -548,9 +548,9 @@ export function useCoreCommands(): ComfyCommand[] {
function: () => {
dialogService.showIssueReportDialog({
title: t('g.feedback'),
subtitle: t('issueReport.feedbackTitle'),
panelProps: {
errorType: 'Feedback',
title: t('issueReport.feedbackTitle'),
defaultFields: ['SystemStats', 'Settings']
}
})

View File

@@ -111,7 +111,7 @@
"label": "Open ComfyUI Docs"
},
"Comfy_Help_OpenComfyUIForum": {
"label": "Open Comfy-Org Forum"
"label": "Open ComfyUI Forum"
},
"Comfy_Help_OpenComfyUIIssues": {
"label": "Open ComfyUI Issues"

View File

@@ -77,7 +77,7 @@
},
"issueReport": {
"submitErrorReport": "Submit Error Report (Optional)",
"provideEmail": "Give us your email (Optional)",
"provideEmail": "Give us your email (optional)",
"provideAdditionalDetails": "Provide additional details (optional)",
"stackTrace": "Stack Trace",
"systemStats": "System Stats",
@@ -85,7 +85,11 @@
"notifyResolve": "Notify me when resolved",
"helpFix": "Help Fix This",
"rating": "Rating",
"feedbackTitle": "Help us improve ComfyUI by providing feedback"
"feedbackTitle": "Help us improve ComfyUI by providing feedback",
"validation": {
"maxLength": "Message too long",
"invalidEmail": "Please enter a valid email address"
}
},
"color": {
"default": "Default",
@@ -392,7 +396,7 @@
"About ComfyUI": "About ComfyUI",
"Comfy-Org Discord": "Comfy-Org Discord",
"ComfyUI Docs": "ComfyUI Docs",
"Comfy-Org Forum": "Comfy-Org Forum",
"ComfyUI Forum": "ComfyUI Forum",
"ComfyUI Issues": "ComfyUI Issues",
"Interrupt": "Interrupt",
"Load Default Workflow": "Load Default Workflow",
@@ -688,5 +692,21 @@
"UPSCALE_MODEL": "UPSCALE_MODEL",
"VAE": "VAE",
"WEBCAM": "WEBCAM"
},
"maintenance": {
"allOk": "No issues were detected.",
"status": "Status",
"detected": "Detected",
"refreshing": "Refreshing",
"None": "None",
"OK": "OK",
"Skipped": "Skipped",
"showManual": "Show maintenance tasks",
"confirmTitle": "Are you sure?",
"error": {
"toastTitle": "Task error",
"taskFailed": "Task failed to run.",
"defaultDescription": "An error occurred while running a maintenance task."
}
}
}

View File

@@ -255,7 +255,27 @@
"rating": "Évaluation",
"stackTrace": "Trace de la pile",
"submitErrorReport": "Soumettre un rapport d'erreur (Facultatif)",
"systemStats": "Statistiques du système"
"systemStats": "Statistiques du système",
"validation": {
"invalidEmail": "Veuillez entrer une adresse e-mail valide",
"maxLength": "Message trop long"
}
},
"maintenance": {
"None": "Aucun",
"OK": "OK",
"Skipped": "Ignoré",
"allOk": "Aucun problème détecté.",
"confirmTitle": "Êtes-vous sûr ?",
"detected": "Détecté",
"error": {
"defaultDescription": "Une erreur s'est produite lors de l'exécution d'une tâche de maintenance.",
"taskFailed": "La tâche a échoué.",
"toastTitle": "Erreur de tâche"
},
"refreshing": "Actualisation",
"showManual": "Afficher les tâches de maintenance",
"status": "Statut"
},
"menu": {
"autoQueue": "File d'attente automatique",
@@ -292,8 +312,8 @@
"Close Current Workflow": "Fermer le flux de travail actuel",
"Collapse/Expand Selected Nodes": "Réduire/Étendre les nœuds sélectionnés",
"Comfy-Org Discord": "Discord de Comfy-Org",
"Comfy-Org Forum": "Forum Comfy-Org",
"ComfyUI Docs": "Docs de ComfyUI",
"ComfyUI Forum": "Forum ComfyUI",
"ComfyUI Issues": "Problèmes de ComfyUI",
"Convert selected nodes to group node": "Convertir les nœuds sélectionnés en nœud de groupe",
"Desktop User Guide": "Guide de l'utilisateur de bureau",

View File

@@ -255,7 +255,27 @@
"rating": "評価",
"stackTrace": "スタックトレース",
"submitErrorReport": "エラーレポートを提出する(オプション)",
"systemStats": "システム統計"
"systemStats": "システム統計",
"validation": {
"invalidEmail": "有効なメールアドレスを入力してください",
"maxLength": "メッセージが長すぎます"
}
},
"maintenance": {
"None": "なし",
"OK": "OK",
"Skipped": "スキップされました",
"allOk": "問題は検出されませんでした。",
"confirmTitle": "よろしいですか?",
"detected": "検出されました",
"error": {
"defaultDescription": "メンテナンスタスクの実行中にエラーが発生しました。",
"taskFailed": "タスクの実行に失敗しました。",
"toastTitle": "タスクエラー"
},
"refreshing": "更新中",
"showManual": "メンテナンスタスクを表示",
"status": "ステータス"
},
"menu": {
"autoQueue": "自動キュー",
@@ -292,8 +312,8 @@
"Close Current Workflow": "現在のワークフローを閉じる",
"Collapse/Expand Selected Nodes": "選択したノードの折りたたみ/展開",
"Comfy-Org Discord": "Comfy-Org Discord",
"Comfy-Org Forum": "Comfy-Org フォーラム",
"ComfyUI Docs": "ComfyUIのドキュメント",
"ComfyUI Forum": "ComfyUI フォーラム",
"ComfyUI Issues": "ComfyUIの問題",
"Convert selected nodes to group node": "選択したノードをグループノードに変換",
"Desktop User Guide": "デスクトップユーザーガイド",

View File

@@ -255,7 +255,27 @@
"rating": "평가",
"stackTrace": "스택 추적",
"submitErrorReport": "오류 보고서 제출 (선택 사항)",
"systemStats": "시스템 통계"
"systemStats": "시스템 통계",
"validation": {
"invalidEmail": "유효한 이메일 주소를 입력해 주세요",
"maxLength": "메시지가 너무 깁니다"
}
},
"maintenance": {
"None": "없음",
"OK": "확인",
"Skipped": "건너뜀",
"allOk": "문제가 발견되지 않았습니다.",
"confirmTitle": "확실합니까?",
"detected": "감지됨",
"error": {
"defaultDescription": "유지 보수 작업을 실행하는 동안 오류가 발생했습니다.",
"taskFailed": "작업 실행에 실패했습니다.",
"toastTitle": "작업 오류"
},
"refreshing": "새로 고침 중",
"showManual": "유지 보수 작업 보기",
"status": "상태"
},
"menu": {
"autoQueue": "자동 실행 큐",
@@ -292,8 +312,8 @@
"Close Current Workflow": "현재 워크플로 닫기",
"Collapse/Expand Selected Nodes": "선택한 노드 축소/확장",
"Comfy-Org Discord": "Comfy-Org 디스코드",
"Comfy-Org Forum": "Comfy-Org 포럼",
"ComfyUI Docs": "ComfyUI 문서",
"ComfyUI Forum": "ComfyUI 포럼",
"ComfyUI Issues": "ComfyUI 이슈 페이지",
"Convert selected nodes to group node": "선택한 노드를 그룹 노드로 변환",
"Desktop User Guide": "데스크톱 사용자 가이드",

View File

@@ -255,7 +255,27 @@
"rating": "Рейтинг",
"stackTrace": "Трассировка стека",
"submitErrorReport": "Отправить отчёт об ошибке (необязательно)",
"systemStats": "Статистика системы"
"systemStats": "Статистика системы",
"validation": {
"invalidEmail": "Пожалуйста, введите действительный адрес электронной почты",
"maxLength": "Сообщение слишком длинное"
}
},
"maintenance": {
"None": "Нет",
"OK": "OK",
"Skipped": "Пропущено",
"allOk": "Проблем не обнаружено.",
"confirmTitle": "Вы уверены?",
"detected": "Обнаружено",
"error": {
"defaultDescription": "Произошла ошибка при выполнении задачи по обслуживанию.",
"taskFailed": "Не удалось выполнить задачу.",
"toastTitle": "Ошибка задачи"
},
"refreshing": "Обновление",
"showManual": "Показать задачи по обслуживанию",
"status": "Статус"
},
"menu": {
"autoQueue": "Автоочередь",
@@ -292,8 +312,8 @@
"Close Current Workflow": "Закрыть текущий рабочий процесс",
"Collapse/Expand Selected Nodes": "Свернуть/развернуть выбранные ноды",
"Comfy-Org Discord": "Discord Comfy-Org",
"Comfy-Org Forum": "Форум Comfy-Org",
"ComfyUI Docs": "Документация ComfyUI",
"ComfyUI Forum": "Форум ComfyUI",
"ComfyUI Issues": "Проблемы ComfyUI",
"Convert selected nodes to group node": "Преобразовать выбранные ноды в групповую ноду",
"Desktop User Guide": "Руководство пользователя для настольных ПК",

View File

@@ -255,7 +255,27 @@
"rating": "评分",
"stackTrace": "堆栈跟踪",
"submitErrorReport": "提交错误报告(可选)",
"systemStats": "系统状态"
"systemStats": "系统状态",
"validation": {
"invalidEmail": "请输入有效的电子邮件地址",
"maxLength": "消息过长"
}
},
"maintenance": {
"None": "无",
"OK": "确定",
"Skipped": "跳过",
"allOk": "未检测到任何问题。",
"confirmTitle": "你确定吗?",
"detected": "检测到",
"error": {
"defaultDescription": "运行维护任务时发生错误。",
"taskFailed": "任务运行失败。",
"toastTitle": "任务错误"
},
"refreshing": "刷新中",
"showManual": "显示维护任务",
"status": "状态"
},
"menu": {
"autoQueue": "自动执行",
@@ -292,8 +312,8 @@
"Close Current Workflow": "关闭当前工作流",
"Collapse/Expand Selected Nodes": "折叠/展开选定节点",
"Comfy-Org Discord": "Comfy-Org Discord",
"Comfy-Org Forum": "Comfy-Org 论坛",
"ComfyUI Docs": "ComfyUI 文档",
"ComfyUI Forum": "ComfyUI 论坛",
"ComfyUI Issues": "ComfyUI 问题",
"Convert selected nodes to group node": "将选中节点转换为组节点",
"Desktop User Guide": "桌面端用户指南",

View File

@@ -104,6 +104,12 @@ const router = createRouter({
name: 'DesktopStartView',
component: () => import('@/views/DesktopStartView.vue'),
beforeEnter: guardElectronAccess
},
{
path: 'maintenance',
name: 'MaintenanceView',
component: () => import('@/views/MaintenanceView.vue'),
beforeEnter: guardElectronAccess
}
]
}

View File

@@ -0,0 +1,175 @@
import type { InstallValidation } from '@comfyorg/comfyui-electron-types'
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { DESKTOP_MAINTENANCE_TASKS } from '@/constants/desktopMaintenanceTasks'
import type { MaintenanceTask } from '@/types/desktop/maintenanceTypes'
import { electronAPI } from '@/utils/envUtil'
/** State of a maintenance task, managed by the maintenance task store. */
export type MaintenanceTaskState = 'warning' | 'error' | 'OK' | 'skipped'
// Type not exported by API
type ValidationState = InstallValidation['basePath']
// Add index to API type
type IndexedUpdate = InstallValidation & Record<string, ValidationState>
/** State of a maintenance task, managed by the maintenance task store. */
export class MaintenanceTaskRunner {
constructor(readonly task: MaintenanceTask) {}
private _state?: MaintenanceTaskState
/** The current state of the task. Setter also controls {@link resolved} as a side-effect. */
get state() {
return this._state
}
/** Updates the task state and {@link resolved} status. */
setState(value: MaintenanceTaskState) {
// Mark resolved
if (this._state === 'error' && value === 'OK') this.resolved = true
// Mark unresolved (if previously resolved)
if (value === 'error') this.resolved &&= false
this._state = value
}
/** `true` if the task has been resolved (was `error`, now `OK`). This is a side-effect of the {@link state} setter. */
resolved?: boolean
/** Whether the task state is currently being refreshed. */
refreshing?: boolean
/** Whether the task is currently running. */
executing?: boolean
/** The error message that occurred when the task failed. */
error?: string
update(update: IndexedUpdate) {
const state = update[this.task.id]
this.refreshing = state === undefined
if (state) this.setState(state)
}
finaliseUpdate(update: IndexedUpdate) {
this.refreshing = false
this.setState(update[this.task.id] ?? 'skipped')
}
/** Wraps the execution of a maintenance task, updating state and rethrowing errors. */
async execute(task: MaintenanceTask) {
try {
this.executing = true
const success = await task.execute()
if (!success) return false
this.error = undefined
return true
} catch (error) {
this.error = (error as Error)?.message
throw error
} finally {
this.executing = false
}
}
}
/**
* User-initiated maintenance tasks. Currently only used by the desktop app maintenance view.
*
* Includes running state, task list, and execution / refresh logic.
* @returns The maintenance task store
*/
export const useMaintenanceTaskStore = defineStore('maintenanceTask', () => {
/** Refresh should run for at least this long, even if it completes much faster. Ensures refresh feels like it is doing something. */
const electron = electronAPI()
// Reactive state
const isRefreshing = ref(false)
const isRunningTerminalCommand = computed(() =>
tasks.value
.filter((task) => task.usesTerminal)
.some((task) => getRunner(task)?.executing)
)
const isRunningInstallationFix = computed(() =>
tasks.value
.filter((task) => task.isInstallationFix)
.some((task) => getRunner(task)?.executing)
)
// Task list
const tasks = ref(DESKTOP_MAINTENANCE_TASKS)
const taskStates = ref(
new Map<MaintenanceTask['id'], MaintenanceTaskRunner>(
DESKTOP_MAINTENANCE_TASKS.map((x) => [x.id, new MaintenanceTaskRunner(x)])
)
)
/** True if any tasks are in an error state. */
const anyErrors = computed(() =>
tasks.value.some((task) => getRunner(task).state === 'error')
)
/**
* Returns the matching state object for a task.
* @param task Task to get the matching state object for
* @returns The state object for this task
*/
const getRunner = (task: MaintenanceTask) => taskStates.value.get(task.id)!
/**
* Updates the task list with the latest validation state.
* @param validationUpdate Update details passed in by electron
*/
const processUpdate = (validationUpdate: InstallValidation) => {
const update = validationUpdate as IndexedUpdate
isRefreshing.value = true
// Update each task state
for (const task of tasks.value) {
getRunner(task).update(update)
}
// Final update
if (!update.inProgress && isRefreshing.value) {
isRefreshing.value = false
for (const task of tasks.value) {
getRunner(task).finaliseUpdate(update)
}
}
}
/** Clears the resolved status of tasks (when changing filters) */
const clearResolved = () => {
for (const task of tasks.value) {
getRunner(task).resolved &&= false
}
}
/** @todo Refreshes Electron tasks only. */
const refreshDesktopTasks = async () => {
isRefreshing.value = true
console.log('Refreshing desktop tasks')
await electron.Validation.validateInstallation(processUpdate)
}
const execute = async (task: MaintenanceTask) => {
return getRunner(task).execute(task)
}
return {
tasks,
isRefreshing,
isRunningTerminalCommand,
isRunningInstallationFix,
execute,
getRunner,
processUpdate,
clearResolved,
/** True if any tasks are in an error state. */
anyErrors,
refreshDesktopTasks
}
})

0
src/types/desktop/index.d.ts vendored Normal file
View File

View File

@@ -0,0 +1,50 @@
import type { VueSeverity } from '../primeVueTypes'
interface MaintenanceTaskButton {
/** The text to display on the button. */
text?: string
/** CSS classes used for the button icon, e.g. 'pi pi-external-link' */
icon?: string
}
/** A maintenance task, used by the maintenance page. */
export interface MaintenanceTask {
/** ID string used as i18n key */
id: string
/** The display name of the task, e.g. Git */
name: string
/** Short description of the task. */
shortDescription?: string
/** Description of the task when it is in an error state. */
errorDescription?: string
/** Description of the task when it is in a warning state. */
warningDescription?: string
/** Full description of the task when it is in an OK state. */
description?: string
/** URL to the image to show in card mode. */
headerImg?: string
/** The button to display on the task card / list item. */
button?: MaintenanceTaskButton
/** Whether to show a confirmation dialog before running the task. */
requireConfirm?: boolean
/** The text to display in the confirmation dialog. */
confirmText?: string
/** Called by onClick to run the actual task. */
execute: (args?: unknown[]) => boolean | Promise<boolean>
/** Show the button with `severity="danger"` */
severity?: VueSeverity
/** Whether this task should display the terminal window when run. */
usesTerminal?: boolean
/** If `true`, successful completion of this task will refresh install validation and automatically continue if successful. */
isInstallationFix?: boolean
}
/** The filter options for the maintenance task list. */
export interface MaintenanceFilter {
/** CSS classes used for the filter button icon, e.g. 'pi pi-cross' */
icon: string
/** The text to display on the filter button. */
value: string
/** The tasks to display when this filter is selected. */
tasks: ReadonlyArray<MaintenanceTask>
}

View File

@@ -1,3 +1,5 @@
import { z } from 'zod'
export type DefaultField = 'Workflow' | 'Logs' | 'SystemStats' | 'Settings'
export interface ReportField {
@@ -14,7 +16,7 @@ export interface ReportField {
/**
* The data associated with this field, sent as part of the report.
*/
data: Record<string, unknown>
getData: () => unknown
/**
* Indicates whether the field requires explicit opt-in from the user
@@ -49,3 +51,15 @@ export interface IssueReportPanelProps {
*/
title?: string
}
const checkboxField = z.boolean().optional()
export const issueReportSchema = z
.object({
contactInfo: z.string().email().max(320).optional().or(z.literal('')),
details: z.string().max(5_000).optional()
})
.catchall(checkboxField)
.refine((data) => Object.values(data).some((value) => value), {
path: ['details']
})
export type IssueReportFormData = z.infer<typeof issueReportSchema>

View File

@@ -0,0 +1,10 @@
/** Button, Tag, etc severity type is 'string' instead of this list. */
export type VueSeverity =
| 'primary'
| 'secondary'
| 'success'
| 'info'
| 'warn'
| 'help'
| 'danger'
| 'contrast'

29
src/utils/refUtil.ts Normal file
View File

@@ -0,0 +1,29 @@
import { useTimeout } from '@vueuse/core'
import { type Ref, computed, ref, watch } from 'vue'
/**
* Vue boolean ref (writable computed) with one difference: when set to `true` it stays that way for at least {@link minDuration}.
* If set to `false` before {@link minDuration} has passed, it uses a timer to delay the change.
* @param value The default value to set on this ref
* @param minDuration The minimum time that this ref must be `true` for
* @returns A custom boolean vue ref with a minimum activation time
*/
export function useMinLoadingDurationRef(
value: Ref<boolean>,
minDuration = 250
) {
const current = ref(value.value)
const { ready, start } = useTimeout(minDuration, {
controls: true,
immediate: false
})
watch(value, (newValue) => {
if (newValue && !current.value) start()
current.value = newValue
})
return computed(() => current.value || !ready.value)
}

View File

@@ -0,0 +1,221 @@
<template>
<BaseViewTemplate dark>
<div
class="min-w-full min-h-full font-sans w-screen h-screen grid justify-around text-neutral-300 bg-neutral-900 dark-theme pointer-events-auto overflow-y-auto"
>
<div class="max-w-screen-sm w-screen m-8 relative">
<!-- Header -->
<h1 class="backspan pi-wrench text-4xl font-bold">Maintenance</h1>
<!-- Toolbar -->
<div class="w-full flex flex-wrap gap-4 items-center">
<span class="grow">
Status: <StatusTag :refreshing="isRefreshing" :error="anyErrors" />
</span>
<div class="flex gap-4 items-center">
<SelectButton
v-model="displayAsList"
:options="[PrimeIcons.LIST, PrimeIcons.TH_LARGE]"
:allow-empty="false"
>
<template #option="opts"><i :class="opts.option" /></template>
</SelectButton>
<SelectButton
v-model="filter"
:options="filterOptions"
:allow-empty="false"
optionLabel="value"
dataKey="value"
area-labelledby="custom"
@change="clearResolved"
>
<template #option="opts">
<i :class="opts.option.icon"></i>
<span class="max-sm:hidden">{{ opts.option.value }}</span>
</template>
</SelectButton>
<RefreshButton
v-model="isRefreshing"
severity="secondary"
@refresh="refreshDesktopTasks"
/>
</div>
</div>
<!-- Tasks -->
<TaskListPanel
class="border-neutral-700 border-solid border-x-0 border-y"
:filter
:displayAsList
:isRefreshing
/>
<!-- Actions -->
<div class="flex justify-between gap-4 flex-row">
<Button
label="Console Logs"
icon="pi pi-desktop"
icon-pos="left"
severity="secondary"
@click="toggleConsoleDrawer"
/>
<Button
label="Continue"
icon="pi pi-arrow-right"
icon-pos="left"
:severity="anyErrors ? 'secondary' : 'primary'"
@click="() => completeValidation()"
:loading="isRefreshing"
/>
</div>
</div>
<Drawer
v-model:visible="terminalVisible"
header="Terminal"
position="bottom"
style="height: max(50vh, 34rem)"
>
<BaseTerminal @created="terminalCreated" />
</Drawer>
<Toast />
</div>
</BaseViewTemplate>
</template>
<script setup lang="ts">
import { PrimeIcons } from '@primevue/core/api'
import Button from 'primevue/button'
import Drawer from 'primevue/drawer'
import SelectButton from 'primevue/selectbutton'
import Toast from 'primevue/toast'
import { useToast } from 'primevue/usetoast'
import { Ref, computed, onMounted, onUnmounted, ref } from 'vue'
import { watch } from 'vue'
import BaseTerminal from '@/components/bottomPanel/tabs/terminal/BaseTerminal.vue'
import RefreshButton from '@/components/common/RefreshButton.vue'
import StatusTag from '@/components/maintenance/StatusTag.vue'
import TaskListPanel from '@/components/maintenance/TaskListPanel.vue'
import type { useTerminal } from '@/hooks/bottomPanelTabs/useTerminal'
import { useMaintenanceTaskStore } from '@/stores/maintenanceTaskStore'
import { MaintenanceFilter } from '@/types/desktop/maintenanceTypes'
import { electronAPI } from '@/utils/envUtil'
import { useMinLoadingDurationRef } from '@/utils/refUtil'
import BaseViewTemplate from './templates/BaseViewTemplate.vue'
const electron = electronAPI()
const toast = useToast()
const taskStore = useMaintenanceTaskStore()
const { clearResolved, processUpdate, refreshDesktopTasks } = taskStore
const terminalVisible = ref(false)
// Use a minimum run time to ensure tasks "feel" like they have run
const reactiveIsRefreshing = computed(() => taskStore.isRefreshing)
/** `true` when waiting on tasks to complete. */
const isRefreshing = useMinLoadingDurationRef(reactiveIsRefreshing, 250)
/** True if any tasks are in an error state. */
const anyErrors = computed(() => taskStore.anyErrors)
/** Whether to display tasks as a list or cards. */
const displayAsList = ref(PrimeIcons.TH_LARGE)
const errorFilter = computed(() =>
taskStore.tasks.filter((x) => {
const { state, resolved } = taskStore.getRunner(x)
return state === 'error' || resolved
})
)
const filterOptions = ref([
{ icon: PrimeIcons.FILTER_FILL, value: 'All', tasks: taskStore.tasks },
{ icon: PrimeIcons.EXCLAMATION_TRIANGLE, value: 'Errors', tasks: errorFilter }
])
/** Filter binding; can be set to show all tasks, or only errors. */
const filter = ref<MaintenanceFilter>(filterOptions.value[1])
/** If valid, leave the validation window. */
const completeValidation = async (alertOnFail = true) => {
const isValid = await electron.Validation.complete()
if (alertOnFail && !isValid) {
toast.add({
severity: 'error',
summary: 'Error',
detail: 'Unable to continue - errors remain',
life: 5_000
})
}
}
const terminalCreated = (
{ terminal, useAutoSize }: ReturnType<typeof useTerminal>,
root: Ref<HTMLElement>
) => {
useAutoSize({ root, autoRows: true, autoCols: true })
electron.onLogMessage((message: string) => {
terminal.write(message)
})
terminal.options.cursorBlink = false
terminal.options.cursorStyle = 'bar'
terminal.options.cursorInactiveStyle = 'bar'
terminal.options.disableStdin = true
}
const toggleConsoleDrawer = () => {
terminalVisible.value = !terminalVisible.value
}
// Show terminal when in use
watch(
() => taskStore.isRunningTerminalCommand,
(value) => {
terminalVisible.value = value
}
)
// If we're running a fix that may resolve all issues, auto-recheck and continue if everything is OK
watch(
() => taskStore.isRunningInstallationFix,
(value, oldValue) => {
if (!value && oldValue) completeValidation(false)
}
)
onMounted(async () => {
electron.Validation.onUpdate(processUpdate)
const update = await electron.Validation.getStatus()
processUpdate(update)
})
onUnmounted(() => electron.Validation.dispose())
</script>
<style scoped>
:deep(.p-tag) {
--p-tag-gap: 0.375rem;
}
.backspan::before {
@apply m-0 absolute text-muted;
font-family: 'primeicons';
top: -2rem;
right: -2rem;
speak: none;
font-style: normal;
font-weight: normal;
font-variant: normal;
text-transform: none;
line-height: 1;
display: inline-block;
-webkit-font-smoothing: antialiased;
opacity: 0.02;
font-size: min(14rem, 90vw);
z-index: 0;
}
</style>