Compare commits

...

11 Commits

Author SHA1 Message Date
Yoan.liu
c0386b153e
Merge pull request #430 from fudiwei/feat/new-workflow
feat: enhance workflow
2025-01-23 09:37:43 +08:00
Fu Diwei
5cabceb08e feat(ui): improve workflow elements scroll area 2025-01-23 03:02:59 +08:00
Fu Diwei
b67049f9aa refactor: clean code 2025-01-22 22:11:04 +08:00
Fu Diwei
7a2fc5e2fd Merge branch 'next' into feat/new-workflow 2025-01-22 20:21:32 +08:00
Fu Diwei
0f945881a1 feat: cancel workflow run 2025-01-22 04:13:16 +08:00
Fu Diwei
bee4ba10cb feat: generate run record at the beginning of the workflow execution 2025-01-22 03:48:58 +08:00
Fu Diwei
7e0f575e0a feat(ui): jump to workflow detail page in dashboard 2025-01-22 03:44:54 +08:00
Fu Diwei
79c1da6d14 feat: a new status for canceled workflow run 2025-01-22 03:13:31 +08:00
Fu Diwei
8dc86209df feat: support removing workflow runs 2025-01-21 23:11:48 +08:00
Fu Diwei
c61b2d2d3f fix: couldn't list expire soon certificates 2025-01-21 21:41:25 +08:00
Fu Diwei
d5568608f5 refactor: clean code 2025-01-21 00:42:28 +08:00
53 changed files with 910 additions and 538 deletions

28
go.mod
View File

