Merge pull request #953 from fudiwei/dev

This commit is contained in:
RHQYZ 2025-09-05 21:57:56 +08:00 committed by GitHub
commit 007385a071
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
27 changed files with 295 additions and 118 deletions

View File

@ -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 = &regres.Body

View File

@ -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
}

View File

@ -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
}

View File

@ -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()

View File

@ -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))
}
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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 {

View File

@ -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
}

View File

@ -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))

View File

@ -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")

View File

@ -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
}

View File

@ -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)
}

View File

@ -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;
}

View File

@ -234,16 +234,6 @@ const BizDeployNodeConfigFieldsProviderLocal = () => {
/>
</Form.Item>
<Form.Item
name={[parentNamePath, "certPath"]}
initialValue={initialValues.certPath}
label={t("workflow_node.deploy.form.local_cert_path.label")}
extra={t("workflow_node.deploy.form.local_cert_path.help")}
rules={[formRule]}
>
<Input placeholder={t("workflow_node.deploy.form.local_cert_path.placeholder")} />
</Form.Item>
<Show when={fieldFormat === FORMAT_PEM}>
<Form.Item
name={[parentNamePath, "keyPath"]}
@ -254,7 +244,19 @@ const BizDeployNodeConfigFieldsProviderLocal = () => {
>
<Input placeholder={t("workflow_node.deploy.form.local_key_path.placeholder")} />
</Form.Item>
</Show>
<Form.Item
name={[parentNamePath, "certPath"]}
initialValue={initialValues.certPath}
label={t(`workflow_node.deploy.form.local_${fieldFormat === FORMAT_PEM ? "fullchaincert" : "cert"}_path.label`)}
extra={t("workflow_node.deploy.form.local_cert_path.help")}
rules={[formRule]}
>
<Input placeholder={t(`workflow_node.deploy.form.local_${fieldFormat === FORMAT_PEM ? "fullchaincert" : "cert"}_path.placeholder`)} />
</Form.Item>
<Show when={fieldFormat === FORMAT_PEM}>
<Form.Item
name={[parentNamePath, "certPathForServerOnly"]}
initialValue={initialValues.certPathForServerOnly}
@ -411,9 +413,13 @@ const getSchema = ({ i18n = getI18n() }: { i18n?: ReturnType<typeof getI18n> })
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<typeof getI18n> })
.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<typeof getI18n> })
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<typeof getI18n> })
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<typeof getI18n> })
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<typeof getI18n> })
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<typeof getI18n> })
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"],
});
}

View File

@ -266,7 +266,7 @@ const BizDeployNodeConfigFieldsProviderSSH = () => {
<Select
options={[FORMAT_PEM, FORMAT_PFX, FORMAT_JKS].map((s) => ({
key: s,
label: <span className="font-mono">{t(`workflow_node.deploy.form.ssh_format.option.${s.toLowerCase()}.label`)}</span>,
label: t(`workflow_node.deploy.form.ssh_format.option.${s.toLowerCase()}.label`),
value: s,
}))}
placeholder={t("workflow_node.deploy.form.ssh_format.placeholder")}
@ -274,16 +274,6 @@ const BizDeployNodeConfigFieldsProviderSSH = () => {
/>
</Form.Item>
<Form.Item
name={[parentNamePath, "certPath"]}
initialValue={initialValues.certPath}
label={t("workflow_node.deploy.form.ssh_cert_path.label")}
extra={t("workflow_node.deploy.form.ssh_cert_path.help")}
rules={[formRule]}
>
<Input placeholder={t("workflow_node.deploy.form.ssh_cert_path.placeholder")} />
</Form.Item>
<Show when={fieldFormat === FORMAT_PEM}>
<Form.Item
name={[parentNamePath, "keyPath"]}
@ -294,7 +284,19 @@ const BizDeployNodeConfigFieldsProviderSSH = () => {
>
<Input placeholder={t("workflow_node.deploy.form.ssh_key_path.placeholder")} />
</Form.Item>
</Show>
<Form.Item
name={[parentNamePath, "certPath"]}
initialValue={initialValues.certPath}
label={t(`workflow_node.deploy.form.ssh_${fieldFormat === FORMAT_PEM ? "fullchaincert" : "cert"}_path.label`)}
extra={t("workflow_node.deploy.form.ssh_cert_path.help")}
rules={[formRule]}
>
<Input placeholder={t(`workflow_node.deploy.form.ssh_${fieldFormat === FORMAT_PEM ? "fullchaincert" : "cert"}_path.placeholder`)} />
</Form.Item>
<Show when={fieldFormat === FORMAT_PEM}>
<Form.Item
name={[parentNamePath, "certPathForServerOnly"]}
initialValue={initialValues.certPathForServerOnly}
@ -453,14 +455,14 @@ const getSchema = ({ i18n = getI18n() }: { i18n?: ReturnType<typeof getI18n> })
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<typeof getI18n> })
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<typeof getI18n> })
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<typeof getI18n> })
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<typeof getI18n> })
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<typeof getI18n> })
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"],
});
}

View File

@ -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;

View File

@ -174,7 +174,9 @@ export const defaultNodeConfigForBizNotify = (): Partial<WorkflowNodeConfigForBi
};
export const newNodeId = (): string => {
return nanoid();
return nanoid()
.replace(/^[_-]+/g, "")
.replace(/[_-]+$/g, "");
};
export const newNode = (type: WorkflowNodeType, { i18n = getI18n() }: { i18n?: ReturnType<typeof getI18n> }): WorkflowNode => {

View File

@ -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",

View File

@ -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?",

View File

@ -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 <em>crontab</em> 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. <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>",
@ -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",

View File

@ -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": "钉钉",

View File

@ -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": "确定要发布更改吗?",

View File

@ -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": "五段式表达式。<br>支持使用任意值(即 <strong>*</strong>)、值列表分隔符(即 <strong>,</strong>)、值的范围(即 <strong>-</strong>)、步骤值(即 <strong>/</strong>)等四种表达式。",
"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": "五段式表达式,使用 <em>crontab</em> 标准语法规则。<br>支持使用任意值(即 <strong>*</strong>)、值列表分隔符(即 <strong>,</strong>)、值的范围(即 <strong>-</strong>)、步骤值(即 <strong>/</strong>)等四种表达式。",
"workflow_node.start.form.trigger_cron.help": "预计最近 5 次运行时间(实际时区以服务器设置为准):",
"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>",
@ -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": "这是什么?请参阅 <a href=\"https://learn.microsoft.com/zh-cn/windows-hardware/drivers/install/personal-information-exchange---pfx--files\" target=\"_blank\">https://learn.microsoft.com/zh-cn/windows-hardware/drivers/install/personal-information-exchange---pfx--files</a>",
"workflow_node.deploy.form.ssh_jks_alias.label": "JKS 别名",

View File

@ -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) => <span className="font-mono">{record.id}</span>,
},
{
key: "name",
title: t("workflow.props.name"),

View File

@ -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;
});

View File

@ -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<number> {
const slots = new Set<number>();
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;
}