diff --git a/internal/certapply/account.go b/internal/certapply/account.go
index f609dfce..34d53cea 100644
--- a/internal/certapply/account.go
+++ b/internal/certapply/account.go
@@ -7,6 +7,7 @@ import (
"crypto/rand"
"errors"
"fmt"
+ "strings"
"github.com/go-acme/lego/v4/lego"
"github.com/go-acme/lego/v4/registration"
@@ -70,10 +71,23 @@ func NewACMEAccount(config *ACMEConfig, email string, register bool) (*ACMEAccou
var regres *registration.Resource
var regerr error
if legoClient.GetExternalAccountRequired() {
+ if config.EABKid == "" {
+ return nil, errors.New("missing or invalid eab kid")
+ }
+ if config.EABHmacKey == "" {
+ return nil, errors.New("missing or invalid eab hmac key")
+ }
+
+ // patch, see https://github.com/go-acme/lego/issues/2634
+ keyId := strings.TrimSpace(config.EABKid)
+ keyEncoded := strings.TrimSpace(config.EABHmacKey)
+ keyEncoded = strings.ReplaceAll(strings.ReplaceAll(keyEncoded, "+", "-"), "/", "_")
+ keyEncoded = strings.TrimRight(keyEncoded, "=")
+
regres, regerr = legoClient.Registration.RegisterWithExternalAccountBinding(registration.RegisterEABOptions{
TermsOfServiceAgreed: true,
- Kid: config.EABKid,
- HmacEncoded: config.EABHmacEncoded,
+ Kid: keyId,
+ HmacEncoded: keyEncoded,
})
} else {
regres, regerr = legoClient.Registration.Register(registration.RegisterOptions{
@@ -81,7 +95,7 @@ func NewACMEAccount(config *ACMEConfig, email string, register bool) (*ACMEAccou
})
}
if regerr != nil {
- return nil, fmt.Errorf("failed to register acme account: %w", err)
+ return nil, fmt.Errorf("failed to register acme account: %w", regerr)
}
account.ACMEAccount = ®res.Body
diff --git a/internal/certapply/config.go b/internal/certapply/config.go
index a1eaeb73..8b36d778 100644
--- a/internal/certapply/config.go
+++ b/internal/certapply/config.go
@@ -34,7 +34,7 @@ type ACMEConfig struct {
CAProvider domain.CAProviderType
CADirUrl string
EABKid string
- EABHmacEncoded string
+ EABHmacKey string
CertifierKeyType certcrypto.KeyType
}
@@ -93,12 +93,12 @@ func NewACMEConfig(options *ACMEConfigOptions) (*ACMEConfig, error) {
ca.CADirUrl = endpoint
}
- eab := &domain.AccessConfigForACMEExternalAccountBinding{}
+ eab := domain.AccessConfigForACMEExternalAccountBinding{}
if err := xmaps.Populate(caAccessConfig, &eab); err != nil {
return nil, err
}
ca.EABKid = eab.EabKid
- ca.EABHmacEncoded = eab.EabHmacKey
+ ca.EABHmacKey = eab.EabHmacKey
return ca, nil
}
diff --git a/internal/domain/expr/expr.go b/internal/domain/expr/expr.go
index 755a876c..e42a3967 100644
--- a/internal/domain/expr/expr.go
+++ b/internal/domain/expr/expr.go
@@ -53,7 +53,7 @@ func (e *EvalResult) GetFloat64() (float64, error) {
floatValue, err := strconv.ParseFloat(stringValue, 64)
if err != nil {
- return 0, fmt.Errorf("failed to parse float64: %v", err)
+ return 0, fmt.Errorf("failed to parse float64: %w", err)
}
return floatValue, nil
}
diff --git a/internal/workflow/dispatcher/dispatcher.go b/internal/workflow/dispatcher/dispatcher.go
index 3802d10c..b53189e6 100644
--- a/internal/workflow/dispatcher/dispatcher.go
+++ b/internal/workflow/dispatcher/dispatcher.go
@@ -330,8 +330,8 @@ func (wd *workflowDispatcher) tryNextAsync() {
if hasSameWorkflowTask {
wd.syslog.Warn(fmt.Sprintf("workflow run #%s is pending, because tasks that belonging to the same workflow already exists", pendingRunId))
- } else if len(wd.processingTasks) >= wd.concurrency {
- wd.syslog.Warn(fmt.Sprintf("workflow run #%s is pending, because the maximum concurrency limit has been reached", pendingRunId))
+ } else if len(wd.processingTasks) >= wd.concurrency && wd.concurrency > 0 {
+ wd.syslog.Warn(fmt.Sprintf("workflow run #%s is pending, because the maximum concurrency (limit: %d) has been reached", pendingRunId, wd.concurrency))
} else {
wd.taskMtx.RUnlock()
wd.taskMtx.Lock()
diff --git a/internal/workflow/engine/engine.go b/internal/workflow/engine/engine.go
index 210c800c..39a13143 100644
--- a/internal/workflow/engine/engine.go
+++ b/internal/workflow/engine/engine.go
@@ -180,7 +180,7 @@ func (we *workflowEngine) executeNode(wfCtx *WorkflowContext, node *Node) error
})
}
if _, err := we.wfoutputRepo.Save(execCtx.ctx, output); err != nil {
- we.syslog.Warn("failed to save node output")
+ we.syslog.Error("failed to save node output", slog.Any("error", err))
}
}
diff --git a/internal/workflow/engine/executor_bizapply.go b/internal/workflow/engine/executor_bizapply.go
index 2d6c1921..7f8e16f4 100644
--- a/internal/workflow/engine/executor_bizapply.go
+++ b/internal/workflow/engine/executor_bizapply.go
@@ -87,7 +87,7 @@ func (ne *bizApplyNodeExecutor) Execute(execCtx *NodeExecutionContext) (*NodeExe
// 解析证书
certX509, err := xcert.ParseCertificateFromPEM(obtainResp.FullChainCertificate)
if err != nil {
- ne.logger.Warn("failed to parse certificate, may be the CA responded error")
+ ne.logger.Warn("could not parse certificate, may be the CA responded error")
return execRes, err
}
@@ -106,7 +106,7 @@ func (ne *bizApplyNodeExecutor) Execute(execCtx *NodeExecutionContext) (*NodeExe
}
certificate.PopulateFromX509(certX509)
if certificate, err := ne.certificateRepo.Save(execCtx.ctx, certificate); err != nil {
- ne.logger.Warn("failed to save certificate")
+ ne.logger.Warn("could not save certificate")
return execRes, err
} else {
ne.logger.Info("certificate saved", slog.String("recordId", certificate.Id))
@@ -232,7 +232,7 @@ func (ne *bizApplyNodeExecutor) executeObtain(execCtx *NodeExecutionContext, nod
}
legoConfig, err := certapply.NewACMEConfig(legoOptions)
if err != nil {
- ne.logger.Warn("failed to initialize acme config")
+ ne.logger.Warn("could not initialize acme config")
return nil, err
} else {
ne.logger.Info("acme config initialized", slog.String("acmeDirUrl", legoConfig.CADirUrl))
@@ -242,7 +242,7 @@ func (ne *bizApplyNodeExecutor) executeObtain(execCtx *NodeExecutionContext, nod
// 注意此步骤仍需在主进程中进行,以保证并发安全
legoUser, err := certapply.NewACMEAccountWithSingleFlight(legoConfig, nodeCfg.ContactEmail)
if err != nil {
- ne.logger.Warn("failed to initialize acme account")
+ ne.logger.Warn("could not initialize acme account")
return nil, err
} else {
ne.logger.Info("acme account initialized", slog.String("acmeAcctUrl", legoUser.ACMEAcctUrl))
@@ -320,7 +320,7 @@ func (ne *bizApplyNodeExecutor) executeObtain(execCtx *NodeExecutionContext, nod
Request: obtainReq,
})
if err != nil {
- ne.logger.Warn("failed to obtain certificate")
+ ne.logger.Warn("could not obtain certificate")
return nil, err
}
@@ -339,14 +339,14 @@ func (ne *bizApplyNodeExecutor) executeObtain(execCtx *NodeExecutionContext, nod
return nil
})
if err != nil {
- ne.logger.Warn("failed to initialize acme client")
+ ne.logger.Warn("could not initialize acme client")
return nil, err
}
// 执行申请证书请求
obtainResp, err := legoClient.ObtainCertificate(execCtx.ctx, obtainReq)
if err != nil {
- ne.logger.Warn("failed to obtain certificate")
+ ne.logger.Warn("could not obtain certificate")
return nil, err
}
diff --git a/internal/workflow/engine/executor_bizdeploy.go b/internal/workflow/engine/executor_bizdeploy.go
index 9f8b98b8..1f05e5f5 100644
--- a/internal/workflow/engine/executor_bizdeploy.go
+++ b/internal/workflow/engine/executor_bizdeploy.go
@@ -43,7 +43,7 @@ func (ne *bizDeployNodeExecutor) Execute(execCtx *NodeExecutionContext) (*NodeEx
if len(s) == 2 {
certificate, err := ne.certificateRepo.GetById(execCtx.ctx, s[1])
if err != nil {
- ne.logger.Warn("failed to get input certificate")
+ ne.logger.Warn("could not get input certificate")
return execRes, err
}
@@ -89,7 +89,7 @@ func (ne *bizDeployNodeExecutor) Execute(execCtx *NodeExecutionContext) (*NodeEx
PrivateKey: inputCertificate.PrivateKey,
}
if _, err := deployClient.DeployCertificate(execCtx.ctx, deployReq); err != nil {
- ne.logger.Warn("failed to deploy certificate")
+ ne.logger.Warn("could not deploy certificate")
return execRes, err
}
diff --git a/internal/workflow/engine/executor_bizmonitor.go b/internal/workflow/engine/executor_bizmonitor.go
index d4616e01..b3304e71 100644
--- a/internal/workflow/engine/executor_bizmonitor.go
+++ b/internal/workflow/engine/executor_bizmonitor.go
@@ -67,7 +67,7 @@ func (ne *bizMonitorNodeExecutor) Execute(execCtx *NodeExecutionContext) (*NodeE
}
if err != nil {
- ne.logger.Warn("failed to monitor certificate")
+ ne.logger.Warn("could not retrieve certificate")
return execRes, err
} else {
if len(certs) == 0 {
diff --git a/internal/workflow/engine/executor_biznotify.go b/internal/workflow/engine/executor_biznotify.go
index e1410fae..5ba43d72 100644
--- a/internal/workflow/engine/executor_biznotify.go
+++ b/internal/workflow/engine/executor_biznotify.go
@@ -49,7 +49,7 @@ func (ne *bizNotifyNodeExecutor) Execute(execCtx *NodeExecutionContext) (*NodeEx
Message: nodeCfg.Message,
}
if _, err := notifyClient.SendNotification(execCtx.ctx, notifyReq); err != nil {
- ne.logger.Warn("failed to send notification")
+ ne.logger.Warn("could not send notification")
return execRes, err
}
diff --git a/internal/workflow/engine/executor_bizupload.go b/internal/workflow/engine/executor_bizupload.go
index aa8a5eef..c62cea79 100644
--- a/internal/workflow/engine/executor_bizupload.go
+++ b/internal/workflow/engine/executor_bizupload.go
@@ -63,7 +63,7 @@ func (ne *bizUploadNodeExecutor) Execute(execCtx *NodeExecutionContext) (*NodeEx
}
certificate.PopulateFromPEM(nodeCfg.Certificate, nodeCfg.PrivateKey)
if certificate, err := ne.certificateRepo.Save(execCtx.ctx, certificate); err != nil {
- ne.logger.Warn("failed to save certificate")
+ ne.logger.Warn("could not save certificate")
return execRes, err
} else {
ne.logger.Info("certificate saved", slog.String("recordId", certificate.Id))
diff --git a/migrations/1756296000_cm0.4.0_migrate.go b/migrations/1756296000_cm0.4.0_migrate.go
index 51c91879..5e3500d9 100644
--- a/migrations/1756296000_cm0.4.0_migrate.go
+++ b/migrations/1756296000_cm0.4.0_migrate.go
@@ -1304,6 +1304,23 @@ func init() {
}
}
}
+
+ // normalize field `nodeId` in collection `workflow`, `workflow_run`, `workflow_output`, `workflow_logs`
+ for i := 0; i < 3; i++ {
+ app.DB().NewQuery(`UPDATE workflow SET graphDraft=REPLACE(graphDraft, '"id":"-', '"id":"')`).Execute()
+ app.DB().NewQuery(`UPDATE workflow SET graphDraft=REPLACE(graphDraft, '"id":"_', '"id":"')`).Execute()
+ app.DB().NewQuery(`UPDATE workflow SET graphContent=REPLACE(graphContent, '"id":"-', '"id":"')`).Execute()
+ app.DB().NewQuery(`UPDATE workflow SET graphContent=REPLACE(graphContent, '"id":"_', '"id":"')`).Execute()
+
+ app.DB().NewQuery(`UPDATE workflow_run SET graph=REPLACE(graph, '"id":"-', '"id":"')`).Execute()
+ app.DB().NewQuery(`UPDATE workflow_run SET graph=REPLACE(graph, '"id":"_', '"id":"')`).Execute()
+
+ app.DB().NewQuery(`UPDATE workflow_output SET nodeId=SUBSTR(nodeId, 2) WHERE nodeId LIKE '-%'`).Execute()
+ app.DB().NewQuery(`UPDATE workflow_output SET nodeId=SUBSTR(nodeId, 2) WHERE nodeId LIKE '_%'`).Execute()
+
+ app.DB().NewQuery(`UPDATE workflow_logs SET nodeId=SUBSTR(nodeId, 2) WHERE nodeId LIKE '-%'`).Execute()
+ app.DB().NewQuery(`UPDATE workflow_logs SET nodeId=SUBSTR(nodeId, 2) WHERE nodeId LIKE '_%'`).Execute()
+ }
}
tracer.Printf("done")
diff --git a/pkg/utils/filepath/path.go b/pkg/utils/filepath/path.go
new file mode 100644
index 00000000..48d1e132
--- /dev/null
+++ b/pkg/utils/filepath/path.go
@@ -0,0 +1,33 @@
+package filepath
+
+import (
+ stdfilepath "path/filepath"
+ "strings"
+)
+
+// 与标准库中的 [filepath.Dir] 类似,但会尝试保留原有的路径分隔符。
+//
+// 入参:
+// - path: 文件路径。
+//
+// 出参:
+// - 目录路径。
+func Dir(path string) string {
+ const SEP_WIN = "\\"
+ const SEP_UNIX = "/"
+
+ sep := SEP_UNIX
+ if strings.Contains(path, SEP_WIN) && !strings.Contains(path, SEP_UNIX) {
+ sep = SEP_WIN
+ }
+
+ dir := stdfilepath.Dir(path)
+
+ if sep != SEP_UNIX && strings.Contains(dir, SEP_UNIX) {
+ dir = strings.ReplaceAll(dir, SEP_UNIX, sep)
+ } else if sep != SEP_WIN && strings.Contains(dir, SEP_WIN) {
+ dir = strings.ReplaceAll(dir, SEP_WIN, sep)
+ }
+
+ return dir
+}
diff --git a/pkg/utils/ssh/io.go b/pkg/utils/ssh/io.go
index 7261e58f..ff1d7753 100644
--- a/pkg/utils/ssh/io.go
+++ b/pkg/utils/ssh/io.go
@@ -5,11 +5,12 @@ import (
"errors"
"fmt"
"os"
- "path/filepath"
"github.com/pkg/sftp"
"github.com/povsister/scp"
"golang.org/x/crypto/ssh"
+
+ xfilepath "github.com/certimate-go/certimate/pkg/utils/filepath"
)
// 与 [WriteRemote] 类似,但写入的是字符串内容。
@@ -97,7 +98,7 @@ func writeRemoteWithSFTP(sshCli *ssh.Client, path string, data []byte) error {
}
defer sftpCli.Close()
- if err := sftpCli.MkdirAll(filepath.ToSlash(filepath.Dir(path))); err != nil {
+ if err := sftpCli.MkdirAll(xfilepath.Dir(path)); err != nil {
return fmt.Errorf("failed to create remote directory: %w", err)
}
@@ -122,7 +123,7 @@ func removeRemoteWithSFTP(sshCli *ssh.Client, path string) error {
}
defer sftpCli.Close()
- if err := sftpCli.MkdirAll(filepath.ToSlash(filepath.Dir(path))); err != nil {
+ if err := sftpCli.MkdirAll(xfilepath.Dir(path)); err != nil {
return fmt.Errorf("failed to create remote directory: %w", err)
}
diff --git a/ui/src/components/access/AccessEditDrawer.tsx b/ui/src/components/access/AccessEditDrawer.tsx
index df5a2c26..75a9e050 100644
--- a/ui/src/components/access/AccessEditDrawer.tsx
+++ b/ui/src/components/access/AccessEditDrawer.tsx
@@ -29,7 +29,7 @@ export interface AccessEditDrawerProps {
const AccessEditDrawer = ({ afterClose, afterSubmit, mode, data, loading, trigger, usage, ...props }: AccessEditDrawerProps) => {
const { t } = useTranslation();
- const { notification } = App.useApp();
+ const { message, notification } = App.useApp();
const { createAccess, updateAccess } = useAccessesStore(useZustandShallowSelector(["createAccess", "updateAccess"]));
@@ -57,6 +57,8 @@ const AccessEditDrawer = ({ afterClose, afterSubmit, mode, data, loading, trigge
try {
await formInst.validateFields();
} catch (err) {
+ message.warning(t("common.errmsg.form_invalid"));
+
setFormPending(false);
throw err;
}
diff --git a/ui/src/components/workflow/designer/forms/BizDeployNodeConfigFieldsProviderLocal.tsx b/ui/src/components/workflow/designer/forms/BizDeployNodeConfigFieldsProviderLocal.tsx
index 8bbd6fc8..e12ca8fd 100644
--- a/ui/src/components/workflow/designer/forms/BizDeployNodeConfigFieldsProviderLocal.tsx
+++ b/ui/src/components/workflow/designer/forms/BizDeployNodeConfigFieldsProviderLocal.tsx
@@ -234,16 +234,6 @@ const BizDeployNodeConfigFieldsProviderLocal = () => {
/>
-
-
-
-
{
>
+
+
+
+
+
+ })
return z
.object({
format: z.literal([FORMAT_PEM, FORMAT_PFX, FORMAT_JKS], t("workflow_node.deploy.form.local_format.placeholder")),
+ keyPath: z
+ .string()
+ .max(256, t("common.errmsg.string_max", { max: 256 }))
+ .nullish(),
certPath: z
.string()
- .min(1, t("workflow_node.deploy.form.local_cert_path.tooltip"))
+ .min(1, t("workflow_node.deploy.form.local_cert_path.placeholder"))
.max(256, t("common.errmsg.string_max", { max: 256 })),
certPathForServerOnly: z
.string()
@@ -423,10 +429,6 @@ const getSchema = ({ i18n = getI18n() }: { i18n?: ReturnType })
.string()
.max(256, t("common.errmsg.string_max", { max: 256 }))
.nullish(),
- keyPath: z
- .string()
- .max(256, t("common.errmsg.string_max", { max: 256 }))
- .nullish(),
pfxPassword: z
.string()
.max(64, t("common.errmsg.string_max", { max: 256 }))
@@ -460,7 +462,7 @@ const getSchema = ({ i18n = getI18n() }: { i18n?: ReturnType })
if (!values.keyPath?.trim()) {
ctx.addIssue({
code: "custom",
- message: t("workflow_node.deploy.form.ssh_key_path.tooltip"),
+ message: t("workflow_node.deploy.form.local_key_path.placeholder"),
path: ["keyPath"],
});
}
@@ -472,7 +474,7 @@ const getSchema = ({ i18n = getI18n() }: { i18n?: ReturnType })
if (!values.pfxPassword?.trim()) {
ctx.addIssue({
code: "custom",
- message: t("workflow_node.deploy.form.ssh_pfx_password.tooltip"),
+ message: t("workflow_node.deploy.form.local_pfx_password.placeholder"),
path: ["pfxPassword"],
});
}
@@ -484,7 +486,7 @@ const getSchema = ({ i18n = getI18n() }: { i18n?: ReturnType })
if (!values.jksAlias?.trim()) {
ctx.addIssue({
code: "custom",
- message: t("workflow_node.deploy.form.ssh_jks_alias.tooltip"),
+ message: t("workflow_node.deploy.form.local_jks_alias.placeholder"),
path: ["jksAlias"],
});
}
@@ -492,7 +494,7 @@ const getSchema = ({ i18n = getI18n() }: { i18n?: ReturnType })
if (!values.jksKeypass?.trim()) {
ctx.addIssue({
code: "custom",
- message: t("workflow_node.deploy.form.ssh_jks_keypass.tooltip"),
+ message: t("workflow_node.deploy.form.local_jks_keypass.placeholder"),
path: ["jksKeypass"],
});
}
@@ -500,7 +502,7 @@ const getSchema = ({ i18n = getI18n() }: { i18n?: ReturnType })
if (!values.jksStorepass?.trim()) {
ctx.addIssue({
code: "custom",
- message: t("workflow_node.deploy.form.ssh_jks_storepass.tooltip"),
+ message: t("workflow_node.deploy.form.local_jks_storepass.placeholder"),
path: ["jksStorepass"],
});
}
diff --git a/ui/src/components/workflow/designer/forms/BizDeployNodeConfigFieldsProviderSSH.tsx b/ui/src/components/workflow/designer/forms/BizDeployNodeConfigFieldsProviderSSH.tsx
index b29e74ab..a6a57f68 100644
--- a/ui/src/components/workflow/designer/forms/BizDeployNodeConfigFieldsProviderSSH.tsx
+++ b/ui/src/components/workflow/designer/forms/BizDeployNodeConfigFieldsProviderSSH.tsx
@@ -266,7 +266,7 @@ const BizDeployNodeConfigFieldsProviderSSH = () => {
-
-
-
-
{
>
+
+
+
+
+
+ })
return z
.object({
format: z.literal([FORMAT_PEM, FORMAT_PFX, FORMAT_JKS], t("workflow_node.deploy.form.ssh_format.placeholder")),
- certPath: z
- .string()
- .min(1, t("workflow_node.deploy.form.ssh_cert_path.tooltip"))
- .max(256, t("common.errmsg.string_max", { max: 256 })),
keyPath: z
.string()
.max(256, t("common.errmsg.string_max", { max: 256 }))
.nullish(),
+ certPath: z
+ .string()
+ .min(1, t("workflow_node.deploy.form.ssh_cert_path.placeholder"))
+ .max(256, t("common.errmsg.string_max", { max: 256 })),
certPathForServerOnly: z
.string()
.max(256, t("common.errmsg.string_max", { max: 256 }))
@@ -502,7 +504,7 @@ const getSchema = ({ i18n = getI18n() }: { i18n?: ReturnType })
if (!values.keyPath?.trim()) {
ctx.addIssue({
code: "custom",
- message: t("workflow_node.deploy.form.ssh_key_path.tooltip"),
+ message: t("workflow_node.deploy.form.ssh_key_path.placeholder"),
path: ["keyPath"],
});
}
@@ -514,7 +516,7 @@ const getSchema = ({ i18n = getI18n() }: { i18n?: ReturnType })
if (!values.pfxPassword?.trim()) {
ctx.addIssue({
code: "custom",
- message: t("workflow_node.deploy.form.ssh_pfx_password.tooltip"),
+ message: t("workflow_node.deploy.form.ssh_pfx_password.placeholder"),
path: ["pfxPassword"],
});
}
@@ -526,7 +528,7 @@ const getSchema = ({ i18n = getI18n() }: { i18n?: ReturnType })
if (!values.jksAlias?.trim()) {
ctx.addIssue({
code: "custom",
- message: t("workflow_node.deploy.form.ssh_jks_alias.tooltip"),
+ message: t("workflow_node.deploy.form.ssh_jks_alias.placeholder"),
path: ["jksAlias"],
});
}
@@ -534,7 +536,7 @@ const getSchema = ({ i18n = getI18n() }: { i18n?: ReturnType })
if (!values.jksKeypass?.trim()) {
ctx.addIssue({
code: "custom",
- message: t("workflow_node.deploy.form.ssh_jks_keypass.tooltip"),
+ message: t("workflow_node.deploy.form.ssh_jks_keypass.placeholder"),
path: ["jksKeypass"],
});
}
@@ -542,7 +544,7 @@ const getSchema = ({ i18n = getI18n() }: { i18n?: ReturnType })
if (!values.jksStorepass?.trim()) {
ctx.addIssue({
code: "custom",
- message: t("workflow_node.deploy.form.ssh_jks_storepass.tooltip"),
+ message: t("workflow_node.deploy.form.ssh_jks_storepass.placeholder"),
path: ["jksStorepass"],
});
}
diff --git a/ui/src/components/workflow/designer/forms/_shared.tsx b/ui/src/components/workflow/designer/forms/_shared.tsx
index 63e23df4..e99cf638 100644
--- a/ui/src/components/workflow/designer/forms/_shared.tsx
+++ b/ui/src/components/workflow/designer/forms/_shared.tsx
@@ -84,7 +84,7 @@ export const NodeConfigDrawer = ({ children, afterClose, anchor, footer = true,
try {
await formInst.validateFields();
} catch (err) {
- message.warning(t("workflow.detail.design.drawer.errmsg.invalid_form"));
+ message.warning(t("common.errmsg.form_invalid"));
setFormPending(false);
throw err;
diff --git a/ui/src/domain/workflow.ts b/ui/src/domain/workflow.ts
index 84f3c3d3..158ed402 100644
--- a/ui/src/domain/workflow.ts
+++ b/ui/src/domain/workflow.ts
@@ -174,7 +174,9 @@ export const defaultNodeConfigForBizNotify = (): Partial {
- return nanoid();
+ return nanoid()
+ .replace(/^[_-]+/g, "")
+ .replace(/[_-]+$/g, "");
};
export const newNode = (type: WorkflowNodeType, { i18n = getI18n() }: { i18n?: ReturnType }): WorkflowNode => {
diff --git a/ui/src/i18n/locales/en/nls.common.json b/ui/src/i18n/locales/en/nls.common.json
index 829ef007..daa4fe6c 100644
--- a/ui/src/i18n/locales/en/nls.common.json
+++ b/ui/src/i18n/locales/en/nls.common.json
@@ -44,6 +44,7 @@
"common.errmsg.port_invalid": "Please enter a valid port",
"common.errmsg.ip_invalid": "Please enter a valid IP address",
"common.errmsg.url_invalid": "Please enter a valid URL",
+ "common.errmsg.form_invalid": "Please check the form content",
"common.notifier.bark": "Bark",
"common.notifier.dingtalk": "DingTalk",
diff --git a/ui/src/i18n/locales/en/nls.workflow.json b/ui/src/i18n/locales/en/nls.workflow.json
index 7f3e42bb..0deeadeb 100644
--- a/ui/src/i18n/locales/en/nls.workflow.json
+++ b/ui/src/i18n/locales/en/nls.workflow.json
@@ -76,7 +76,6 @@
"workflow.detail.design.drawer.node_id.label": "Node ID: ",
"workflow.detail.design.drawer.disabled.on.tooltip": "Disable",
"workflow.detail.design.drawer.disabled.off.tooltip": "Enable",
- "workflow.detail.design.drawer.errmsg.invalid_form": "Please check the form content",
"workflow.detail.design.action.publish.button": "Publish",
"workflow.detail.design.action.publish.modal.title": "Publish changes",
"workflow.detail.design.action.publish.modal.content": "Are you sure to publish your changes?",
diff --git a/ui/src/i18n/locales/en/nls.workflow.nodes.json b/ui/src/i18n/locales/en/nls.workflow.nodes.json
index e1d81920..60ed9ca9 100644
--- a/ui/src/i18n/locales/en/nls.workflow.nodes.json
+++ b/ui/src/i18n/locales/en/nls.workflow.nodes.json
@@ -11,10 +11,10 @@
"workflow_node.start.form.trigger.placeholder": "Please select trigger",
"workflow_node.start.form.trigger.option.scheduled.label": "Scheduled",
"workflow_node.start.form.trigger.option.manual.label": "Manual",
- "workflow_node.start.form.trigger_cron.label": "Cron expression",
- "workflow_node.start.form.trigger_cron.placeholder": "Please enter cron expression",
- "workflow_node.start.form.trigger_cron.errmsg.invalid": "Please enter a valid cron expression",
- "workflow_node.start.form.trigger_cron.tooltip": "Exactly 5 space separated segments.",
+ "workflow_node.start.form.trigger_cron.label": "CRON expression",
+ "workflow_node.start.form.trigger_cron.placeholder": "Please enter CRON expression",
+ "workflow_node.start.form.trigger_cron.errmsg.invalid": "Please enter a valid CRON expression",
+ "workflow_node.start.form.trigger_cron.tooltip": "Exactly 5 space separated segments, in standard crontab rules.",
"workflow_node.start.form.trigger_cron.help": "Expected execution time for the last 5 times (the actual time zone is based on the server):",
"workflow_node.start.form.trigger_cron.guide": "If you have multiple workflows, it is recommended to set them to run at different times of the day instead of always running at a specific time. And please don't always set it to midnight every day to avoid spikes in traffic.
Reference links: 1. Let’s Encrypt rate limits 2. Why should my Let’s Encrypt (ACME) client run at a random time?",
@@ -612,17 +612,19 @@
"workflow_node.deploy.form.local_format.option.pem.label": "PEM (*.pem, *.crt, *.key)",
"workflow_node.deploy.form.local_format.option.pfx.label": "PFX (*.pfx, *.p12)",
"workflow_node.deploy.form.local_format.option.jks.label": "JKS (*.jks)",
- "workflow_node.deploy.form.local_cert_path.label": "Certificate file saving path",
- "workflow_node.deploy.form.local_cert_path.placeholder": "Please enter saving path for certificate file",
- "workflow_node.deploy.form.local_cert_path.help": "Notes: It should include the full file path, not just the directory.",
- "workflow_node.deploy.form.local_key_path.label": "Certificate's private key file saving path",
- "workflow_node.deploy.form.local_key_path.placeholder": "Please enter saving path for certificate's private key file",
+ "workflow_node.deploy.form.local_key_path.label": "Private key file path",
+ "workflow_node.deploy.form.local_key_path.placeholder": "Please enter the local path for private key file",
"workflow_node.deploy.form.local_key_path.help": "Notes: It should include the full file path, not just the directory.",
- "workflow_node.deploy.form.local_servercert_path.label": "Server certificate file saving path (Optional)",
- "workflow_node.deploy.form.local_servercert_path.placeholder": "Please enter saving path for server certificate file",
+ "workflow_node.deploy.form.local_cert_path.label": "Certificate file path",
+ "workflow_node.deploy.form.local_cert_path.placeholder": "Please enter the local path for certificate file",
+ "workflow_node.deploy.form.local_cert_path.help": "Notes: It should include the full file path, not just the directory.",
+ "workflow_node.deploy.form.local_fullchaincert_path.label": "Bundled fullchain certificate file path",
+ "workflow_node.deploy.form.local_fullchaincert_path.placeholder": "Please enter the local path for bundled fullchain certificate",
+ "workflow_node.deploy.form.local_servercert_path.label": "Server certificate file path (Optional)",
+ "workflow_node.deploy.form.local_servercert_path.placeholder": "Please enter the local path for server certificate file",
"workflow_node.deploy.form.local_servercert_path.help": "Notes: It should include the full file path, not just the directory.",
- "workflow_node.deploy.form.local_intermediacert_path.label": "Intermediate CA certificate file saving path (Optional)",
- "workflow_node.deploy.form.local_intermediacert_path.placeholder": "Please enter saving path for intermediate CA certificate file",
+ "workflow_node.deploy.form.local_intermediacert_path.label": "Intermediate CA certificate file path (Optional)",
+ "workflow_node.deploy.form.local_intermediacert_path.placeholder": "Please enter the local path for intermediate CA certificate file",
"workflow_node.deploy.form.local_intermediacert_path.help": "Notes: It should include the full file path, not just the directory.",
"workflow_node.deploy.form.local_pfx_password.label": "PFX password",
"workflow_node.deploy.form.local_pfx_password.placeholder": "Please enter PFX password",
@@ -690,17 +692,19 @@
"workflow_node.deploy.form.ssh_format.option.pem.label": "PEM (*.pem, *.crt, *.key)",
"workflow_node.deploy.form.ssh_format.option.pfx.label": "PFX (*.pfx, *.p12)",
"workflow_node.deploy.form.ssh_format.option.jks.label": "JKS (*.jks)",
- "workflow_node.deploy.form.ssh_cert_path.label": "Certificate file uploading path",
- "workflow_node.deploy.form.ssh_cert_path.placeholder": "Please enter uploading path for certificate file",
- "workflow_node.deploy.form.ssh_cert_path.help": "Notes: It should include the full file path, not just the directory.",
- "workflow_node.deploy.form.ssh_key_path.label": "Certificate's private key file uploading path",
- "workflow_node.deploy.form.ssh_key_path.placeholder": "Please enter uploading path for certificate's private key file",
+ "workflow_node.deploy.form.ssh_key_path.label": "Private key file path",
+ "workflow_node.deploy.form.ssh_key_path.placeholder": "Please enter the remote path for private key file",
"workflow_node.deploy.form.ssh_key_path.help": "Notes: It should include the full file path, not just the directory.",
- "workflow_node.deploy.form.ssh_servercert_path.label": "Server certificate file uploading path (Optional)",
- "workflow_node.deploy.form.ssh_servercert_path.placeholder": "Please enter uploading path for server certificate file",
+ "workflow_node.deploy.form.ssh_cert_path.label": "Certificate file path",
+ "workflow_node.deploy.form.ssh_cert_path.placeholder": "Please enter the remote path for certificate",
+ "workflow_node.deploy.form.ssh_cert_path.help": "Notes: It should include the full file path, not just the directory.",
+ "workflow_node.deploy.form.ssh_fullchaincert_path.label": "Bundled fullchain certificate file path",
+ "workflow_node.deploy.form.ssh_fullchaincert_path.placeholder": "Please enter the remote path for bundled fullchain certificate",
+ "workflow_node.deploy.form.ssh_servercert_path.label": "Server certificate file path (Optional)",
+ "workflow_node.deploy.form.ssh_servercert_path.placeholder": "Please enter the remote path for server certificate file",
"workflow_node.deploy.form.ssh_servercert_path.help": "Notes: It should include the full file path, not just the directory.",
- "workflow_node.deploy.form.ssh_intermediacert_path.label": "Intermediate CA certificate file uploading path (Optional)",
- "workflow_node.deploy.form.ssh_intermediacert_path.placeholder": "Please enter uploading path for intermediate CA certificate file",
+ "workflow_node.deploy.form.ssh_intermediacert_path.label": "Intermediate CA certificate file path (Optional)",
+ "workflow_node.deploy.form.ssh_intermediacert_path.placeholder": "Please enter the remote path for intermediate CA certificate file",
"workflow_node.deploy.form.ssh_intermediacert_path.help": "Notes: It should include the full file path, not just the directory.",
"workflow_node.deploy.form.ssh_pfx_password.label": "PFX password",
"workflow_node.deploy.form.ssh_pfx_password.placeholder": "Please enter PFX password",
diff --git a/ui/src/i18n/locales/zh/nls.common.json b/ui/src/i18n/locales/zh/nls.common.json
index 5bfe67f3..731ef9e3 100644
--- a/ui/src/i18n/locales/zh/nls.common.json
+++ b/ui/src/i18n/locales/zh/nls.common.json
@@ -44,6 +44,7 @@
"common.errmsg.port_invalid": "请输入正确的端口号",
"common.errmsg.ip_invalid": "请输入正确的 IP 地址",
"common.errmsg.url_invalid": "请输入正确的 URL 地址",
+ "common.errmsg.form_invalid": "请检查表单内容",
"common.notifier.bark": "Bark",
"common.notifier.dingtalk": "钉钉",
diff --git a/ui/src/i18n/locales/zh/nls.workflow.json b/ui/src/i18n/locales/zh/nls.workflow.json
index 0667095d..67e5948b 100644
--- a/ui/src/i18n/locales/zh/nls.workflow.json
+++ b/ui/src/i18n/locales/zh/nls.workflow.json
@@ -76,7 +76,6 @@
"workflow.detail.design.drawer.node_id.label": "节点 ID:",
"workflow.detail.design.drawer.disabled.on.tooltip": "禁用",
"workflow.detail.design.drawer.disabled.off.tooltip": "启用",
- "workflow.detail.design.drawer.errmsg.invalid_form": "请检查表单内容",
"workflow.detail.design.action.publish.button": "发布更改",
"workflow.detail.design.action.publish.modal.title": "发布更改",
"workflow.detail.design.action.publish.modal.content": "确定要发布更改吗?",
diff --git a/ui/src/i18n/locales/zh/nls.workflow.nodes.json b/ui/src/i18n/locales/zh/nls.workflow.nodes.json
index d5dcfa17..e214f6e9 100644
--- a/ui/src/i18n/locales/zh/nls.workflow.nodes.json
+++ b/ui/src/i18n/locales/zh/nls.workflow.nodes.json
@@ -11,10 +11,10 @@
"workflow_node.start.form.trigger.placeholder": "请选择触发方式",
"workflow_node.start.form.trigger.option.scheduled.label": "定时触发",
"workflow_node.start.form.trigger.option.manual.label": "手动触发",
- "workflow_node.start.form.trigger_cron.label": "Cron 表达式",
- "workflow_node.start.form.trigger_cron.placeholder": "请输入 Cron 表达式",
- "workflow_node.start.form.trigger_cron.errmsg.invalid": "请输入正确的 Cron 表达式",
- "workflow_node.start.form.trigger_cron.tooltip": "五段式表达式。 支持使用任意值(即 *)、值列表分隔符(即 ,)、值的范围(即 -)、步骤值(即 /)等四种表达式。",
+ "workflow_node.start.form.trigger_cron.label": "CRON 表达式",
+ "workflow_node.start.form.trigger_cron.placeholder": "请输入 CRON 表达式",
+ "workflow_node.start.form.trigger_cron.errmsg.invalid": "请输入正确的 CRON 表达式",
+ "workflow_node.start.form.trigger_cron.tooltip": "五段式表达式,使用 crontab 标准语法规则。 支持使用任意值(即 *)、值列表分隔符(即 ,)、值的范围(即 -)、步骤值(即 /)等四种表达式。",
"workflow_node.start.form.trigger_cron.help": "预计最近 5 次运行时间(实际时区以服务器设置为准):",
"workflow_node.start.form.trigger_cron.guide": "如果你有多个工作流,建议将它们设置为在一天中的多个时间段运行,而非总是在相同的特定时间。也不要总是设置为每日零时,以免遭遇证书颁发机构的流量高峰。
参考链接: 1. Let’s Encrypt 速率限制 2. 为什么我的 Let’s Encrypt (ACME) 客户端启动时间应当随机?",
@@ -610,17 +610,19 @@
"workflow_node.deploy.form.local_format.option.pem.label": "PEM 格式(*.pem, *.crt, *.key)",
"workflow_node.deploy.form.local_format.option.pfx.label": "PFX 格式(*.pfx, *.p12)",
"workflow_node.deploy.form.local_format.option.jks.label": "JKS 格式(*.jks)",
- "workflow_node.deploy.form.local_cert_path.label": "证书文件保存路径",
- "workflow_node.deploy.form.local_cert_path.placeholder": "请输入证书文件保存路径",
- "workflow_node.deploy.form.local_cert_path.help": "注意:路径需包含完整的文件名,而不是只有目录。",
- "workflow_node.deploy.form.local_key_path.label": "证书私钥文件保存路径",
- "workflow_node.deploy.form.local_key_path.placeholder": "请输入证书私钥文件保存路径",
+ "workflow_node.deploy.form.local_key_path.label": "私钥文件路径",
+ "workflow_node.deploy.form.local_key_path.placeholder": "请输入私钥文件本地路径",
"workflow_node.deploy.form.local_key_path.help": "注意:路径需包含完整的文件名,而不是只有目录。",
- "workflow_node.deploy.form.local_servercert_path.label": "服务器证书文件保存路径(可选)",
- "workflow_node.deploy.form.local_servercert_path.placeholder": "请输入服务器证书文件保存路径",
+ "workflow_node.deploy.form.local_cert_path.label": "证书文件路径",
+ "workflow_node.deploy.form.local_cert_path.placeholder": "请输入证书文件本地路径",
+ "workflow_node.deploy.form.local_cert_path.help": "注意:路径需包含完整的文件名,而不是只有目录。",
+ "workflow_node.deploy.form.local_fullchaincert_path.label": "证书链文件路径",
+ "workflow_node.deploy.form.local_fullchaincert_path.placeholder": "请输入证书链文件本地路径",
+ "workflow_node.deploy.form.local_servercert_path.label": "服务器证书文件路径(可选)",
+ "workflow_node.deploy.form.local_servercert_path.placeholder": "请输入服务器证书文件本地路径",
"workflow_node.deploy.form.local_servercert_path.help": "注意:路径需包含完整的文件名,而不是只有目录。不填写时将不会保存服务器证书。",
- "workflow_node.deploy.form.local_intermediacert_path.label": "中间证书文件保存路径(可选)",
- "workflow_node.deploy.form.local_intermediacert_path.placeholder": "请输入中间证书文件保存路径",
+ "workflow_node.deploy.form.local_intermediacert_path.label": "中间证书文件路径(可选)",
+ "workflow_node.deploy.form.local_intermediacert_path.placeholder": "请输入中间证书文件本地路径",
"workflow_node.deploy.form.local_intermediacert_path.help": "注意:路径需包含完整的文件名,而不是只有目录。不填写时将不会保存中间证书。",
"workflow_node.deploy.form.local_pfx_password.label": "PFX 导出密码",
"workflow_node.deploy.form.local_pfx_password.placeholder": "请输入 PFX 导出密码",
@@ -688,19 +690,21 @@
"workflow_node.deploy.form.ssh_format.option.pem.label": "PEM 格式(*.pem, *.crt, *.key)",
"workflow_node.deploy.form.ssh_format.option.pfx.label": "PFX 格式(*.pfx, *.p12)",
"workflow_node.deploy.form.ssh_format.option.jks.label": "JKS 格式(*.jks)",
- "workflow_node.deploy.form.ssh_cert_path.label": "证书文件上传路径",
- "workflow_node.deploy.form.ssh_cert_path.placeholder": "请输入证书文件上传路径",
- "workflow_node.deploy.form.ssh_cert_path.help": "注意:路径需包含完整的文件名,而不是只有目录。",
- "workflow_node.deploy.form.ssh_key_path.label": "证书私钥文件上传路径",
- "workflow_node.deploy.form.ssh_key_path.placeholder": "请输入证书私钥文件上传路径",
+ "workflow_node.deploy.form.ssh_key_path.label": "私钥文件路径",
+ "workflow_node.deploy.form.ssh_key_path.placeholder": "请输入私钥文件远程路径",
"workflow_node.deploy.form.ssh_key_path.help": "注意:路径需包含完整的文件名,而不是只有目录。",
- "workflow_node.deploy.form.ssh_pfx_password.label": "PFX 导出密码",
- "workflow_node.deploy.form.ssh_servercert_path.label": "服务器证书文件上传路径(可选)",
- "workflow_node.deploy.form.ssh_servercert_path.placeholder": "请输入服务器证书文件上传路径",
+ "workflow_node.deploy.form.ssh_cert_path.label": "证书文件路径",
+ "workflow_node.deploy.form.ssh_cert_path.placeholder": "请输入证书文件远程路径",
+ "workflow_node.deploy.form.ssh_cert_path.help": "注意:路径需包含完整的文件名,而不是只有目录。",
+ "workflow_node.deploy.form.ssh_fullchaincert_path.label": "证书链文件路径",
+ "workflow_node.deploy.form.ssh_fullchaincert_path.placeholder": "请输入证书链文件远程路径",
+ "workflow_node.deploy.form.ssh_servercert_path.label": "服务器证书文件路径(可选)",
+ "workflow_node.deploy.form.ssh_servercert_path.placeholder": "请输入服务器证书文件远程路径",
"workflow_node.deploy.form.ssh_servercert_path.help": "注意:路径需包含完整的文件名,而不是只有目录。不填写时将不上传服务器证书。",
- "workflow_node.deploy.form.ssh_intermediacert_path.label": "中间证书文件上传路径(可选)",
- "workflow_node.deploy.form.ssh_intermediacert_path.placeholder": "请输入中间证书文件上传路径",
+ "workflow_node.deploy.form.ssh_intermediacert_path.label": "中间证书文件路径(可选)",
+ "workflow_node.deploy.form.ssh_intermediacert_path.placeholder": "请输入中间证书文件远程路径",
"workflow_node.deploy.form.ssh_intermediacert_path.help": "注意:路径需包含完整的文件名,而不是只有目录。不填写时将不上传中间证书。",
+ "workflow_node.deploy.form.ssh_pfx_password.label": "PFX 导出密码",
"workflow_node.deploy.form.ssh_pfx_password.placeholder": "请输入 PFX 导出密码",
"workflow_node.deploy.form.ssh_pfx_password.tooltip": "这是什么?请参阅 https://learn.microsoft.com/zh-cn/windows-hardware/drivers/install/personal-information-exchange---pfx--files",
"workflow_node.deploy.form.ssh_jks_alias.label": "JKS 别名",
diff --git a/ui/src/pages/dashboard/Dashboard.tsx b/ui/src/pages/dashboard/Dashboard.tsx
index dece63cc..618c3124 100644
--- a/ui/src/pages/dashboard/Dashboard.tsx
+++ b/ui/src/pages/dashboard/Dashboard.tsx
@@ -268,6 +268,12 @@ const WorkflowRunHistoryTable = ({ className, style }: { className?: string; sty
width: 48,
render: (_, __, index) => index + 1,
},
+ {
+ key: "id",
+ title: "ID",
+ width: 160,
+ render: (_, record) => {record.id},
+ },
{
key: "name",
title: t("workflow.props.name"),
diff --git a/ui/src/pages/workflows/WorkflowList.tsx b/ui/src/pages/workflows/WorkflowList.tsx
index c6d1ef7a..0eceb7a7 100644
--- a/ui/src/pages/workflows/WorkflowList.tsx
+++ b/ui/src/pages/workflows/WorkflowList.tsx
@@ -313,7 +313,7 @@ const WorkflowList = () => {
const handleRecordActiveChange = async (workflow: WorkflowModel) => {
try {
- if (!workflow.enabled && !workflow.graphContent) {
+ if (!workflow.enabled && !workflow.hasContent) {
message.warning(t("workflow.action.enable.errmsg.unpublished"));
return;
}
@@ -326,7 +326,8 @@ const WorkflowList = () => {
setTableData((prev) => {
return prev.map((item) => {
if (item.id === workflow.id) {
- return resp;
+ item.enabled = resp.enabled;
+ item.updated = resp.updated;
}
return item;
});
diff --git a/ui/src/utils/cron.ts b/ui/src/utils/cron.ts
index 46d806e3..8e3dd7e3 100644
--- a/ui/src/utils/cron.ts
+++ b/ui/src/utils/cron.ts
@@ -4,7 +4,16 @@ export const validCronExpression = (expr: string): boolean => {
try {
CronExpressionParser.parse(expr);
- if (expr.trim().split(" ").length !== 5) return false; // pocketbase 后端仅支持五段式的表达式
+ // pocketbase 后端仅支持标准 crontab 形式的表达式
+ // 这里转译了来自 pocketbase 的 golang 代码来验证
+ const segments = expr.trim().split(" ");
+ if (segments.length !== 5) return false;
+ parseCronSegment(segments[0], 0, 59);
+ parseCronSegment(segments[1], 0, 23);
+ parseCronSegment(segments[2], 1, 31);
+ parseCronSegment(segments[3], 1, 12);
+ parseCronSegment(segments[4], 0, 6);
+
return true;
} catch {
return false;
@@ -19,3 +28,83 @@ export const getNextCronExecutions = (expr: string, times = 1): Date[] => {
return cron.take(times).map((date) => date.toDate());
};
+
+// transpile from:
+// https://github.com/pocketbase/pocketbase/blob/5d964c1b1d020f425299b32df03ecf44e0a0502e/tools/cron/schedule.go#L141-L218
+function parseCronSegment(segment: string, min: number, max: number): Set {
+ const slots = new Set();
+
+ const list = segment.split(",");
+ for (const p of list) {
+ const stepParts = p.split("/");
+
+ let step: number;
+ switch (stepParts.length) {
+ case 1:
+ {
+ step = 1;
+ }
+ break;
+ case 2:
+ {
+ const parsedStep = parseInt(stepParts[1], 10);
+ if (isNaN(parsedStep) || parsedStep < 1 || parsedStep > max) {
+ throw new Error(`invalid segment step boundary - the step must be between 1 and the ${max}`);
+ }
+ step = parsedStep;
+ }
+ break;
+
+ default:
+ throw new Error("invalid segment step format - must be in the format */n or 1-30/n");
+ }
+
+ let rangeMin: number, rangeMax: number;
+ if (stepParts[0] === "*") {
+ rangeMin = min;
+ rangeMax = max;
+ } else {
+ const rangeParts = stepParts[0].split("-");
+ switch (rangeParts.length) {
+ case 1:
+ {
+ if (step !== 1) {
+ throw new Error("invalid segment step - step > 1 could be used only with the wildcard or range format");
+ }
+ const parsed = parseInt(rangeParts[0], 10);
+ if (isNaN(parsed) || parsed < min || parsed > max) {
+ throw new Error("invalid segment value - must be between the min and max of the segment");
+ }
+ rangeMin = parsed;
+ rangeMax = rangeMin;
+ }
+ break;
+
+ case 2:
+ {
+ const parsedMin = parseInt(rangeParts[0], 10);
+ if (isNaN(parsedMin) || parsedMin < min || parsedMin > max) {
+ throw new Error(`invalid segment range minimum - must be between ${min} and ${max}`);
+ }
+ rangeMin = parsedMin;
+
+ const parsedMax = parseInt(rangeParts[1], 10);
+ if (isNaN(parsedMax) || parsedMax < rangeMin || parsedMax > max) {
+ throw new Error(`invalid segment range maximum - must be between ${rangeMin} and ${max}`);
+ }
+ rangeMax = parsedMax;
+ }
+ break;
+
+ default:
+ throw new Error("invalid segment range format - the range must have 1 or 2 parts");
+ }
+ }
+
+ for (let i = rangeMin; i <= rangeMax; i += step) {
+ slots.add(i);
+ }
+ }
+
+ return slots;
+}