@ -18,14 +18,14 @@ require (
github.com/baidubce/bce-sdk-go v0.9.214
github.com/byteplus-sdk/byteplus-sdk-golang v1.0.40
github.com/go-acme/lego/v4 v4.21.0
github.com/go-resty/resty/v2 v2.16.3
github.com/go-resty/resty/v2 v2.16.4
github.com/go-viper/mapstructure/v2 v2.2.1
github.com/huaweicloud/huaweicloud-sdk-go-v3 v0.1.132
github.com/nikoksr/notify v1.3.0
github.com/pavlo-v-chernykh/keystore-go/v4 v4.5.0
github.com/pkg/sftp v1.13.7
github.com/pocketbase/dbx v1.11.0
github.com/pocketbase/pocketbase v0.24.3
github.com/pocketbase/pocketbase v0.24.4
github.com/qiniu/go-sdk/v7 v7.25.2
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/cdn v1.0.1084
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/clb v1.0.1084
@ -73,7 +73,6 @@ require (
github.com/google/gnostic-models v0.6.9 // indirect
github.com/google/go-cmp v0.6.0 // indirect
github.com/google/gofuzz v1.2.0 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/kylelemons/godebug v1.1.0 // indirect
github.com/mailru/easyjson v0.9.0 // indirect
@ -92,9 +91,6 @@ require (
k8s.io/klog/v2 v2.130.1 // indirect
k8s.io/kube-openapi v0.0.0-20241212222426-2c72e554b1e7 // indirect
k8s.io/utils v0.0.0-20241210054802-24370beab758 // indirect
modernc.org/gc/v3 v3.0.0-20250105121824-520be1a3aee6 // indirect
modernc.org/strutil v1.2.1 // indirect
modernc.org/token v1.1.0 // indirect
sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect
sigs.k8s.io/structured-merge-diff/v4 v4.5.0 // indirect
sigs.k8s.io/yaml v1.4.0 // indirect
@ -115,22 +111,22 @@ require (
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
github.com/aws/aws-sdk-go-v2 v1.33.0 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.7 // indirect
github.com/aws/aws-sdk-go-v2/config v1.29.0 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.17.53 // indirect
github.com/aws/aws-sdk-go-v2/config v1.29.1 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.17.54 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.24 // indirect
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.51 // indirect
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.52 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.28 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.28 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.28 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.1 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.5.1 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.5.2 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.9 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.9 // indirect
github.com/aws/aws-sdk-go-v2/service/s3 v1.73.1 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.24.10 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.9 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.33.8 // indirect
github.com/aws/aws-sdk-go-v2/service/s3 v1.73.2 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.24.11 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.10 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.33.9 // indirect
github.com/aws/smithy-go v1.22.1 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/clbanning/mxj/v2 v2.7.0 // indirect
@ -192,8 +188,8 @@ require (
google.golang.org/protobuf v1.36.3 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.55.3 // indirect
modernc.org/libc v1.61.9 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.8.2 // indirect
modernc.org/sqlite v1.34.4 // indirect
modernc.org/sqlite v1.34.5 // indirect
)

72
go.sum
View File

@ -214,14 +214,14 @@ github.com/aws/aws-sdk-go-v2 v1.33.0 h1:Evgm4DI9imD81V0WwD+TN4DCwjUMdc94TrduMLbg
github.com/aws/aws-sdk-go-v2 v1.33.0/go.mod h1:P5WJBrYqqbWVaOxgH0X/FYYD47/nooaPOZPlQdmiN2U=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.7 h1:lL7IfaFzngfx0ZwUGOZdsFFnQ5uLvR0hWqqhyE7Q9M8=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.7/go.mod h1:QraP0UcVlQJsmHfioCrveWOC1nbiWUl3ej08h4mXWoc=
github.com/aws/aws-sdk-go-v2/config v1.29.0 h1:Vk/u4jof33or1qAQLdofpjKV7mQQT7DcUpnYx8kdmxY=
github.com/aws/aws-sdk-go-v2/config v1.29.0/go.mod h1:iXAZK3Gxvpq3tA+B9WaDYpZis7M8KFgdrDPMmHrgbJM=
github.com/aws/aws-sdk-go-v2/credentials v1.17.53 h1:lwrVhiEDW5yXsuVKlFVUnR2R50zt2DklhOyeLETqDuE=
github.com/aws/aws-sdk-go-v2/credentials v1.17.53/go.mod h1:CkqM1bIw/xjEpBMhBnvqUXYZbpCFuj6dnCAyDk2AtAY=
github.com/aws/aws-sdk-go-v2/config v1.29.1 h1:JZhGawAyZ/EuJeBtbQYnaoftczcb2drR2Iq36Wgz4sQ=
github.com/aws/aws-sdk-go-v2/config v1.29.1/go.mod h1:7bR2YD5euaxBhzt2y/oDkt3uNRb6tjFp98GlTFueRwk=
github.com/aws/aws-sdk-go-v2/credentials v1.17.54 h1:4UmqeOqJPvdvASZWrKlhzpRahAulBfyTJQUaYy4+hEI=
github.com/aws/aws-sdk-go-v2/credentials v1.17.54/go.mod h1:RTdfo0P0hbbTxIhmQrOsC/PquBZGabEPnCaxxKRPSnI=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.24 h1:5grmdTdMsovn9kPZPI23Hhvp0ZyNm5cRO+IZFIYiAfw=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.24/go.mod h1:zqi7TVKTswH3Ozq28PkmBmgzG1tona7mo9G2IJg4Cis=
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.51 h1:Q0FNHs6JTGuoBWNQycD5LRSf+/WVHWEl+FwJ0tEDZUE=
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.51/go.mod h1:B9sW5/AD5bStKdTyUdz1xWRKOwnyUwJ4eJ4olQBtZo0=
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.52 h1:6kI83R98XOnnyzHv9g9KTYXFawMyeQq8NeEERWMAwJk=
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.52/go.mod h1:Juj7unpf3CIrWpEyJZhRJ6rJl9IYX7Hd8HOlwaZq/LE=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.28 h1:igORFSiH3bfq4lxKFkTSYDhJEUCYo6C8VKiWJjYwQuQ=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.28/go.mod h1:3So8EA/aAYm36L7XIvCVwLa0s5N0P7o2b1oqnx/2R4g=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.28 h1:1mOW9zAUMhTSrMDssEHS/ajx8JcAj/IcftzcmNlmVLI=
@ -233,22 +233,22 @@ github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.28/go.mod h1:pyaOYEdp1MJWgtXLy6q8
github.com/aws/aws-sdk-go-v2/service/cloudwatch v1.8.1/go.mod h1:CM+19rL1+4dFWnOQKwDc7H1KwXTz+h61oUSHyhV0b3o=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.1 h1:iXtILhvDxB6kPvEXgsDhGaZCSC6LQET5ZHSdJozeI0Y=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.1/go.mod h1:9nu0fVANtYiAePIBh2/pFUSwtJ402hLnp854CNoDOeE=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.5.1 h1:mJ9FRktB8v1Ihpqwfk0AWvYEd0FgQtLsshc2Qb2TVc8=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.5.1/go.mod h1:dIW8puxSbYLSPv/ju0d9A3CpwXdtqvJtYKDMVmPLOWE=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.5.2 h1:e6um6+DWYQP1XCa+E9YVtG/9v1qk5lyAOelMOVwSyO8=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.5.2/go.mod h1:dIW8puxSbYLSPv/ju0d9A3CpwXdtqvJtYKDMVmPLOWE=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.9 h1:TQmKDyETFGiXVhZfQ/I0cCFziqqX58pi4tKJGYGFSz0=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.9/go.mod h1:HVLPK2iHQBUx7HfZeOQSEu3v2ubZaAY2YPbAm5/WUyY=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.9 h1:2aInXbh02XsbO0KobPGMNXyv2QP73VDKsWPNJARj/+4=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.9/go.mod h1:dgXS1i+HgWnYkPXqNoPIPKeUsUUYHaUbThC90aDnNiE=
github.com/aws/aws-sdk-go-v2/service/route53 v1.48.1 h1:njgAP7Rtt4DGdTGFPhJ4gaZXCD1CDj/SZDa5W4ZgSTs=
github.com/aws/aws-sdk-go-v2/service/route53 v1.48.1/go.mod h1:TN4PcCL0lvqmYcv+AV8iZFC4Sd0FM06QDaoBXrFEftU=
github.com/aws/aws-sdk-go-v2/service/s3 v1.73.1 h1:OzmyfYGiMCOIAq5pa0KWcaZoA9F8FqajOJevh+hhFdY=
github.com/aws/aws-sdk-go-v2/service/s3 v1.73.1/go.mod h1:K+0a0kWDHAUXBH8GvYGS3cQRwIuRjO9bMWUz6vpNCaU=
github.com/aws/aws-sdk-go-v2/service/sso v1.24.10 h1:DyZUj3xSw3FR3TXSwDhPhuZkkT14QHBiacdbUVcD0Dg=
github.com/aws/aws-sdk-go-v2/service/sso v1.24.10/go.mod h1:Ro744S4fKiCCuZECXgOi760TiYylUM8ZBf6OGiZzJtY=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.9 h1:I1TsPEs34vbpOnR81GIcAq4/3Ud+jRHVGwx6qLQUHLs=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.9/go.mod h1:Fzsj6lZEb8AkTE5S68OhcbBqeWPsR8RnGuKPr8Todl8=
github.com/aws/aws-sdk-go-v2/service/sts v1.33.8 h1:pqEJQtlKWvnv3B6VRt60ZmsHy3SotlEBvfUBPB1KVcM=
github.com/aws/aws-sdk-go-v2/service/sts v1.33.8/go.mod h1:f6vjfZER1M17Fokn0IzssOTMT2N8ZSq+7jnNF0tArvw=
github.com/aws/aws-sdk-go-v2/service/s3 v1.73.2 h1:F3h8VYq9ZLBXYurmwrT8W0SPhgCcU0q+0WZJfT1dFt0=
github.com/aws/aws-sdk-go-v2/service/s3 v1.73.2/go.mod h1:jGJ/v7FIi7Ys9t54tmEFnrxuaWeJLpwNgKp2DXAVhOU=
github.com/aws/aws-sdk-go-v2/service/sso v1.24.11 h1:kuIyu4fTT38Kj7YCC7ouNbVZSSpqkZ+LzIfhCr6Dg+I=
github.com/aws/aws-sdk-go-v2/service/sso v1.24.11/go.mod h1:Ro744S4fKiCCuZECXgOi760TiYylUM8ZBf6OGiZzJtY=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.10 h1:l+dgv/64iVlQ3WsBbnn+JSbkj01jIi+SM0wYsj3y/hY=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.10/go.mod h1:Fzsj6lZEb8AkTE5S68OhcbBqeWPsR8RnGuKPr8Todl8=
github.com/aws/aws-sdk-go-v2/service/sts v1.33.9 h1:BRVDbewN6VZcwr+FBOszDKvYeXY1kJ+GGMCcpghlw0U=
github.com/aws/aws-sdk-go-v2/service/sts v1.33.9/go.mod h1:f6vjfZER1M17Fokn0IzssOTMT2N8ZSq+7jnNF0tArvw=
github.com/aws/smithy-go v1.8.0/go.mod h1:SObp3lf9smib00L/v3U2eAKG8FyQ7iLrJnQiAmR5n+E=
github.com/aws/smithy-go v1.22.1 h1:/HPHZQ0g7f4eUeK6HKglFz8uwVfZKgoI25rb/J+dnro=
github.com/aws/smithy-go v1.22.1/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg=
@ -393,8 +393,8 @@ github.com/go-playground/locales v0.14.0/go.mod h1:sawfccIbzZTqEDETgFXqTho0QybSa
github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA=
github.com/go-playground/universal-translator v0.18.0/go.mod h1:UvRDBj+xPUEGrFYl+lu/H90nyDXpg0fqeB/AQUGNTVA=
github.com/go-playground/validator/v10 v10.7.0/go.mod h1:xm76BBt941f7yWdGnI2DVPFFg1UK3YY04qifoXU3lOk=
github.com/go-resty/resty/v2 v2.16.3 h1:zacNT7lt4b8M/io2Ahj6yPypL7bqx9n1iprfQuodV+E=
github.com/go-resty/resty/v2 v2.16.3/go.mod h1:hkJtXbA2iKHzJheXYvQ8snQES5ZLGKMwQ07xAwp/fiA=
github.com/go-resty/resty/v2 v2.16.4 h1:81IjtszQKwbz7dot4LLYGwhJNUsNwECD2O7nru5q60E=
github.com/go-resty/resty/v2 v2.16.4/go.mod h1:hkJtXbA2iKHzJheXYvQ8snQES5ZLGKMwQ07xAwp/fiA=
github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
@ -542,8 +542,6 @@ github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/b
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64=
github.com/hashicorp/mdns v1.0.1/go.mod h1:4gW7WsVCke5TE7EPeYliwHlRUyBtfCwuFwuMg2DmyNY=
github.com/hashicorp/memberlist v0.2.2/go.mod h1:MS2lj3INKhZjWNqd3N0m3J+Jxf3DAOnAH9VT3Sh9MUE=
@ -723,8 +721,8 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pocketbase/dbx v1.11.0 h1:LpZezioMfT3K4tLrqA55wWFw1EtH1pM4tzSVa7kgszU=
github.com/pocketbase/dbx v1.11.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
github.com/pocketbase/pocketbase v0.24.3 h1:WUrzW11ijCySlDsVRHon3HXdtiratWv+ODK26/k6cI8=
github.com/pocketbase/pocketbase v0.24.3/go.mod h1:EfXV/8RUY76jA6g1RPNHjOuW7wTd2bz0QlvAI/RU8YY=
github.com/pocketbase/pocketbase v0.24.4 h1:kw/c23HccoxMV/19U9QlDcvNJgQ66vlUrxGQDZicWKM=
github.com/pocketbase/pocketbase v0.24.4/go.mod h1:EfXV/8RUY76jA6g1RPNHjOuW7wTd2bz0QlvAI/RU8YY=
github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=
github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSgv7Sy7s/s=
github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
@ -1385,29 +1383,27 @@ k8s.io/kube-openapi v0.0.0-20241212222426-2c72e554b1e7 h1:hcha5B1kVACrLujCKLbr8X
k8s.io/kube-openapi v0.0.0-20241212222426-2c72e554b1e7/go.mod h1:GewRfANuJ70iYzvn+i4lezLDAFzvjxZYK1gn1lWcfas=
k8s.io/utils v0.0.0-20241210054802-24370beab758 h1:sdbE21q2nlQtFh65saZY+rRM6x6aJJI8IUa1AmH/qa0=
k8s.io/utils v0.0.0-20241210054802-24370beab758/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=
modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=
modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y=
modernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s=
modernc.org/cc/v4 v4.24.4 h1:TFkx1s6dCkQpd6dKurBNmpo+G8Zl4Sq/ztJ+2+DEsh0=
modernc.org/cc/v4 v4.24.4/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.23.13 h1:PFiaemQwE/jdwi8XEHyEV+qYWoIuikLP3T4rvDeJb00=
modernc.org/ccgo/v4 v4.23.13/go.mod h1:vdN4h2WR5aEoNondUx26K7G8X+nuBscYnAEWSRmN2/0=
modernc.org/fileutil v1.0.0/go.mod h1:JHsWpkrk/CnVV1H/eGlFf85BEpfkrp56ro8nojIq9Q8=
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw=
modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
modernc.org/gc/v3 v3.0.0-20250105121824-520be1a3aee6 h1:JoKwHjIFumiKrjMbp1cNbC5E9UyCgA/ZcID0xOWQ2N8=
modernc.org/gc/v3 v3.0.0-20250105121824-520be1a3aee6/go.mod h1:LG5UO1Ran4OO0JRKz2oNiXhR5nNrgz0PzH7UKhz0aMU=
modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U=
modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w=
modernc.org/gc/v2 v2.6.1 h1:+Qf6xdG8l7B27TQ8D8lw/iFMUj1RXRBOuMUWziJOsk8=
modernc.org/gc/v2 v2.6.1/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/libc v1.61.9 h1:PLSBXVkifXGELtJ5BOnBUyAHr7lsatNwFU/RRo4kfJM=
modernc.org/libc v1.61.9/go.mod h1:61xrnzk/aR8gr5bR7Uj/lLFLuXu2/zMpIjcry63Eumk=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.8.2 h1:cL9L4bcoAObu4NkxOlKWBWtNHIsnnACGF/TbqQ6sbcI=
modernc.org/memory v1.8.2/go.mod h1:ZbjSvMO5NQ1A2i3bWeDiVMxIorXwdClKE/0SZ+BMotU=
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=
modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss=
modernc.org/sqlite v1.34.4 h1:sjdARozcL5KJBvYQvLlZEmctRgW9xqIZc2ncN7PU0P8=
modernc.org/sqlite v1.34.4/go.mod h1:3QQFCG2SEMtc2nv+Wq4cQCH7Hjcg+p/RMlS1XK+zwbk=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.34.5 h1:Bb6SR13/fjp15jt70CL4f18JIN7p7dnMExd+UFnF15g=
modernc.org/sqlite v1.34.5/go.mod h1:YLuNmX9NKs8wRNK2ko1LW1NGYcc9FkBO69JOt1AR9JE=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=

View File

@ -82,9 +82,9 @@ type acmeAccountRepository interface {
var registerGroup singleflight.Group
func registerAcmeUser(client *lego.Client, sslProviderConfig *acmeSSLProviderConfig, user *acmeUser) (*registration.Resource, error) {
func registerAcmeUserWithSingleFlight(client *lego.Client, sslProviderConfig *acmeSSLProviderConfig, user *acmeUser) (*registration.Resource, error) {
resp, err, _ := registerGroup.Do(fmt.Sprintf("register_acme_user_%s_%s", sslProviderConfig.Provider, user.GetEmail()), func() (interface{}, error) {
return register(client, sslProviderConfig, user)
return registerAcmeUser(client, sslProviderConfig, user)
})
if err != nil {
@ -94,7 +94,7 @@ func registerAcmeUser(client *lego.Client, sslProviderConfig *acmeSSLProviderCon
return resp.(*registration.Resource), nil
}
func register(client *lego.Client, sslProviderConfig *acmeSSLProviderConfig, user *acmeUser) (*registration.Resource, error) {
func registerAcmeUser(client *lego.Client, sslProviderConfig *acmeSSLProviderConfig, user *acmeUser) (*registration.Resource, error) {
var reg *registration.Resource
var err error
switch sslProviderConfig.Provider {
@ -123,7 +123,6 @@ func register(client *lego.Client, sslProviderConfig *acmeSSLProviderConfig, use
}
repo := repository.NewAcmeAccountRepository()
resp, err := repo.GetByCAAndEmail(sslProviderConfig.Provider, user.GetEmail())
if err == nil {
user.privkey = resp.Key

View File

@ -159,7 +159,7 @@ func apply(challengeProvider challenge.Provider, options *applicantOptions) (*Ap
// New users need to register first
if !acmeUser.hasRegistration() {
reg, err := registerAcmeUser(client, sslProviderConfig, acmeUser)
reg, err := registerAcmeUserWithSingleFlight(client, sslProviderConfig, acmeUser)
if err != nil {
return nil, fmt.Errorf("failed to register: %w", err)
}

View File

@ -25,8 +25,8 @@ const (
)
type certificateRepository interface {
GetById(ctx context.Context, id string) (*domain.Certificate, error)
ListExpireSoon(ctx context.Context) ([]*domain.Certificate, error)
GetById(ctx context.Context, id string) (*domain.Certificate, error)
}
type CertificateService struct {

View File

@ -5,11 +5,6 @@ type CertificateArchiveFileReq struct {
Format string `json:"format"`
}
type CertificateArchiveFileResp struct {
Certificate string `json:"certificate"`
PrivateKey string `json:"privateKey"`
}
type CertificateValidateCertificateReq struct {
Certificate string `json:"certificate"`
}

View File

@ -2,7 +2,12 @@
import "github.com/usual2970/certimate/internal/domain"
type WorkflowRunReq struct {
type WorkflowStartRunReq struct {
WorkflowId string `json:"-"`
Trigger domain.WorkflowTriggerType `json:"trigger"`
}
type WorkflowCancelRunReq struct {
WorkflowId string `json:"-"`
RunId string `json:"-"`
}

View File

@ -69,11 +69,11 @@ type WorkflowNodeConfigForApply struct {
ProviderConfig map[string]any `json:"providerConfig"` // DNS 提供商额外配置
KeyAlgorithm string `json:"keyAlgorithm"` // 密钥算法
Nameservers string `json:"nameservers"` // DNS 服务器列表,以半角逗号分隔
DnsPropagationTimeout int32 `json:"dnsPropagationTimeout"` // DNS 传播超时时间(默认取决于提供商
DnsTTL int32 `json:"dnsTTL"` // DNS TTL默认取决于提供商
DisableFollowCNAME bool `json:"disableFollowCNAME"` // 是否禁用 CNAME 跟随
DisableARI bool `json:"disableARI"` // 是否禁用 ARI
SkipBeforeExpiryDays int32 `json:"skipBeforeExpiryDays"` // 证书到期前多少天前跳过续期(默认值:30
DnsPropagationTimeout int32 `json:"dnsPropagationTimeout"` // DNS 传播超时时间(零值取决于提供商的默认值
DnsTTL int32 `json:"dnsTTL"` // DNS TTL零值取决于提供商的默认值
DisableFollowCNAME bool `json:"disableFollowCNAME"` // 是否关闭 CNAME 跟随
DisableARI bool `json:"disableARI"` // 是否关闭 ARI
SkipBeforeExpiryDays int32 `json:"skipBeforeExpiryDays"` // 证书到期前多少天前跳过续期(零值将使用默认值 30
}
type WorkflowNodeConfigForUpload struct {

View File

@ -1,6 +1,9 @@
package domain
import "time"
import (
"strings"
"time"
)
const CollectionNameWorkflowRun = "workflow_run"
@ -22,6 +25,7 @@ const (
WorkflowRunStatusTypeRunning WorkflowRunStatusType = "running"
WorkflowRunStatusTypeSucceeded WorkflowRunStatusType = "succeeded"
WorkflowRunStatusTypeFailed WorkflowRunStatusType = "failed"
WorkflowRunStatusTypeCanceled WorkflowRunStatusType = "canceled"
)
type WorkflowRunLog struct {
@ -40,12 +44,13 @@ type WorkflowRunLogOutput struct {
type WorkflowRunLogs []WorkflowRunLog
func (r WorkflowRunLogs) FirstError() string {
func (r WorkflowRunLogs) ErrorString() string {
var builder strings.Builder
for _, log := range r {
if log.Error != "" {
return log.Error
builder.WriteString(log.Error)
builder.WriteString("\n")
}
}
return ""
return builder.String()
}

View File

@ -5,6 +5,8 @@ import (
"fmt"
"reflect"
"strings"
"github.com/usual2970/certimate/internal/pkg/utils/types"
)
// 表示默认的日志记录器类型。
@ -21,7 +23,7 @@ func (l *DefaultLogger) Logt(tag string, data ...any) {
temp[0] = tag
for i, v := range data {
s := ""
if v == nil {
if types.IsNil(v) {
s = "<nil>"
} else {
switch reflect.ValueOf(v).Kind() {

View File

@ -1,12 +1,14 @@
package certs
import (
"crypto"
"crypto/ecdsa"
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"errors"
"github.com/go-acme/lego/v4/certcrypto"
xerrors "github.com/pkg/errors"
)
@ -34,6 +36,19 @@ func ParseCertificateFromPEM(certPem string) (cert *x509.Certificate, err error)
return cert, nil
}
// 从 PEM 编码的私钥字符串解析并返回一个 crypto.PrivateKey 对象。
//
// 入参:
// - privkeyPem: 私钥 PEM 内容。
//
// 出参:
// - privkey: crypto.PrivateKey 对象,可能是 rsa.PrivateKey、ecdsa.PrivateKey 或 ed25519.PrivateKey。
// - err: 错误。
func ParsePrivateKeyFromPEM(privkeyPem string) (privkey crypto.PrivateKey, err error) {
pemData := []byte(privkeyPem)
return certcrypto.ParsePEMPrivateKey(pemData)
}
// 从 PEM 编码的私钥字符串解析并返回一个 ecdsa.PrivateKey 对象。
//
// 入参:

View File

@ -2,8 +2,6 @@
import (
"bytes"
"crypto/ecdsa"
"crypto/rsa"
"encoding/pem"
"errors"
"time"
@ -28,23 +26,9 @@ func TransformCertificateFromPEMToPFX(certPem string, privkeyPem string, pfxPass
return nil, err
}
var privkey interface{}
switch cert.PublicKey.(type) {
case *rsa.PublicKey:
{
privkey, err = ParsePKCS1PrivateKeyFromPEM(privkeyPem)
if err != nil {
return nil, err
}
}
case *ecdsa.PublicKey:
{
privkey, err = ParseECPrivateKeyFromPEM(privkeyPem)
if err != nil {
return nil, err
}
}
privkey, err := ParsePrivateKeyFromPEM(privkeyPem)
if err != nil {
return nil, err
}
pfxData, err := pkcs12.LegacyRC2.Encode(privkey, cert, nil, pfxPassword)

View File

@ -3,6 +3,7 @@
import "reflect"
// 判断对象是否为 nil。
// 与直接使用 `obj == nil` 不同,该函数会正确判断接口类型对象的真实值是否为空。
//
// 入参:
// - value待判断的对象。

View File

@ -19,11 +19,11 @@ func NewCertificateRepository() *CertificateRepository {
}
func (r *CertificateRepository) ListExpireSoon(ctx context.Context) ([]*domain.Certificate, error) {
records, err := app.GetApp().FindRecordsByFilter(
records, err := app.GetApp().FindAllRecords(
domain.CollectionNameCertificate,
"expireAt>DATETIME('now') && expireAt<DATETIME('now', '+20 days') && deleted=null",
"-created",
0, 0,
dbx.NewExp("expireAt>DATETIME('now')"),
dbx.NewExp("expireAt<DATETIME('now', '+20 days')"),
dbx.NewExp("deleted=null"),
)
if err != nil {
return nil, err
@ -62,7 +62,8 @@ func (r *CertificateRepository) GetByWorkflowNodeId(ctx context.Context, workflo
records, err := app.GetApp().FindRecordsByFilter(
domain.CollectionNameCertificate,
"workflowNodeId={:workflowNodeId} && deleted=null",
"-created", 1, 0,
"-created",
1, 0,
dbx.Params{"workflowNodeId": workflowNodeId},
)
if err != nil {

View File

@ -55,10 +55,10 @@ func (r *WorkflowRepository) GetById(ctx context.Context, id string) (*domain.Wo
return r.castRecordToModel(record)
}
func (r *WorkflowRepository) Save(ctx context.Context, workflow *domain.Workflow) error {
func (r *WorkflowRepository) Save(ctx context.Context, workflow *domain.Workflow) (*domain.Workflow, error) {
collection, err := app.GetApp().FindCollectionByNameOrId(domain.CollectionNameWorkflow)
if err != nil {
return err
return workflow, err
}
var record *core.Record
@ -68,9 +68,9 @@ func (r *WorkflowRepository) Save(ctx context.Context, workflow *domain.Workflow
record, err = app.GetApp().FindRecordById(domain.CollectionNameWorkflow, workflow.Id)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return domain.ErrRecordNotFound
return workflow, domain.ErrRecordNotFound
}
return err
return workflow, err
}
}
@ -86,18 +86,36 @@ func (r *WorkflowRepository) Save(ctx context.Context, workflow *domain.Workflow
record.Set("lastRunStatus", string(workflow.LastRunStatus))
record.Set("lastRunTime", workflow.LastRunTime)
return app.GetApp().Save(record)
if err := app.GetApp().Save(record); err != nil {
return workflow, err
}
workflow.Id = record.Id
workflow.CreatedAt = record.GetDateTime("created").Time()
workflow.UpdatedAt = record.GetDateTime("updated").Time()
return workflow, nil
}
func (r *WorkflowRepository) SaveRun(ctx context.Context, workflowRun *domain.WorkflowRun) error {
func (r *WorkflowRepository) SaveRun(ctx context.Context, workflowRun *domain.WorkflowRun) (*domain.WorkflowRun, error) {
collection, err := app.GetApp().FindCollectionByNameOrId(domain.CollectionNameWorkflowRun)
if err != nil {
return err
return workflowRun, err
}
var workflowRunRecord *core.Record
if workflowRun.Id == "" {
workflowRunRecord = core.NewRecord(collection)
} else {
workflowRunRecord, err = app.GetApp().FindRecordById(domain.CollectionNameWorkflowRun, workflowRun.Id)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return workflowRun, err
}
workflowRunRecord = core.NewRecord(collection)
}
}
err = app.GetApp().RunInTransaction(func(txApp core.App) error {
workflowRunRecord := core.NewRecord(collection)
workflowRunRecord.Id = workflowRun.Id
workflowRunRecord.Set("workflowId", workflowRun.WorkflowId)
workflowRunRecord.Set("trigger", string(workflowRun.Trigger))
workflowRunRecord.Set("status", string(workflowRun.Status))
@ -115,6 +133,7 @@ func (r *WorkflowRepository) SaveRun(ctx context.Context, workflowRun *domain.Wo
return err
}
workflowRecord.IgnoreUnchangedFields(true)
workflowRecord.Set("lastRunId", workflowRunRecord.Id)
workflowRecord.Set("lastRunStatus", workflowRunRecord.GetString("status"))
workflowRecord.Set("lastRunTime", workflowRunRecord.GetString("startedAt"))
@ -123,13 +142,17 @@ func (r *WorkflowRepository) SaveRun(ctx context.Context, workflowRun *domain.Wo
return err
}
workflowRun.Id = workflowRunRecord.Id
workflowRun.CreatedAt = workflowRunRecord.GetDateTime("created").Time()
workflowRun.UpdatedAt = workflowRunRecord.GetDateTime("updated").Time()
return nil
})
if err != nil {
return err
return workflowRun, err
}
return nil
return workflowRun, nil
}
func (r *WorkflowRepository) castRecordToModel(record *core.Record) (*domain.Workflow, error) {

View File

@ -26,16 +26,31 @@ func NewCertificateHandler(router *router.RouterGroup[*core.RequestEvent], servi
}
group := router.Group("/certificates")
group.POST("/{id}/archive", handler.run)
group.POST("/{certificateId}/archive", handler.archiveFile)
group.POST("/validate/certificate", handler.validateCertificate)
group.POST("/validate/private-key", handler.validatePrivateKey)
}
func (handler *CertificateHandler) archiveFile(e *core.RequestEvent) error {
req := &dtos.CertificateArchiveFileReq{}
req.CertificateId = e.Request.PathValue("certificateId")
if err := e.BindBody(req); err != nil {
return resp.Err(e, err)
}
if bt, err := handler.service.ArchiveFile(e.Request.Context(), req); err != nil {
return resp.Err(e, err)
} else {
return resp.Ok(e, bt)
}
}
func (handler *CertificateHandler) validateCertificate(e *core.RequestEvent) error {
req := &dtos.CertificateValidateCertificateReq{}
if err := e.BindBody(req); err != nil {
return resp.Err(e, err)
}
if rs, err := handler.service.ValidateCertificate(e.Request.Context(), req); err != nil {
return resp.Err(e, err)
} else {
@ -48,23 +63,10 @@ func (handler *CertificateHandler) validatePrivateKey(e *core.RequestEvent) erro
if err := e.BindBody(req); err != nil {
return resp.Err(e, err)
}
if err := handler.service.ValidatePrivateKey(e.Request.Context(), req); err != nil {
return resp.Err(e, err)
} else {
return resp.Ok(e, nil)
}
}
func (handler *CertificateHandler) run(e *core.RequestEvent) error {
req := &dtos.CertificateArchiveFileReq{}
req.CertificateId = e.Request.PathValue("id")
if err := e.BindBody(req); err != nil {
return resp.Err(e, err)
}
if bt, err := handler.service.ArchiveFile(e.Request.Context(), req); err != nil {
return resp.Err(e, err)
} else {
return resp.Ok(e, bt)
}
}

View File

@ -11,7 +11,8 @@ import (
)
type workflowService interface {
Run(ctx context.Context, req *dtos.WorkflowRunReq) error
StartRun(ctx context.Context, req *dtos.WorkflowStartRunReq) error
CancelRun(ctx context.Context, req *dtos.WorkflowCancelRunReq) error
Stop(ctx context.Context)
}
@ -25,17 +26,30 @@ func NewWorkflowHandler(router *router.RouterGroup[*core.RequestEvent], service
}
group := router.Group("/workflows")
group.POST("/{id}/run", handler.run)
group.POST("/{workflowId}/runs", handler.run)
group.POST("/{workflowId}/runs/{runId}/cancel", handler.cancel)
}
func (handler *WorkflowHandler) run(e *core.RequestEvent) error {
req := &dtos.WorkflowRunReq{}
req.WorkflowId = e.Request.PathValue("id")
req := &dtos.WorkflowStartRunReq{}
req.WorkflowId = e.Request.PathValue("workflowId")
if err := e.BindBody(req); err != nil {
return resp.Err(e, err)
}
if err := handler.service.Run(e.Request.Context(), req); err != nil {
if err := handler.service.StartRun(e.Request.Context(), req); err != nil {
return resp.Err(e, err)
}
return resp.Ok(e, nil)
}
func (handler *WorkflowHandler) cancel(e *core.RequestEvent) error {
req := &dtos.WorkflowCancelRunReq{}
req.WorkflowId = e.Request.PathValue("workflowId")
req.RunId = e.Request.PathValue("runId")
if err := handler.service.CancelRun(e.Request.Context(), req); err != nil {
return resp.Err(e, err)
}

View File

@ -65,7 +65,7 @@ func onWorkflowRecordCreateOrUpdate(ctx context.Context, record *core.Record) er
// 反之,重新添加定时任务
err := scheduler.Add(fmt.Sprintf("workflow#%s", workflowId), record.GetString("triggerCron"), func() {
NewWorkflowService(repository.NewWorkflowRepository()).Run(ctx, &dtos.WorkflowRunReq{
NewWorkflowService(repository.NewWorkflowRepository()).StartRun(ctx, &dtos.WorkflowStartRunReq{
WorkflowId: workflowId,
Trigger: domain.WorkflowTriggerTypeAuto,
})

View File

@ -134,7 +134,7 @@ func (a *applyNode) checkCanSkip(ctx context.Context, lastOutput *domain.Workflo
renewalInterval := time.Duration(currentNodeConfig.SkipBeforeExpiryDays) * time.Hour * 24
expirationTime := time.Until(lastCertificate.ExpireAt)
if lastCertificate != nil && expirationTime > renewalInterval {
return true, fmt.Sprintf("已申请过证书,且证书尚未临近过期(到期尚余 %d 天,距 %d 天时续期)", int(expirationTime.Hours()/24), currentNodeConfig.SkipBeforeExpiryDays)
return true, fmt.Sprintf("已申请过证书,且证书尚未临近过期(到期尚余 %d 天,预计距 %d 天时续期)", int(expirationTime.Hours()/24), currentNodeConfig.SkipBeforeExpiryDays)
}
}

View File

@ -19,15 +19,15 @@ func NewWorkflowProcessor(workflow *domain.Workflow) *workflowProcessor {
}
}
func (w *workflowProcessor) Log(ctx context.Context) []domain.WorkflowRunLog {
return w.logs
}
func (w *workflowProcessor) Run(ctx context.Context) error {
ctx = setContextWorkflowId(ctx, w.workflow.Id)
return w.processNode(ctx, w.workflow.Content)
}
func (w *workflowProcessor) GetRunLogs() []domain.WorkflowRunLog {
return w.logs
}
func (w *workflowProcessor) processNode(ctx context.Context, node *domain.WorkflowNode) error {
current := node
for current != nil {
@ -39,8 +39,8 @@ func (w *workflowProcessor) processNode(ctx context.Context, node *domain.Workfl
}
}
var runErr error
var processor nodes.NodeProcessor
var runErr error
for {
if current.Type != domain.WorkflowNodeTypeBranch && current.Type != domain.WorkflowNodeTypeExecuteResultBranch {
processor, runErr = nodes.GetProcessor(current)
@ -49,7 +49,6 @@ func (w *workflowProcessor) processNode(ctx context.Context, node *domain.Workfl
}
runErr = processor.Run(ctx)
log := processor.Log(ctx)
if log != nil {
w.logs = append(w.logs, *log)
@ -58,6 +57,7 @@ func (w *workflowProcessor) processNode(ctx context.Context, node *domain.Workfl
break
}
}
break
}
@ -70,8 +70,8 @@ func (w *workflowProcessor) processNode(ctx context.Context, node *domain.Workfl
} else {
current = current.Next
}
}
return nil
}
@ -79,10 +79,6 @@ func setContextWorkflowId(ctx context.Context, id string) context.Context {
return context.WithValue(ctx, "workflow_id", id)
}
func GetWorkflowId(ctx context.Context) string {
return ctx.Value("workflow_id").(string)
}
func getBranchByType(branches []domain.WorkflowNode, nodeType domain.WorkflowNodeType) *domain.WorkflowNode {
for _, branch := range branches {
if branch.Type == nodeType {

View File

@ -23,8 +23,8 @@ type workflowRunData struct {
type workflowRepository interface {
ListEnabledAuto(ctx context.Context) ([]*domain.Workflow, error)
GetById(ctx context.Context, id string) (*domain.Workflow, error)
Save(ctx context.Context, workflow *domain.Workflow) error
SaveRun(ctx context.Context, workflowRun *domain.WorkflowRun) error
Save(ctx context.Context, workflow *domain.Workflow) (*domain.Workflow, error)
SaveRun(ctx context.Context, workflowRun *domain.WorkflowRun) (*domain.WorkflowRun, error)
}
type WorkflowService struct {
@ -35,35 +35,20 @@ type WorkflowService struct {
}
func NewWorkflowService(repo workflowRepository) *WorkflowService {
rs := &WorkflowService{
srv := &WorkflowService{
repo: repo,
ch: make(chan *workflowRunData, 1),
}
ctx, cancel := context.WithCancel(context.Background())
rs.cancel = cancel
srv.cancel = cancel
rs.wg.Add(defaultRoutines)
srv.wg.Add(defaultRoutines)
for i := 0; i < defaultRoutines; i++ {
go rs.process(ctx)
go srv.run(ctx)
}
return rs
}
func (s *WorkflowService) process(ctx context.Context) {
defer s.wg.Done()
for {
select {
case data := <-s.ch:
// 执行
if err := s.run(ctx, data); err != nil {
app.GetLogger().Error("failed to run workflow", "id", data.Workflow.Id, "err", err)
}
case <-ctx.Done():
return
}
}
return srv
}
func (s *WorkflowService) InitSchedule(ctx context.Context) error {
@ -75,7 +60,7 @@ func (s *WorkflowService) InitSchedule(ctx context.Context) error {
scheduler := app.GetScheduler()
for _, workflow := range workflows {
err := scheduler.Add(fmt.Sprintf("workflow#%s", workflow.Id), workflow.TriggerCron, func() {
s.Run(ctx, &dtos.WorkflowRunReq{
s.StartRun(ctx, &dtos.WorkflowStartRunReq{
WorkflowId: workflow.Id,
Trigger: domain.WorkflowTriggerTypeAuto,
})
@ -89,8 +74,7 @@ func (s *WorkflowService) InitSchedule(ctx context.Context) error {
return nil
}
func (s *WorkflowService) Run(ctx context.Context, req *dtos.WorkflowRunReq) error {
// 查询
func (s *WorkflowService) StartRun(ctx context.Context, req *dtos.WorkflowStartRunReq) error {
workflow, err := s.repo.GetById(ctx, req.WorkflowId)
if err != nil {
app.GetLogger().Error("failed to get workflow", "id", req.WorkflowId, "err", err)
@ -101,13 +85,13 @@ func (s *WorkflowService) Run(ctx context.Context, req *dtos.WorkflowRunReq) err
return errors.New("workflow is running")
}
// set last run
workflow.LastRunTime = time.Now()
workflow.LastRunStatus = domain.WorkflowRunStatusTypeRunning
workflow.LastRunStatus = domain.WorkflowRunStatusTypePending
workflow.LastRunId = ""
if err := s.repo.Save(ctx, workflow); err != nil {
if resp, err := s.repo.Save(ctx, workflow); err != nil {
return err
} else {
workflow = resp
}
s.ch <- &workflowRunData{
@ -118,51 +102,70 @@ func (s *WorkflowService) Run(ctx context.Context, req *dtos.WorkflowRunReq) err
return nil
}
func (s *WorkflowService) run(ctx context.Context, runData *workflowRunData) error {
// 执行
workflow := runData.Workflow
run := &domain.WorkflowRun{
WorkflowId: workflow.Id,
Status: domain.WorkflowRunStatusTypeRunning,
Trigger: runData.RunTrigger,
StartedAt: time.Now(),
EndedAt: time.Now(),
}
func (s *WorkflowService) CancelRun(ctx context.Context, req *dtos.WorkflowCancelRunReq) error {
// TODO: 取消运行,防止因为某些原因意外挂起(如进程被杀死)导致工作流一直处于 running 状态无法重新运行
processor := processor.NewWorkflowProcessor(workflow)
if err := processor.Run(ctx); err != nil {
run.Status = domain.WorkflowRunStatusTypeFailed
run.EndedAt = time.Now()
run.Logs = processor.Log(ctx)
run.Error = err.Error()
if err := s.repo.SaveRun(ctx, run); err != nil {
app.GetLogger().Error("failed to save workflow run", "err", err)
}
return fmt.Errorf("failed to run workflow: %w", err)
}
// 保存日志
logs := processor.Log(ctx)
runStatus := domain.WorkflowRunStatusTypeSucceeded
runError := domain.WorkflowRunLogs(logs).FirstError()
if runError != "" {
runStatus = domain.WorkflowRunStatusTypeFailed
}
run.Status = runStatus
run.EndedAt = time.Now()
run.Logs = processor.Log(ctx)
run.Error = runError
if err := s.repo.SaveRun(ctx, run); err != nil {
app.GetLogger().Error("failed to save workflow run", "err", err)
return err
}
return nil
return errors.New("TODO: 尚未实现")
}
func (s *WorkflowService) Stop(ctx context.Context) {
s.cancel()
s.wg.Wait()
}
func (s *WorkflowService) run(ctx context.Context) {
defer s.wg.Done()
for {
select {
case data := <-s.ch:
if err := s.runWithData(ctx, data); err != nil {
app.GetLogger().Error("failed to run workflow", "id", data.Workflow.Id, "err", err)
}
case <-ctx.Done():
return
}
}
}
func (s *WorkflowService) runWithData(ctx context.Context, runData *workflowRunData) error {
workflow := runData.Workflow
run := &domain.WorkflowRun{
WorkflowId: workflow.Id,
Status: domain.WorkflowRunStatusTypeRunning,
Trigger: runData.RunTrigger,
StartedAt: time.Now(),
}
if resp, err := s.repo.SaveRun(ctx, run); err != nil {
return err
} else {
run = resp
}
processor := processor.NewWorkflowProcessor(workflow)
if runErr := processor.Run(ctx); runErr != nil {
run.Status = domain.WorkflowRunStatusTypeFailed
run.EndedAt = time.Now()
run.Logs = processor.GetRunLogs()
run.Error = runErr.Error()
if _, err := s.repo.SaveRun(ctx, run); err != nil {
app.GetLogger().Error("failed to save workflow run", "err", err)
}
return fmt.Errorf("failed to run workflow: %w", runErr)
}
run.EndedAt = time.Now()
run.Logs = processor.GetRunLogs()
run.Error = domain.WorkflowRunLogs(run.Logs).ErrorString()
if run.Error == "" {
run.Status = domain.WorkflowRunStatusTypeSucceeded
} else {
run.Status = domain.WorkflowRunStatusTypeFailed
}
if _, err := s.repo.SaveRun(ctx, run); err != nil {
app.GetLogger().Error("failed to save workflow run", "err", err)
return err
}
return nil
}

View File

@ -0,0 +1,65 @@
package migrations
import (
"github.com/pocketbase/pocketbase/core"
m "github.com/pocketbase/pocketbase/migrations"
)
func init() {
m.Register(func(app core.App) error {
collection, err := app.FindCollectionByNameOrId("tovyif5ax6j62ur")
if err != nil {
return err
}
// update field
if err := collection.Fields.AddMarshaledJSONAt(10, []byte(`{
"hidden": false,
"id": "zivdxh23",
"maxSelect": 1,
"name": "lastRunStatus",
"presentable": false,
"required": false,
"system": false,
"type": "select",
"values": [
"pending",
"running",
"succeeded",
"failed",
"canceled"
]
}`)); err != nil {
return err
}
return app.Save(collection)
}, func(app core.App) error {
collection, err := app.FindCollectionByNameOrId("tovyif5ax6j62ur")
if err != nil {
return err
}
// update field
if err := collection.Fields.AddMarshaledJSONAt(10, []byte(`{
"hidden": false,
"id": "zivdxh23",
"maxSelect": 1,
"name": "lastRunStatus",
"presentable": false,
"required": false,
"system": false,
"type": "select",
"values": [
"pending",
"running",
"succeeded",
"failed"
]
}`)); err != nil {
return err
}
return app.Save(collection)
})
}

View File

@ -0,0 +1,65 @@
package migrations
import (
"github.com/pocketbase/pocketbase/core"
m "github.com/pocketbase/pocketbase/migrations"
)
func init() {
m.Register(func(app core.App) error {
collection, err := app.FindCollectionByNameOrId("qjp8lygssgwyqyz")
if err != nil {
return err
}
// update field
if err := collection.Fields.AddMarshaledJSONAt(2, []byte(`{
"hidden": false,
"id": "qldmh0tw",
"maxSelect": 1,
"name": "status",
"presentable": false,
"required": false,
"system": false,
"type": "select",
"values": [
"pending",
"running",
"succeeded",
"failed",
"canceled"
]
}`)); err != nil {
return err
}
return app.Save(collection)
}, func(app core.App) error {
collection, err := app.FindCollectionByNameOrId("qjp8lygssgwyqyz")
if err != nil {
return err
}
// update field
if err := collection.Fields.AddMarshaledJSONAt(2, []byte(`{
"hidden": false,
"id": "qldmh0tw",
"maxSelect": 1,
"name": "status",
"presentable": false,
"required": false,
"system": false,
"type": "select",
"values": [
"pending",
"running",
"succeeded",
"failed"
]
}`)); err != nil {
return err
}
return app.Save(collection)
})
}

19
ui/package-lock.json generated
View File

@ -13,6 +13,7 @@
"ahooks": "^3.8.4",
"antd": "^5.23.1",
"antd-zod": "^6.0.1",
"clsx": "^2.1.1",
"cron-parser": "^4.9.0",
"file-saver": "^2.0.5",
"i18next": "^24.2.1",
@ -27,6 +28,7 @@
"react-dom": "^18.3.1",
"react-i18next": "^15.4.0",
"react-router-dom": "^7.1.3",
"tailwind-merge": "^2.6.0",
"zod": "^3.24.1",
"zustand": "^5.0.3"
},
@ -4124,6 +4126,14 @@
"resolved": "https://registry.npmmirror.com/classnames/-/classnames-2.5.1.tgz",
"integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow=="
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmmirror.com/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"engines": {
"node": ">=6"
}
},
"node_modules/commander": {
"version": "4.1.1",
"resolved": "https://registry.npmmirror.com/commander/-/commander-4.1.1.tgz",
@ -8563,6 +8573,15 @@
"integrity": "sha512-Nk8c4lXvMB98MtbmjX7JwJRgJOL8fluecYCfCeYBznwmpOs8Bf15hLM6z4z71EDAhQVrQrI+wt1aLWSXZq+hXA==",
"dev": true
},
"node_modules/tailwind-merge": {
"version": "2.6.0",
"resolved": "https://registry.npmmirror.com/tailwind-merge/-/tailwind-merge-2.6.0.tgz",
"integrity": "sha512-P+Vu1qXfzediirmHOC3xKGAYeZtPcV9g76X+xg2FD4tYgR71ewMA35Y3sCz3zhiN/dwefRpJX0yBcgwi1fXNQA==",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/dcastil"
}
},
"node_modules/tailwindcss": {
"version": "3.4.17",
"resolved": "https://registry.npmmirror.com/tailwindcss/-/tailwindcss-3.4.17.tgz",

View File

@ -15,6 +15,7 @@
"ahooks": "^3.8.4",
"antd": "^5.23.1",
"antd-zod": "^6.0.1",
"clsx": "^2.1.1",
"cron-parser": "^4.9.0",
"file-saver": "^2.0.5",
"i18next": "^24.2.1",
@ -29,6 +30,7 @@
"react-dom": "^18.3.1",
"react-i18next": "^15.4.0",
"react-router-dom": "^7.1.3",
"tailwind-merge": "^2.6.0",
"zod": "^3.24.1",
"zustand": "^5.0.3"
},

View File

@ -3,10 +3,10 @@ import { ClientResponseError } from "pocketbase";
import { type CertificateFormatType } from "@/domain/certificate";
import { getPocketBase } from "@/repository/_pocketbase";
export const archive = async (id: string, format?: CertificateFormatType) => {
export const archive = async (certificateId: string, format?: CertificateFormatType) => {
const pb = getPocketBase();
const resp = await pb.send<BaseResponse>(`/api/certificates/${encodeURIComponent(id)}/archive`, {
const resp = await pb.send<BaseResponse<string>>(`/api/certificates/${encodeURIComponent(certificateId)}/archive`, {
method: "POST",
headers: {
"Content-Type": "application/json",
@ -38,9 +38,11 @@ export const validateCertificate = async (certificate: string) => {
certificate: certificate,
},
});
if (resp.code != 0) {
throw new Error(resp.msg);
throw new ClientResponseError({ status: resp.code, response: resp, data: {} });
}
return resp;
};
@ -55,8 +57,10 @@ export const validatePrivateKey = async (privateKey: string) => {
privateKey: privateKey,
},
});
if (resp.code != 0) {
throw new Error(resp.msg);
throw new ClientResponseError({ status: resp.code, response: resp, data: {} });
}
return resp;
};

View File

@ -3,10 +3,10 @@ import { ClientResponseError } from "pocketbase";
import { WORKFLOW_TRIGGERS } from "@/domain/workflow";
import { getPocketBase } from "@/repository/_pocketbase";
export const run = async (id: string) => {
export const startRun = async (workflowId: string) => {
const pb = getPocketBase();
const resp = await pb.send<BaseResponse>(`/api/workflows/${encodeURIComponent(id)}/run`, {
const resp = await pb.send<BaseResponse>(`/api/workflows/${encodeURIComponent(workflowId)}/runs`, {
method: "POST",
headers: {
"Content-Type": "application/json",
@ -22,3 +22,20 @@ export const run = async (id: string) => {
return resp;
};
export const cancelRun = async (workflowId: string, runId: string) => {
const pb = getPocketBase();
const resp = await pb.send<BaseResponse>(`/api/workflows/${encodeURIComponent(workflowId)}/runs/${encodeURIComponent(runId)}/cancel`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
});
if (resp.code != 0) {
throw new ClientResponseError({ status: resp.code, response: resp, data: {} });
}
return resp;
};

View File

@ -49,9 +49,7 @@ const DrawerForm = <T extends NonNullable<unknown> = any>({
const triggerEl = useTriggerElement(trigger, {
onClick: () => {
console.log("click");
setOpen(true);
console.log(open);
},
});

View File

@ -27,7 +27,7 @@ const CertificateDetail = ({ data, ...props }: CertificateDetailProps) => {
const blob = new Blob([u8arr], { type: "application/zip" });
saveAs(blob, `${data.id}-${data.subjectAltNames}.zip`);
} catch (err) {
console.log(err);
console.error(err);
messageApi.warning(t("common.text.operation_failed"));
}
};

View File

@ -0,0 +1,41 @@
import { useState } from "react";
import { ExpandOutlined as ExpandOutlinedIcon, MinusOutlined as MinusOutlinedIcon, PlusOutlined as PlusOutlinedIcon } from "@ant-design/icons";
import { Button, Card, Typography } from "antd";
import WorkflowElements from "@/components/workflow/WorkflowElements";
import { mergeCls } from "@/utils/css";
export type WorkflowElementsProps = {
className?: string;
style?: React.CSSProperties;
disabled?: boolean;
};
const WorkflowElementsContainer = ({ className, style, disabled }: WorkflowElementsProps) => {
const [scale, setScale] = useState(1);
return (
<div className={mergeCls("relative size-full overflow-hidden", className)} style={style}>
<div className="size-full overflow-auto">
<div className="relative z-[1]">
<div className="origin-center transition-transform duration-300" style={{ zoom: `${scale}` }}>
<div className="p-4">
<WorkflowElements disabled={disabled} />
</div>
</div>
</div>
</div>
<Card className="absolute bottom-4 right-6 z-[2] rounded-lg p-2 shadow-lg" styles={{ body: { padding: 0 } }}>
<div className="flex items-center gap-2">
<Button icon={<MinusOutlinedIcon />} disabled={scale <= 0.5} onClick={() => setScale((s) => Math.max(0.5, s - 0.1))} />
<Typography.Text className="min-w-[3em] text-center">{Math.round(scale * 100)}%</Typography.Text>
<Button icon={<PlusOutlinedIcon />} disabled={scale >= 2} onClick={() => setScale((s) => Math.min(2, s + 0.1))} />
<Button icon={<ExpandOutlinedIcon />} onClick={() => setScale(1)} />
</div>
</Card>
</div>
);
};
export default WorkflowElementsContainer;

View File

@ -41,11 +41,11 @@ const WorkflowRunDetailDrawer = ({ data, loading, trigger, ...props }: WorkflowR
</Show>
<div className="mt-4 rounded-md bg-black p-4 text-stone-200">
<div className="flex flex-col space-y-3">
<div className="flex flex-col space-y-4">
{data!.logs?.map((item, i) => {
return (
<div key={i} className="flex flex-col space-y-2">
<div>{item.nodeName}</div>
<div className="font-semibold">{item.nodeName}</div>
<div className="flex flex-col space-y-1">
{item.outputs?.map((output, j) => {
return (

View File

@ -4,17 +4,21 @@ import {
CheckCircleOutlined as CheckCircleOutlinedIcon,
ClockCircleOutlined as ClockCircleOutlinedIcon,
CloseCircleOutlined as CloseCircleOutlinedIcon,
DeleteOutlined as DeleteOutlinedIcon,
PauseCircleOutlined as PauseCircleOutlinedIcon,
PauseOutlined as PauseOutlinedIcon,
SelectOutlined as SelectOutlinedIcon,
SyncOutlined as SyncOutlinedIcon,
} from "@ant-design/icons";
import { useRequest } from "ahooks";
import { Button, Empty, Table, type TableProps, Tag, notification } from "antd";
import { Button, Empty, Modal, Table, type TableProps, Tag, Tooltip, notification } from "antd";
import dayjs from "dayjs";
import { ClientResponseError } from "pocketbase";
import { cancelRun as cancelWorkflowRun } from "@/api/workflows";
import { WORKFLOW_TRIGGERS } from "@/domain/workflow";
import { WORKFLOW_RUN_STATUSES, type WorkflowRunModel } from "@/domain/workflowRun";
import { list as listWorkflowRuns } from "@/repository/workflowRun";
import { list as listWorkflowRuns, remove as removeWorkflowRun } from "@/repository/workflowRun";
import { getErrMsg } from "@/utils/error";
import WorkflowRunDetailDrawer from "./WorkflowRunDetailDrawer";
@ -27,6 +31,7 @@ export type WorkflowRunsProps = {
const WorkflowRuns = ({ className, style, workflowId }: WorkflowRunsProps) => {
const { t } = useTranslation();
const [modalApi, ModelContextHolder] = Modal.useModal();
const [notificationApi, NotificationContextHolder] = notification.useNotification();
const tableColumns: TableProps<WorkflowRunModel>["columns"] = [
@ -68,6 +73,12 @@ const WorkflowRuns = ({ className, style, workflowId }: WorkflowRunsProps) => {
{t("workflow_run.props.status.failed")}
</Tag>
);
} else if (record.status === WORKFLOW_RUN_STATUSES.CANCELED) {
return (
<Tag icon={<PauseCircleOutlinedIcon />} color="warning">
{t("workflow_run.props.status.canceled")}
</Tag>
);
}
return <></>;
@ -116,11 +127,51 @@ const WorkflowRuns = ({ className, style, workflowId }: WorkflowRunsProps) => {
align: "end",
fixed: "right",
width: 120,
render: (_, record) => (
<Button.Group>
<WorkflowRunDetailDrawer data={record} trigger={<Button color="primary" icon={<SelectOutlinedIcon />} variant="text" />} />
</Button.Group>
),
render: (_, record) => {
const allowCancel = record.status === WORKFLOW_RUN_STATUSES.PENDING || record.status === WORKFLOW_RUN_STATUSES.RUNNING;
const aloowDelete =
record.status === WORKFLOW_RUN_STATUSES.SUCCEEDED ||
record.status === WORKFLOW_RUN_STATUSES.FAILED ||
record.status === WORKFLOW_RUN_STATUSES.CANCELED;
return (
<Button.Group>
<WorkflowRunDetailDrawer
data={record}
trigger={
<Tooltip title={t("workflow_run.action.view")}>
<Button color="primary" icon={<SelectOutlinedIcon />} variant="text" />
</Tooltip>
}
/>
<Tooltip title={t("workflow_run.action.cancel")}>
<Button
color="default"
disabled={!allowCancel}
icon={<PauseOutlinedIcon />}
variant="text"
onClick={() => {
handleCancelClick(record);
}}
/>
</Tooltip>
<Tooltip title={t("workflow_run.action.delete")}>
<Button
color="danger"
danger
disabled={!aloowDelete}
icon={<DeleteOutlinedIcon />}
variant="text"
onClick={() => {
handleDeleteClick(record);
}}
/>
</Tooltip>
</Button.Group>
);
},
},
];
const [tableData, setTableData] = useState<WorkflowRunModel[]>([]);
@ -129,7 +180,11 @@ const WorkflowRuns = ({ className, style, workflowId }: WorkflowRunsProps) => {
const [page, setPage] = useState<number>(1);
const [pageSize, setPageSize] = useState<number>(10);
const { loading, error: loadedError } = useRequest(
const {
loading,
error: loadedError,
run: refreshData,
} = useRequest(
() => {
return listWorkflowRuns({
workflowId: workflowId,
@ -156,8 +211,46 @@ const WorkflowRuns = ({ className, style, workflowId }: WorkflowRunsProps) => {
}
);
const handleCancelClick = (workflowRun: WorkflowRunModel) => {
modalApi.confirm({
title: t("workflow_run.action.cancel"),
content: t("workflow_run.action.cancel.confirm"),
onOk: async () => {
try {
const resp = await cancelWorkflowRun(workflowId, workflowRun.id);
if (resp) {
refreshData();
}
} catch (err) {
console.error(err);
notificationApi.error({ message: t("common.text.request_error"), description: getErrMsg(err) });
}
},
});
};
const handleDeleteClick = (workflowRun: WorkflowRunModel) => {
modalApi.confirm({
title: t("workflow_run.action.delete"),
content: t("workflow_run.action.delete.confirm"),
onOk: async () => {
try {
const resp = await removeWorkflowRun(workflowRun);
if (resp) {
setTableData((prev) => prev.filter((item) => item.id !== workflowRun.id));
refreshData();
}
} catch (err) {
console.error(err);
notificationApi.error({ message: t("common.text.request_error"), description: getErrMsg(err) });
}
},
});
};
return (
<>
{ModelContextHolder}
{NotificationContextHolder}
<div className={className} style={style}>

View File

@ -2,11 +2,11 @@ import { memo, useMemo } from "react";
import { useTranslation } from "react-i18next";
import {
CloudUploadOutlined as CloudUploadOutlinedIcon,
DeploymentUnitOutlined as DeploymentUnitOutlinedIcon,
PlusOutlined as PlusOutlinedIcon,
SendOutlined as SendOutlinedIcon,
SisternodeOutlined as SisternodeOutlinedIcon,
SolutionOutlined as SolutionOutlinedIcon,
SafetyOutlined as SafetyOutlinedIcon,
} from "@ant-design/icons";
import { Dropdown } from "antd";
@ -26,15 +26,15 @@ const AddNode = ({ node, disabled }: AddNodeProps) => {
const dropdownMenus = useMemo(() => {
return [
[WorkflowNodeType.Apply, "workflow_node.apply.label", <SolutionOutlinedIcon />],
[WorkflowNodeType.Upload, "workflow_node.upload.label", <SafetyOutlinedIcon />],
[WorkflowNodeType.Deploy, "workflow_node.deploy.label", <CloudUploadOutlinedIcon />],
[WorkflowNodeType.Upload, "workflow_node.upload.label", <CloudUploadOutlinedIcon />],
[WorkflowNodeType.Deploy, "workflow_node.deploy.label", <DeploymentUnitOutlinedIcon />],
[WorkflowNodeType.Notify, "workflow_node.notify.label", <SendOutlinedIcon />],
[WorkflowNodeType.Branch, "workflow_node.branch.label", <SisternodeOutlinedIcon />],
[WorkflowNodeType.ExecuteResultBranch, "workflow_node.execute_result_branch.label", <SisternodeOutlinedIcon />],
[WorkflowNodeType.Notify, "workflow_node.notify.label", <SendOutlinedIcon />],
]
.filter(([type]) => {
if (node.type !== WorkflowNodeType.Apply && node.type !== WorkflowNodeType.Deploy && type === WorkflowNodeType.ExecuteResultBranch) {
return false;
if (node.type !== WorkflowNodeType.Apply && node.type !== WorkflowNodeType.Deploy && node.type !== WorkflowNodeType.Notify) {
return type !== WorkflowNodeType.ExecuteResultBranch;
}
return true;

View File

@ -16,6 +16,8 @@ const ConditionNode = ({ node, disabled, branchId, branchIndex }: ConditionNodeP
return (
<>
<Popover
classNames={{ root: "shadow-md" }}
styles={{ body: { padding: 0 } }}
arrow={false}
content={
<SharedNode.Menu
@ -26,8 +28,6 @@ const ConditionNode = ({ node, disabled, branchId, branchIndex }: ConditionNodeP
trigger={<Button color="primary" icon={<MoreOutlinedIcon />} variant="text" />}
/>
}
overlayClassName="shadow-md"
overlayInnerStyle={{ padding: 0 }}
placement="rightTop"
>
<Card className="relative z-[1] mt-10 w-[256px] shadow-md" styles={{ body: { padding: 0 } }} hoverable>

View File

@ -1,7 +1,7 @@
import { forwardRef, memo, useEffect, useImperativeHandle, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { PlusOutlined as PlusOutlinedIcon, QuestionCircleOutlined as QuestionCircleOutlinedIcon } from "@ant-design/icons";
import { Button, Divider, Flex, Form, type FormInstance, Select, Switch, Tooltip, Typography } from "antd";
import { Alert, Button, Divider, Flex, Form, type FormInstance, Select, Switch, Tooltip, Typography } from "antd";
import { createSchemaFieldRule } from "antd-zod";
import { z } from "zod";
@ -310,6 +310,15 @@ const DeployNodeConfigForm = forwardRef<DeployNodeConfigFormInstance, DeployNode
</Form.Item>
</Form.Item>
<Show when={fieldProvider === DEPLOY_PROVIDERS.LOCAL}>
<Form.Item>
<Alert
type="info"
message={<span dangerouslySetInnerHTML={{ __html: t("workflow_node.deploy.form.provider_access.guide_for_local") }}></span>}
/>
</Form.Item>
</Show>
<Form.Item
name="certificate"
label={t("workflow_node.deploy.form.certificate.label")}

View File

@ -1,9 +1,12 @@
import { memo } from "react";
import { useTranslation } from "react-i18next";
import { MoreOutlined as MoreOutlinedIcon } from "@ant-design/icons";
import { Button, Card, Popover } from "antd";
import {
CheckCircleOutlined as CheckCircleOutlinedIcon,
CloseCircleOutlined as CloseCircleOutlinedIcon,
MoreOutlined as MoreOutlinedIcon,
} from "@ant-design/icons";
import { Button, Card, Popover, theme } from "antd";
import { CheckCircleIcon, XCircleIcon } from "lucide-react";
import { WorkflowNodeType } from "@/domain/workflow";
import AddNode from "./AddNode";
import SharedNode, { type SharedNodeProps } from "./_SharedNode";
@ -16,9 +19,13 @@ export type ConditionNodeProps = SharedNodeProps & {
const ExecuteResultNode = ({ node, disabled, branchId, branchIndex }: ConditionNodeProps) => {
const { t } = useTranslation();
const { token: themeToken } = theme.useToken();
return (
<>
<Popover
classNames={{ root: "shadow-md" }}
styles={{ body: { padding: 0 } }}
arrow={false}
content={
<SharedNode.Menu
@ -29,8 +36,6 @@ const ExecuteResultNode = ({ node, disabled, branchId, branchIndex }: ConditionN
trigger={<Button color="primary" icon={<MoreOutlinedIcon />} variant="text" />}
/>
}
overlayClassName="shadow-md"
overlayInnerStyle={{ padding: 0 }}
placement="rightTop"
>
<Card className="relative z-[1] mt-10 w-[256px] shadow-md" styles={{ body: { padding: 0 } }} hoverable>
@ -38,12 +43,12 @@ const ExecuteResultNode = ({ node, disabled, branchId, branchIndex }: ConditionN
<div className="flex items-center space-x-2">
{node.type === WorkflowNodeType.ExecuteSuccess ? (
<>
<CheckCircleIcon size={18} className="text-green-500" />
<CheckCircleOutlinedIcon style={{ color: themeToken.colorSuccess }} />
<div>{t("workflow_node.execute_success.label")}</div>
</>
) : (
<>
<XCircleIcon size={18} className="text-red-500" />
<CloseCircleOutlinedIcon style={{ color: themeToken.colorError }} />
<div>{t("workflow_node.execute_failure.label")}</div>
</>
)}

View File

@ -136,7 +136,7 @@ const StartNodeConfigForm = forwardRef<StartNodeConfigFormInstance, StartNodeCon
<Show when={fieldTrigger === WORKFLOW_TRIGGERS.AUTO}>
<Form.Item>
<Alert type="info" message={<span dangerouslySetInnerHTML={{ __html: t("workflow_node.start.form.trigger_cron_alert.content") }}></span>} />
<Alert type="info" message={<span dangerouslySetInnerHTML={{ __html: t("workflow_node.start.form.trigger_cron.guide") }}></span>} />
</Form.Item>
</Show>
</Form>

View File

@ -1,5 +1,6 @@
import { forwardRef, memo, useEffect, useImperativeHandle } from "react";
import { forwardRef, memo, useImperativeHandle } from "react";
import { useTranslation } from "react-i18next";
import { UploadOutlined as UploadOutlinedIcon } from "@ant-design/icons";
import { Button, Form, type FormInstance, Input, Upload, type UploadProps } from "antd";
import { createSchemaFieldRule } from "antd-zod";
import { z } from "zod";
@ -35,8 +36,7 @@ const UploadNodeConfigForm = forwardRef<UploadNodeConfigFormInstance, UploadNode
const { t } = useTranslation();
const formSchema = z.object({
certificateId: z.string().optional(),
domains: z.string().optional(),
domains: z.string().nullish(),
certificate: z
.string({ message: t("workflow_node.upload.form.certificate.placeholder") })
.min(1, t("workflow_node.upload.form.certificate.placeholder"))
@ -52,15 +52,6 @@ const UploadNodeConfigForm = forwardRef<UploadNodeConfigFormInstance, UploadNode
initialValues: initialValues ?? initFormModel(),
});
const certificate = Form.useWatch("certificate", formInst);
const privateKey = Form.useWatch("privateKey", formInst);
useEffect(() => {
if (certificate && privateKey) {
formInst.validateFields(["certificate", "privateKey"]);
}
}, [certificate, privateKey]);
const handleFormChange = (_: unknown, values: z.infer<typeof formSchema>) => {
onValuesChange?.(values as UploadNodeConfigFormFieldValues);
};
@ -86,18 +77,21 @@ const UploadNodeConfigForm = forwardRef<UploadNodeConfigFormInstance, UploadNode
try {
const resp = await validateCertificate(certificate);
formInst.setFields([
{
name: "certificate",
value: certificate,
errors: [],
},
{
name: "domains",
value: resp.data.domains,
},
{
name: "certificate",
value: certificate,
},
]);
} catch (e) {
formInst.setFields([
{
name: "domains",
value: "",
},
{
name: "certificate",
value: "",
@ -108,25 +102,27 @@ const UploadNodeConfigForm = forwardRef<UploadNodeConfigFormInstance, UploadNode
} else {
formInst.setFieldValue("certificate", "");
}
onValuesChange?.(formInst.getFieldsValue(true));
};
const handlePrivateKeyFileChange: UploadProps["onChange"] = async ({ file }) => {
if (file && file.status !== "removed") {
const privateKey = await readFileContent(file.originFileObj ?? (file as unknown as File));
try {
await validatePrivateKey(privateKey);
formInst.setFields([
{
name: "privateKey",
value: privateKey,
errors: [],
},
]);
} catch (e) {
formInst.setFields([
{
name: "privateKey",
value: "",
errors: [getErrMsg(e)],
},
]);
@ -141,35 +137,27 @@ const UploadNodeConfigForm = forwardRef<UploadNodeConfigFormInstance, UploadNode
return (
<Form className={className} style={style} {...formProps} disabled={disabled} layout="vertical" scrollToFirstError onValuesChange={handleFormChange}>
<Form.Item name="domains" label={t("workflow_node.upload.form.domains.label")} rules={[formRule]}>
<Input readOnly />
<Input placeholder={t("workflow_node.upload.form.domains.placeholder")} readOnly />
</Form.Item>
<Form.Item name="certificate" label={t("workflow_node.upload.form.certificate.label")} rules={[formRule]}>
<Input.TextArea
readOnly
autoSize={{ minRows: 5, maxRows: 10 }}
placeholder={t("workflow_node.upload.form.certificate.placeholder")}
value={certificate}
/>
<div className="mt-2 text-right">
<Upload beforeUpload={() => false} maxCount={1} onChange={handleCertificateFileChange}>
<Button>{t("workflow_node.upload.form.certificate.button")}</Button>
</Upload>
</div>
<Input.TextArea readOnly autoSize={{ minRows: 5, maxRows: 10 }} placeholder={t("workflow_node.upload.form.certificate.placeholder")} />
</Form.Item>
<Form.Item>
<Upload beforeUpload={() => false} maxCount={1} onChange={handleCertificateFileChange}>
<Button icon={<UploadOutlinedIcon />}>{t("workflow_node.upload.form.certificate.button")}</Button>
</Upload>
</Form.Item>
<Form.Item name="privateKey" label={t("workflow_node.upload.form.private_key.label")} rules={[formRule]}>
<Input.TextArea
readOnly
autoSize={{ minRows: 5, maxRows: 10 }}
placeholder={t("workflow_node.upload.form.private_key.placeholder")}
value={privateKey}
/>
<div className="mt-2 text-right">
<Upload beforeUpload={() => false} maxCount={1} onChange={handlePrivateKeyFileChange}>
<Button>{t("workflow_node.upload.form.private_key.button")}</Button>
</Upload>
</div>
<Input.TextArea readOnly autoSize={{ minRows: 5, maxRows: 10 }} placeholder={t("workflow_node.upload.form.private_key.placeholder")} />
</Form.Item>
<Form.Item>
<Upload beforeUpload={() => false} maxCount={1} onChange={handlePrivateKeyFileChange}>
<Button icon={<UploadOutlinedIcon />}>{t("workflow_node.upload.form.private_key.button")}</Button>
</Upload>
</Form.Item>
</Form>
);

View File

@ -64,6 +64,16 @@ type SharedNodeMenuProps = SharedNodeProps & {
afterDelete?: () => void;
};
const isBranchingNode = (node: WorkflowNode) => {
return (
node.type === WorkflowNodeType.Branch ||
node.type === WorkflowNodeType.Condition ||
node.type === WorkflowNodeType.ExecuteResultBranch ||
node.type === WorkflowNodeType.ExecuteSuccess ||
node.type === WorkflowNodeType.ExecuteFailure
);
};
const SharedNodeMenu = ({ trigger, node, disabled, branchId, branchIndex, afterUpdate, afterDelete }: SharedNodeMenuProps) => {
const { t } = useTranslation();
@ -91,13 +101,7 @@ const SharedNodeMenu = ({ trigger, node, disabled, branchId, branchIndex, afterU
};
const handleDeleteClick = async () => {
if (
node.type === WorkflowNodeType.Branch ||
node.type === WorkflowNodeType.Condition ||
node.type === WorkflowNodeType.ExecuteResultBranch ||
node.type === WorkflowNodeType.ExecuteSuccess ||
node.type === WorkflowNodeType.ExecuteFailure
) {
if (isBranchingNode(node)) {
await removeBranch(branchId!, branchIndex!);
} else {
await removeNode(node.id);
@ -116,19 +120,13 @@ const SharedNodeMenu = ({ trigger, node, disabled, branchId, branchIndex, afterU
{
key: "rename",
disabled: disabled,
label:
node.type === WorkflowNodeType.Branch || node.type === WorkflowNodeType.Condition
? t("workflow_node.action.rename_branch")
: t("workflow_node.action.rename_node"),
label: isBranchingNode(node) ? t("workflow_node.action.rename_branch") : t("workflow_node.action.rename_node"),
icon: <FormOutlinedIcon />,
onClick: () => {
nameRef.current = node.name;
const dialog = modalApi.confirm({
title:
node.type === WorkflowNodeType.Branch || node.type === WorkflowNodeType.Condition
? t("workflow_node.action.rename_branch")
: t("workflow_node.action.rename_node"),
title: isBranchingNode(node) ? t("workflow_node.action.rename_branch") : t("workflow_node.action.rename_node"),
content: (
<div className="pb-2 pt-4">
<Input
@ -156,14 +154,7 @@ const SharedNodeMenu = ({ trigger, node, disabled, branchId, branchIndex, afterU
{
key: "remove",
disabled: disabled || node.type === WorkflowNodeType.Start,
label:
node.type === WorkflowNodeType.Branch ||
node.type === WorkflowNodeType.Condition ||
node.type === WorkflowNodeType.ExecuteResultBranch ||
node.type === WorkflowNodeType.ExecuteSuccess ||
node.type === WorkflowNodeType.ExecuteFailure
? t("workflow_node.action.remove_branch")
: t("workflow_node.action.remove_node"),
label: isBranchingNode(node) ? t("workflow_node.action.remove_branch") : t("workflow_node.action.remove_node"),
icon: <CloseCircleOutlinedIcon />,
danger: true,
onClick: handleDeleteClick,
@ -193,10 +184,10 @@ const SharedNodeBlock = ({ children, node, disabled, onClick }: SharedNodeBlockP
return (
<>
<Popover
classNames={{ root: "shadow-md" }}
styles={{ body: { padding: 0 } }}
arrow={false}
content={<SharedNodeMenu node={node} disabled={disabled} trigger={<Button color="primary" icon={<MoreOutlinedIcon />} variant="text" />} />}
overlayClassName="shadow-md"
overlayInnerStyle={{ padding: 0 }}
placement="rightTop"
>
<Card className="relative w-[256px] overflow-hidden shadow-md" styles={{ body: { padding: 0 } }} hoverable>

View File

@ -29,30 +29,30 @@ export type WorkflowTriggerType = (typeof WORKFLOW_TRIGGERS)[keyof typeof WORKFL
export enum WorkflowNodeType {
Start = "start",
End = "end",
Branch = "branch",
ExecuteResultBranch = "execute_result_branch",
ExecuteSuccess = "execute_success",
ExecuteFailure = "execute_failure",
Condition = "condition",
Apply = "apply",
Upload = "upload",
Deploy = "deploy",
Notify = "notify",
Branch = "branch",
Condition = "condition",
ExecuteResultBranch = "execute_result_branch",
ExecuteSuccess = "execute_success",
ExecuteFailure = "execute_failure",
Custom = "custom",
}
const workflowNodeTypeDefaultNames: Map<WorkflowNodeType, string> = new Map([
[WorkflowNodeType.Start, i18n.t("workflow_node.start.label")],
[WorkflowNodeType.End, i18n.t("workflow_node.end.label")],
[WorkflowNodeType.Branch, i18n.t("workflow_node.branch.label")],
[WorkflowNodeType.ExecuteResultBranch, i18n.t("workflow_node.execute_result_branch.label")],
[WorkflowNodeType.ExecuteSuccess, i18n.t("workflow_node.execute_success.label")],
[WorkflowNodeType.ExecuteFailure, i18n.t("workflow_node.execute_failure.label")],
[WorkflowNodeType.Condition, i18n.t("workflow_node.condition.label")],
[WorkflowNodeType.Apply, i18n.t("workflow_node.apply.label")],
[WorkflowNodeType.Upload, i18n.t("workflow_node.upload.label")],
[WorkflowNodeType.Deploy, i18n.t("workflow_node.deploy.label")],
[WorkflowNodeType.Notify, i18n.t("workflow_node.notify.label")],
[WorkflowNodeType.Branch, i18n.t("workflow_node.branch.label")],
[WorkflowNodeType.Condition, i18n.t("workflow_node.condition.label")],
[WorkflowNodeType.ExecuteResultBranch, i18n.t("workflow_node.execute_result_branch.label")],
[WorkflowNodeType.ExecuteSuccess, i18n.t("workflow_node.execute_success.label")],
[WorkflowNodeType.ExecuteFailure, i18n.t("workflow_node.execute_failure.label")],
[WorkflowNodeType.Custom, i18n.t("workflow_node.custom.title")],
]);

View File

@ -32,6 +32,7 @@ export const WORKFLOW_RUN_STATUSES = Object.freeze({
RUNNING: "running",
SUCCEEDED: "succeeded",
FAILED: "failed",
CANCELED: "canceled",
} as const);
export type WorkflorRunStatusType = (typeof WORKFLOW_RUN_STATUSES)[keyof typeof WORKFLOW_RUN_STATUSES];

View File

@ -1,5 +1,5 @@
{
"workflow_node.action.configure_node": "Configure",
"workflow_node.action.configure_node": "Configure node",
"workflow_node.action.add_node": "Add node",
"workflow_node.action.rename_node": "Rename node",
"workflow_node.action.remove_node": "Delete node",
@ -20,7 +20,7 @@
"workflow_node.start.form.trigger_cron.errmsg.invalid": "Please enter a valid cron expression",
"workflow_node.start.form.trigger_cron.tooltip": "Time zone is based on the server.",
"workflow_node.start.form.trigger_cron.extra": "Expected execution time for the last 5 times:",
"workflow_node.start.form.trigger_cron_alert.content": "Tips: If you have multiple workflows, it is recommended to set them to run at multiple times of the day instead of always running at specific times.<br><br>Reference links:<br>1. <a href=\"https://letsencrypt.org/docs/rate-limits/\" target=\"_blank\">Lets Encrypt rate limits</a><br>2. <a href=\"https://letsencrypt.org/docs/faq/#why-should-my-let-s-encrypt-acme-client-run-at-a-random-time\" target=\"_blank\">Why should my Lets Encrypt (ACME) client run at a random time?</a>",
"workflow_node.start.form.trigger_cron.guide": "Tips: If you have multiple workflows, it is recommended to set them to run at multiple times of the day instead of always running at specific times.<br><br>Reference links:<br>1. <a href=\"https://letsencrypt.org/docs/rate-limits/\" target=\"_blank\">Lets Encrypt rate limits</a><br>2. <a href=\"https://letsencrypt.org/docs/faq/#why-should-my-let-s-encrypt-acme-client-run-at-a-random-time\" target=\"_blank\">Why should my Lets Encrypt (ACME) client run at a random time?</a>",
"workflow_node.apply.label": "Application",
"workflow_node.apply.form.domains.label": "Domains",
@ -82,6 +82,7 @@
"workflow_node.deploy.form.provider_access.placeholder": "Please select an authorization of host provider",
"workflow_node.deploy.form.provider_access.tooltip": "Used to deploy certificates.",
"workflow_node.deploy.form.provider_access.button": "Create",
"workflow_node.deploy.form.provider_access.guide_for_local": "Tips: Due to the form validations, youe need to select an authorization for local deployment also, even if it means nothing.",
"workflow_node.deploy.form.certificate.label": "Certificate",
"workflow_node.deploy.form.certificate.placeholder": "Please select certificate",
"workflow_node.deploy.form.certificate.tooltip": "The certificate to be deployed comes from the previous application stage node.",
@ -377,25 +378,25 @@
"workflow_node.notify.form.channel.placeholder": "Please select channel",
"workflow_node.notify.form.channel.button": "Configure",
"workflow_node.upload.label": "Upload certificate",
"workflow_node.upload.label": "Upload",
"workflow_node.upload.form.domains.label": "Domains",
"workflow_node.upload.form.certificate.label": "Certificate",
"workflow_node.upload.form.certificate.placeholder": "The certificate format begins with \"-----BEGIN CERTIFICATE-----\" and ends with \"-----END CERTIFICATE-----\"",
"workflow_node.upload.form.certificate.button": "Upload",
"workflow_node.upload.form.private_key.label": "Private key",
"workflow_node.upload.form.private_key.placeholder": "The private key begins with \"-----BEGIN (RSA|EC) PRIVATE KEY-----\" and ends with \"-----END(RSA|EC) PRIVATE KEY-----\"",
"workflow_node.upload.form.private_key.button": "Upload",
"workflow_node.upload.form.domains.placholder": "Please select certificate file",
"workflow_node.upload.form.certificate.label": "Certificate (PEM format)",
"workflow_node.upload.form.certificate.placeholder": "-----BEGIN CERTIFICATE-----...-----END CERTIFICATE-----",
"workflow_node.upload.form.certificate.button": "Choose file ...",
"workflow_node.upload.form.private_key.label": "Private key (PEM format)",
"workflow_node.upload.form.private_key.placeholder": "-----BEGIN (RSA|EC) PRIVATE KEY-----...-----END(RSA|EC) PRIVATE KEY-----",
"workflow_node.upload.form.private_key.button": "Choose file ...",
"workflow_node.end.label": "End",
"workflow_node.branch.label": "Branch",
"workflow_node.branch.label": "Parallel branch",
"workflow_node.execute_result_branch.label": "Execute result branch",
"workflow_node.condition.label": "Branch",
"workflow_node.execute_success.label": "Execute success",
"workflow_node.execute_result_branch.label": "Execution result branch",
"workflow_node.execute_failure.label": "Execute failure",
"workflow_node.execute_success.label": "If the previous node succeeded ...",
"workflow_node.condition.label": "Condition"
"workflow_node.execute_failure.label": "If the previous node failed ..."
}

View File

@ -1,10 +1,17 @@
{
"workflow_run.action.view": "View detail",
"workflow_run.action.cancel": "Cancel run",
"workflow_run.action.cancel.confirm": "Are you sure to cancel this run?",
"workflow_run.action.delete": "Delete run",
"workflow_run.action.delete.confirm": "Are you sure to delete this run?",
"workflow_run.props.id": "ID",
"workflow_run.props.status": "Status",
"workflow_run.props.status.pending": "Pending",
"workflow_run.props.status.running": "Running",
"workflow_run.props.status.succeeded": "Succeeded",
"workflow_run.props.status.failed": "Failed",
"workflow_run.props.status.canceled": "Canceled",
"workflow_run.props.trigger": "Trigger",
"workflow_run.props.trigger.auto": "Timing",
"workflow_run.props.trigger.manual": "Manual",

View File

@ -3,7 +3,7 @@
"workflow_node.branch.add_node": "添加节点",
"workflow_node.action.rename_node": "重命名",
"workflow_node.action.remove_node": "删除节点",
"workflow_node.action.add_branch": "添加分支",
"workflow_node.action.add_branch": "添加并行分支",
"workflow_node.action.rename_branch": "重命名",
"workflow_node.action.remove_branch": "删除分支",
@ -20,7 +20,7 @@
"workflow_node.start.form.trigger_cron.errmsg.invalid": "请输入正确的 Cron 表达式",
"workflow_node.start.form.trigger_cron.tooltip": "支持使用任意值(即 <strong>*</strong>)、值列表分隔符(即 <strong>,</strong>)、值的范围(即 <strong>-</strong>)、步骤值(即 <strong>/</strong>)等四种表达式,时区以服务器设置为准。",
"workflow_node.start.form.trigger_cron.extra": "预计最近 5 次执行时间:",
"workflow_node.start.form.trigger_cron_alert.content": "小贴士:如果你有多个工作流,建议将它们设置为在一天中的多个时间段运行,而非总是在相同的特定时间。<br><br>参考链接:<br>1. <a href=\"https://letsencrypt.org/zh-cn/docs/rate-limits/\" target=\"_blank\">Lets Encrypt 速率限制</a><br>2. <a href=\"https://letsencrypt.org/zh-cn/docs/faq/#%E4%B8%BA%E4%BB%80%E4%B9%88%E6%88%91%E7%9A%84-let-s-encrypt-acme-%E5%AE%A2%E6%88%B7%E7%AB%AF%E5%90%AF%E5%8A%A8%E6%97%B6%E9%97%B4%E5%BA%94%E5%BD%93%E9%9A%8F%E6%9C%BA\" target=\"_blank\">为什么我的 Lets Encrypt (ACME) 客户端启动时间应当随机?</a>",
"workflow_node.start.form.trigger_cron.guide": "小贴士:如果你有多个工作流,建议将它们设置为在一天中的多个时间段运行,而非总是在相同的特定时间。<br><br>参考链接:<br>1. <a href=\"https://letsencrypt.org/zh-cn/docs/rate-limits/\" target=\"_blank\">Lets Encrypt 速率限制</a><br>2. <a href=\"https://letsencrypt.org/zh-cn/docs/faq/#%E4%B8%BA%E4%BB%80%E4%B9%88%E6%88%91%E7%9A%84-let-s-encrypt-acme-%E5%AE%A2%E6%88%B7%E7%AB%AF%E5%90%AF%E5%8A%A8%E6%97%B6%E9%97%B4%E5%BA%94%E5%BD%93%E9%9A%8F%E6%9C%BA\" target=\"_blank\">为什么我的 Lets Encrypt (ACME) 客户端启动时间应当随机?</a>",
"workflow_node.apply.label": "申请",
"workflow_node.apply.form.domains.label": "域名",
@ -37,6 +37,7 @@
"workflow_node.apply.form.provider_access.placeholder": "请选择 DNS 提供商授权",
"workflow_node.apply.form.provider_access.tooltip": "用于 ACME DNS-01 认证时操作域名解析记录,注意与部署阶段所需的主机提供商相区分。",
"workflow_node.apply.form.provider_access.button": "新建",
"workflow_node.deploy.form.provider_access.guide_for_local": "小贴士:由于表单限制,你同样需要为本地部署选择一个授权 —— 即使它是空白的。",
"workflow_node.apply.form.aws_route53_region.label": "AWS Route53 区域",
"workflow_node.apply.form.aws_route53_region.placeholder": "请输入 AWS Route53 区域例如us-east-1",
"workflow_node.apply.form.aws_route53_region.tooltip": "这是什么?请参阅 <a href=\"https://docs.aws.amazon.com/zh_cn/general/latest/gr/rande.html#regional-endpoints\" tworkflow_node.applyank\">https://docs.aws.amazon.com/zh_cn/general/latest/gr/rande.html#regional-endpoints</a>",
@ -377,25 +378,25 @@
"workflow_node.notify.form.channel.placeholder": "请选择通知渠道",
"workflow_node.notify.form.channel.button": "去配置",
"workflow_node.upload.label": "上传证书",
"workflow_node.upload.form.domains.label": "证书域名",
"workflow_node.upload.form.certificate.label": "证书文件",
"workflow_node.upload.form.certificate.placeholder": "证书格式以\"-----BEGIN CERTIFICATE-----\"开头,以\"-----END CERTIFICATE-----\"结尾。",
"workflow_node.upload.form.certificate.button": "上传",
"workflow_node.upload.form.private_key.label": "证书私钥",
"workflow_node.upload.form.private_key.placeholder": "证书私钥格式以\"-----BEGIN (RSA|EC) PRIVATE KEY-----\"开头,以\"-----END(RSA|EC) PRIVATE KEY-----\"结尾。",
"workflow_node.upload.form.private_key.button": "上传",
"workflow_node.upload.label": "上传",
"workflow_node.upload.form.domains.label": "域名",
"workflow_node.upload.form.domains.placeholder": "上传证书文件后显示",
"workflow_node.upload.form.certificate.label": "证书文件PEM 格式)",
"workflow_node.upload.form.certificate.placeholder": "-----BEGIN CERTIFICATE-----...-----END CERTIFICATE-----",
"workflow_node.upload.form.certificate.button": "选择文件",
"workflow_node.upload.form.private_key.label": "私钥文件PEM 格式)",
"workflow_node.upload.form.private_key.placeholder": "-----BEGIN (RSA|EC) PRIVATE KEY-----...-----END(RSA|EC) PRIVATE KEY-----",
"workflow_node.upload.form.private_key.button": "选择文件",
"workflow_node.end.label": "结束",
"workflow_node.branch.label": "分支",
"workflow_node.branch.label": "并行分支",
"workflow_node.condition.label": "分支",
"workflow_node.execute_result_branch.label": "执行结果分支",
"workflow_node.execute_success.label": "执行成功",
"workflow_node.execute_success.label": "若前序节点执行成功",
"workflow_node.execute_failure.label": "执行失败",
"workflow_node.condition.label": "条件"
"workflow_node.execute_failure.label": "若前序节点执行失败…"
}

View File

@ -1,10 +1,17 @@
{
"workflow_run.action.view": "查看详情",
"workflow_run.action.cancel": "取消执行",
"workflow_run.action.cancel.confirm": "确定要取消此执行吗?请注意此操作仅中止流程,但不会回滚已执行的节点。",
"workflow_run.action.delete": "删除执行",
"workflow_run.action.delete.confirm": "确定要删除此执行吗?请注意此操作仅清除日志历史,但不会影响各节点的执行结果和签发的证书。",
"workflow_run.props.id": "ID",
"workflow_run.props.status": "状态",
"workflow_run.props.status.pending": "等待执行",
"workflow_run.props.status.running": "执行中",
"workflow_run.props.status.succeeded": "成功",
"workflow_run.props.status.failed": "失败",
"workflow_run.props.status.succeeded": "已成功",
"workflow_run.props.status.failed": "已失败",
"workflow_run.props.status.canceled": "已取消",
"workflow_run.props.trigger": "执行方式",
"workflow_run.props.trigger.auto": "定时执行",
"workflow_run.props.trigger.manual": "手动执行",

View File

@ -41,7 +41,7 @@ const ConsoleLayout = () => {
}
return (
<Layout className="min-h-screen" hasSider>
<Layout className="h-screen" hasSider>
<Layout.Sider className="fixed left-0 top-0 z-20 h-full max-md:static max-md:hidden" width="256px" theme="light">
<div className="flex size-full flex-col items-center justify-between overflow-hidden">
<div className="w-full">
@ -53,8 +53,8 @@ const ConsoleLayout = () => {
</div>
</Layout.Sider>
<Layout className="pl-[256px] max-md:pl-0">
<Layout.Header className="sticky inset-x-0 top-0 z-[19] p-0 shadow-sm" style={{ background: themeToken.colorBgContainer }}>
<Layout className="flex flex-col overflow-hidden pl-[256px] max-md:pl-0">
<Layout.Header className="p-0 shadow-sm" style={{ background: themeToken.colorBgContainer }}>
<div className="flex size-full items-center justify-between overflow-hidden px-4">
<div className="flex items-center gap-4">
<SiderMenuDrawer trigger={<Button className="md:hidden" icon={<MenuOutlinedIcon />} size="large" />} />
@ -76,7 +76,7 @@ const ConsoleLayout = () => {
</div>
</Layout.Header>
<Layout.Content style={{ overflow: "initial" }}>
<Layout.Content className="flex-1 overflow-y-auto overflow-x-hidden">
<Outlet />
</Layout.Content>
</Layout>

View File

@ -2,15 +2,16 @@ import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import {
ApiOutlined,
CheckCircleOutlined,
ClockCircleOutlined,
CloseCircleOutlined,
LockOutlined,
PlusOutlined,
SelectOutlined,
SendOutlined,
SyncOutlined,
ApiOutlined as ApiOutlinedIcon,
CheckCircleOutlined as CheckCircleOutlinedIcon,
ClockCircleOutlined as ClockCircleOutlinedIcon,
CloseCircleOutlined as CloseCircleOutlinedIcon,
LockOutlined as LockOutlinedIcon,
PauseCircleOutlined as PauseCircleOutlinedIcon,
PlusOutlined as PlusOutlinedIcon,
SelectOutlined as SelectOutlinedIcon,
SendOutlined as SendOutlinedIcon,
SyncOutlined as SyncOutlinedIcon,
} from "@ant-design/icons";
import { PageHeader } from "@ant-design/pro-components";
import { useRequest } from "ahooks";
@ -84,14 +85,22 @@ const Dashboard = () => {
key: "name",
title: t("workflow.props.name"),
ellipsis: true,
render: (_, record) => (
<Space className="max-w-full" direction="vertical" size={4}>
<Typography.Text ellipsis>{record.expand?.workflowId?.name}</Typography.Text>
<Typography.Text type="secondary" ellipsis>
{record.expand?.workflowId?.description}
</Typography.Text>
</Space>
),
render: (_, record) => {
const workflow = record.expand?.workflowId;
return (
<Typography.Link
type="secondary"
ellipsis
onClick={() => {
if (workflow) {
navigate(`/workflows/${workflow.id}`);
}
}}
>
{workflow?.name ?? <span className="font-mono">{t(`#${record.workflowId}`)}</span>}
</Typography.Link>
);
},
},
{
key: "status",
@ -99,25 +108,31 @@ const Dashboard = () => {
ellipsis: true,
render: (_, record) => {
if (record.status === WORKFLOW_RUN_STATUSES.PENDING) {
return <Tag icon={<ClockCircleOutlined />}>{t("workflow_run.props.status.pending")}</Tag>;
return <Tag icon={<ClockCircleOutlinedIcon />}>{t("workflow_run.props.status.pending")}</Tag>;
} else if (record.status === WORKFLOW_RUN_STATUSES.RUNNING) {
return (
<Tag icon={<SyncOutlined spin />} color="processing">
<Tag icon={<SyncOutlinedIcon spin />} color="processing">
{t("workflow_run.props.status.running")}
</Tag>
);
} else if (record.status === WORKFLOW_RUN_STATUSES.SUCCEEDED) {
return (
<Tag icon={<CheckCircleOutlined />} color="success">
<Tag icon={<CheckCircleOutlinedIcon />} color="success">
{t("workflow_run.props.status.succeeded")}
</Tag>
);
} else if (record.status === WORKFLOW_RUN_STATUSES.FAILED) {
return (
<Tag icon={<CloseCircleOutlined />} color="error">
<Tag icon={<CloseCircleOutlinedIcon />} color="error">
{t("workflow_run.props.status.failed")}
</Tag>
);
} else if (record.status === WORKFLOW_RUN_STATUSES.CANCELED) {
return (
<Tag icon={<PauseCircleOutlinedIcon />} color="warning">
{t("workflow_run.props.status.canceled")}
</Tag>
);
}
return <></>;
@ -153,7 +168,7 @@ const Dashboard = () => {
width: 120,
render: (_, record) => (
<Button.Group>
<WorkflowRunDetailDrawer data={record} trigger={<Button color="primary" icon={<SelectOutlined />} variant="text" />} />
<WorkflowRunDetailDrawer data={record} trigger={<Button color="primary" icon={<SelectOutlinedIcon />} variant="text" />} />
</Button.Group>
),
},
@ -248,16 +263,16 @@ const Dashboard = () => {
<Flex justify="stretch" vertical={!breakpoints.lg} gap={16}>
<Card className="max-lg:flex-1 lg:w-[360px]" title={t("dashboard.quick_actions")}>
<Space className="w-full" direction="vertical" size="large">
<Button block type="primary" size="large" icon={<PlusOutlined />} onClick={() => navigate("/workflows/new")}>
<Button block type="primary" size="large" icon={<PlusOutlinedIcon />} onClick={() => navigate("/workflows/new")}>
{t("dashboard.quick_actions.create_workflow")}
</Button>
<Button block size="large" icon={<LockOutlined />} onClick={() => navigate("/settings/password")}>
<Button block size="large" icon={<LockOutlinedIcon />} onClick={() => navigate("/settings/password")}>
{t("dashboard.quick_actions.change_login_password")}
</Button>
<Button block size="large" icon={<SendOutlined />} onClick={() => navigate("/settings/notification")}>
<Button block size="large" icon={<SendOutlinedIcon />} onClick={() => navigate("/settings/notification")}>
{t("dashboard.quick_actions.cofigure_notification")}
</Button>
<Button block size="large" icon={<ApiOutlined />} onClick={() => navigate("/settings/ssl-provider")}>
<Button block size="large" icon={<ApiOutlinedIcon />} onClick={() => navigate("/settings/ssl-provider")}>
{t("dashboard.quick_actions.configure_ca")}
</Button>
</Space>

View File

@ -8,9 +8,6 @@ import {
DownOutlined as DownOutlinedIcon,
EllipsisOutlined as EllipsisOutlinedIcon,
HistoryOutlined as HistoryOutlinedIcon,
MinusOutlined,
PlusCircleOutlined,
ReloadOutlined,
UndoOutlined as UndoOutlinedIcon,
} from "@ant-design/icons";
import { PageHeader } from "@ant-design/pro-components";
@ -19,10 +16,10 @@ import { createSchemaFieldRule } from "antd-zod";
import { isEqual } from "radash";
import { z } from "zod";
import { run as runWorkflow } from "@/api/workflows";
import { startRun as startWorkflowRun } from "@/api/workflows";
import ModalForm from "@/components/ModalForm";
import Show from "@/components/Show";
import WorkflowElements from "@/components/workflow/WorkflowElements";
import WorkflowElementsContainer from "@/components/workflow/WorkflowElementsContainer";
import WorkflowRuns from "@/components/workflow/WorkflowRuns";
import { isAllNodesValidated } from "@/domain/workflow";
import { WORKFLOW_RUN_STATUSES } from "@/domain/workflowRun";
@ -40,8 +37,6 @@ const WorkflowDetail = () => {
const [modalApi, ModalContextHolder] = Modal.useModal();
const [notificationApi, NotificationContextHolder] = notification.useNotification();
const [scale, setScale] = useState(1);
const { id: workflowId } = useParams();
const { workflow, initialized, ...workflowState } = useWorkflowStore(
useZustandShallowSelector(["workflow", "initialized", "init", "destroy", "setEnabled", "release", "discard"])
@ -58,15 +53,12 @@ const WorkflowDetail = () => {
const [tabValue, setTabValue] = useState<"orchestration" | "runs">("orchestration");
const [isRunning, setIsRunning] = useState(false);
const lastRunStatus = useMemo(() => workflow.lastRunStatus, [workflow]);
const [allowDiscard, setAllowDiscard] = useState(false);
const [allowRelease, setAllowRelease] = useState(false);
const [allowRun, setAllowRun] = useState(false);
const lastRunStatus = useMemo(() => {
return workflow.lastRunStatus;
}, [workflow]);
useEffect(() => {
setIsRunning(lastRunStatus == WORKFLOW_RUN_STATUSES.RUNNING);
}, [lastRunStatus]);
@ -192,7 +184,7 @@ const WorkflowDetail = () => {
}
});
await runWorkflow(workflowId!);
await startWorkflowRun(workflowId!);
messageApi.info(t("workflow.detail.orchestration.action.run.prompt"));
} catch (err) {
@ -206,123 +198,129 @@ const WorkflowDetail = () => {
};
return (
<div>
<div className="flex size-full flex-col">
{MessageContextHolder}
{ModalContextHolder}
{NotificationContextHolder}
<Card styles={{ body: { padding: "0.5rem", paddingBottom: 0 } }}>
<PageHeader
style={{ paddingBottom: 0 }}
title={workflow.name}
extra={
initialized
? [
<WorkflowBaseInfoModal key="edit" trigger={<Button>{t("common.button.edit")}</Button>} />,
<div>
<Card styles={{ body: { padding: "0.5rem", paddingBottom: 0 } }} style={{ borderRadius: 0 }}>
<PageHeader
style={{ paddingBottom: 0 }}
title={workflow.name}
extra={
initialized
? [
<WorkflowBaseInfoModal key="edit" trigger={<Button>{t("common.button.edit")}</Button>} />,
<Button key="enable" onClick={handleEnableChange}>
{workflow.enabled ? t("workflow.action.disable") : t("workflow.action.enable")}
</Button>,
<Button key="enable" onClick={handleEnableChange}>
{workflow.enabled ? t("workflow.action.disable") : t("workflow.action.enable")}
</Button>,
<Dropdown
key="more"
menu={{
items: [
{
key: "delete",
label: t("workflow.action.delete"),
danger: true,
icon: <DeleteOutlinedIcon />,
onClick: () => {
handleDeleteClick();
},
},
],
}}
trigger={["click"]}
>
<Button icon={<DownOutlinedIcon />} iconPosition="end">
{t("common.button.more")}
</Button>
</Dropdown>,
]
: []
}
>
<Typography.Paragraph type="secondary">{workflow.description}</Typography.Paragraph>
<Tabs
activeKey={tabValue}
defaultActiveKey="orchestration"
items={[
{ key: "orchestration", label: t("workflow.detail.orchestration.tab"), icon: <ApartmentOutlinedIcon /> },
{ key: "runs", label: t("workflow.detail.runs.tab"), icon: <HistoryOutlinedIcon /> },
]}
renderTabBar={(props, DefaultTabBar) => <DefaultTabBar {...props} style={{ margin: 0 }} />}
tabBarStyle={{ border: "none" }}
onChange={(key) => setTabValue(key as typeof tabValue)}
/>
</PageHeader>
</Card>
<div className="p-4">
<Card loading={!initialized}>
<Show when={tabValue === "orchestration"}>
<div className="relative">
<div className="flex items-center justify-between gap-4">
<div className="flex-1 overflow-hidden">
<Show when={workflow.hasDraft!}>
<Alert banner message={<div className="truncate">{t("workflow.detail.orchestration.draft.alert")}</div>} type="warning" />
</Show>
</div>
<div className="flex justify-end">
<Space>
<Button disabled={!allowRun} icon={<CaretRightOutlinedIcon />} loading={isRunning} type="primary" onClick={handleRunClick}>
{t("workflow.detail.orchestration.action.run")}
</Button>
<Button.Group>
<Button color="primary" disabled={!allowRelease} variant="outlined" onClick={handleReleaseClick}>
{t("workflow.detail.orchestration.action.release")}
</Button>
<Dropdown
menu={{
items: [
{
key: "discard",
disabled: !allowDiscard,
label: t("workflow.detail.orchestration.action.discard"),
icon: <UndoOutlinedIcon />,
onClick: handleDiscardClick,
<Dropdown
key="more"
menu={{
items: [
{
key: "delete",
label: t("workflow.action.delete"),
danger: true,
icon: <DeleteOutlinedIcon />,
onClick: () => {
handleDeleteClick();
},
],
}}
trigger={["click"]}
>
<Button color="primary" disabled={!allowDiscard} icon={<EllipsisOutlinedIcon />} variant="outlined" />
</Dropdown>
</Button.Group>
</Space>
</div>
</div>
<div className="fixed bottom-8 right-8 z-10 flex items-center gap-2 rounded-lg bg-white p-2 shadow-lg">
<Button icon={<MinusOutlined />} disabled={scale <= 0.5} onClick={() => setScale((s) => Math.max(0.5, s - 0.1))} />
<Typography.Text className="min-w-[3em] text-center">{Math.round(scale * 100)}%</Typography.Text>
<Button icon={<PlusCircleOutlined />} disabled={scale >= 2} onClick={() => setScale((s) => Math.min(2, s + 0.1))} />
<Button icon={<ReloadOutlined />} onClick={() => setScale(1)} />
</div>
<div className="size-full origin-top px-12 py-8 transition-transform duration-300 max-md:px-4" style={{ transform: `scale(${scale})` }}>
<WorkflowElements />
</div>
</div>
</Show>
<Show when={tabValue === "runs"}>
<WorkflowRuns workflowId={workflowId!} />
</Show>
},
],
}}
trigger={["click"]}
>
<Button icon={<DownOutlinedIcon />} iconPosition="end">
{t("common.button.more")}
</Button>
</Dropdown>,
]
: []
}
>
<Typography.Paragraph type="secondary">{workflow.description}</Typography.Paragraph>
<Tabs
activeKey={tabValue}
defaultActiveKey="orchestration"
items={[
{ key: "orchestration", label: t("workflow.detail.orchestration.tab"), icon: <ApartmentOutlinedIcon /> },
{ key: "runs", label: t("workflow.detail.runs.tab"), icon: <HistoryOutlinedIcon /> },
]}
renderTabBar={(props, DefaultTabBar) => <DefaultTabBar {...props} style={{ margin: 0 }} />}
tabBarStyle={{ border: "none" }}
onChange={(key) => setTabValue(key as typeof tabValue)}
/>
</PageHeader>
</Card>
</div>
<Show when={tabValue === "orchestration"}>
<div className="min-h-[360px] flex-1 overflow-hidden p-4">
<Card
className="size-full overflow-hidden"
styles={{
body: {
position: "relative",
height: "100%",
padding: 0,
},
}}
loading={!initialized}
>
<div className="absolute inset-x-6 top-4 z-[2] flex items-center justify-between gap-4">
<div className="flex-1 overflow-hidden">
<Show when={workflow.hasDraft!}>
<Alert banner message={<div className="truncate">{t("workflow.detail.orchestration.draft.alert")}</div>} type="warning" />
</Show>
</div>
<div className="flex justify-end">
<Space>
<Button disabled={!allowRun} icon={<CaretRightOutlinedIcon />} loading={isRunning} type="primary" onClick={handleRunClick}>
{t("workflow.detail.orchestration.action.run")}
</Button>
<Button.Group>
<Button color="primary" disabled={!allowRelease} variant="outlined" onClick={handleReleaseClick}>
{t("workflow.detail.orchestration.action.release")}
</Button>
<Dropdown
menu={{
items: [
{
key: "discard",
disabled: !allowDiscard,
label: t("workflow.detail.orchestration.action.discard"),
icon: <UndoOutlinedIcon />,
onClick: handleDiscardClick,
},
],
}}
trigger={["click"]}
>
<Button color="primary" disabled={!allowDiscard} icon={<EllipsisOutlinedIcon />} variant="outlined" />
</Dropdown>
</Button.Group>
</Space>
</div>
</div>
<WorkflowElementsContainer className="pt-16" />
</Card>
</div>
</Show>
<Show when={tabValue === "runs"}>
<div className="p-4">
<Card loading={!initialized}>
<WorkflowRuns workflowId={workflowId!} />
</Card>
</div>
</Show>
</div>
);
};

View File

@ -3,9 +3,11 @@ import { useTranslation } from "react-i18next";
import { useNavigate, useSearchParams } from "react-router-dom";
import {
CheckCircleOutlined as CheckCircleOutlinedIcon,
ClockCircleOutlined as ClockCircleOutlinedIcon,
CloseCircleOutlined as CloseCircleOutlinedIcon,
DeleteOutlined as DeleteOutlinedIcon,
EditOutlined as EditOutlinedIcon,
PauseCircleOutlined as PauseCircleOutlinedIcon,
PlusOutlined as PlusOutlinedIcon,
SyncOutlined as SyncOutlinedIcon,
} from "@ant-design/icons";
@ -13,7 +15,6 @@ import {
import { PageHeader } from "@ant-design/pro-components";
import { useRequest } from "ahooks";
import {
Badge,
Button,
Divider,
Empty,
@ -159,32 +160,25 @@ const WorkflowList = () => {
key: "lastRun",
title: t("workflow.props.last_run_at"),
render: (_, record) => {
if (record.lastRunId) {
if (record.lastRunStatus === WORKFLOW_RUN_STATUSES.RUNNING) {
return (
<Space>
<Badge status="processing" count={<SyncOutlinedIcon style={{ color: themeToken.colorInfo }} />} />
<Typography.Text>{dayjs(record.lastRunTime!).format("YYYY-MM-DD HH:mm:ss")}</Typography.Text>
</Space>
);
} else if (record.lastRunStatus === WORKFLOW_RUN_STATUSES.SUCCEEDED) {
return (
<Space>
<Badge status="success" count={<CheckCircleOutlinedIcon style={{ color: themeToken.colorSuccess }} />} />
<Typography.Text>{dayjs(record.lastRunTime!).format("YYYY-MM-DD HH:mm:ss")}</Typography.Text>
</Space>
);
} else if (record.lastRunStatus === WORKFLOW_RUN_STATUSES.FAILED) {
return (
<Space>
<Badge status="error" count={<CloseCircleOutlinedIcon style={{ color: themeToken.colorError }} />} />
<Typography.Text>{dayjs(record.lastRunTime!).format("YYYY-MM-DD HH:mm:ss")}</Typography.Text>
</Space>
);
}
let icon = <></>;
if (record.lastRunStatus === WORKFLOW_RUN_STATUSES.PENDING) {
icon = <ClockCircleOutlinedIcon style={{ color: themeToken.colorTextSecondary }} />;
} else if (record.lastRunStatus === WORKFLOW_RUN_STATUSES.RUNNING) {
icon = <SyncOutlinedIcon style={{ color: themeToken.colorInfo }} spin />;
} else if (record.lastRunStatus === WORKFLOW_RUN_STATUSES.SUCCEEDED) {
icon = <CheckCircleOutlinedIcon style={{ color: themeToken.colorSuccess }} />;
} else if (record.lastRunStatus === WORKFLOW_RUN_STATUSES.FAILED) {
icon = <CloseCircleOutlinedIcon style={{ color: themeToken.colorError }} />;
} else if (record.lastRunStatus === WORKFLOW_RUN_STATUSES.CANCELED) {
icon = <PauseCircleOutlinedIcon style={{ color: themeToken.colorWarning }} />;
}
return <></>;
return (
<Space>
{icon}
<Typography.Text>{record.lastRunTime ? dayjs(record.lastRunTime!).format("YYYY-MM-DD HH:mm:ss") : ""}</Typography.Text>
</Space>
);
},
},
{

View File

@ -109,7 +109,7 @@ const WorkflowNew = () => {
<div>
{NotificationContextHolder}
<Card styles={{ body: { padding: "0.5rem", paddingBottom: 0 } }}>
<Card styles={{ body: { padding: "0.5rem", paddingBottom: 0 } }} style={{ borderRadius: 0 }}>
<PageHeader title={t("workflow.new.title")}>
<Typography.Paragraph type="secondary">{t("workflow.new.subtitle")}</Typography.Paragraph>
</PageHeader>

View File

@ -14,7 +14,7 @@ export type ListWorkflowRunsRequest = {
export const list = async (request: ListWorkflowRunsRequest) => {
const page = request.page || 1;
const perPage = request.perPage || 10;
console.log("request.workflowId", request.workflowId);
let filter = "";
const params: Record<string, string> = {};
if (request.workflowId) {
@ -31,3 +31,7 @@ export const list = async (request: ListWorkflowRunsRequest) => {
expand: request.expand ? "workflowId" : undefined,
});
};
export const remove = async (record: MaybeModelRecordWithId<WorkflowRunModel>) => {
return await getPocketBase().collection(COLLECTION_NAME).delete(record.id);
};

6
ui/src/utils/css.ts Normal file
View File

@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export const mergeCls = (...inputs: ClassValue[]) => {
return twMerge(clsx(inputs));
};

View File

@ -1,5 +1,9 @@
import { ClientResponseError } from "pocketbase";
export const getErrMsg = (error: unknown): string => {
if (error instanceof Error) {
if (error instanceof ClientResponseError) {
return error.response != null ? getErrMsg(error.response) : error.message;
} else if (error instanceof Error) {
return error.message;
} else if (typeof error === "object" && error != null) {
if ("message" in error) {