feat: auto cleanup workflow history runs and expired certificates

This commit is contained in:
Fu Diwei 2025-03-19 17:12:24 +08:00
parent 914c5b4870
commit e27d4f11ee
19 changed files with 355 additions and 55 deletions

View File

@ -11,6 +11,8 @@ import (
"time" "time"
"github.com/go-acme/lego/v4/certcrypto" "github.com/go-acme/lego/v4/certcrypto"
"github.com/pocketbase/dbx"
"github.com/usual2970/certimate/internal/app" "github.com/usual2970/certimate/internal/app"
"github.com/usual2970/certimate/internal/domain" "github.com/usual2970/certimate/internal/domain"
"github.com/usual2970/certimate/internal/domain/dtos" "github.com/usual2970/certimate/internal/domain/dtos"
@ -27,21 +29,29 @@ const (
type certificateRepository interface { type certificateRepository interface {
ListExpireSoon(ctx context.Context) ([]*domain.Certificate, error) ListExpireSoon(ctx context.Context) ([]*domain.Certificate, error)
GetById(ctx context.Context, id string) (*domain.Certificate, error) GetById(ctx context.Context, id string) (*domain.Certificate, error)
DeleteWhere(ctx context.Context, exprs ...dbx.Expression) (int, error)
}
type settingsRepository interface {
GetByName(ctx context.Context, name string) (*domain.Settings, error)
} }
type CertificateService struct { type CertificateService struct {
certRepo certificateRepository certificateRepo certificateRepository
settingsRepo settingsRepository
} }
func NewCertificateService(certRepo certificateRepository) *CertificateService { func NewCertificateService(certificateRepo certificateRepository, settingsRepo settingsRepository) *CertificateService {
return &CertificateService{ return &CertificateService{
certRepo: certRepo, certificateRepo: certificateRepo,
settingsRepo: settingsRepo,
} }
} }
func (s *CertificateService) InitSchedule(ctx context.Context) error { func (s *CertificateService) InitSchedule(ctx context.Context) error {
// 每日发送过期证书提醒
app.GetScheduler().MustAdd("certificateExpireSoonNotify", "0 0 * * *", func() { app.GetScheduler().MustAdd("certificateExpireSoonNotify", "0 0 * * *", func() {
certificates, err := s.certRepo.ListExpireSoon(context.Background()) certificates, err := s.certificateRepo.ListExpireSoon(context.Background())
if err != nil { if err != nil {
app.GetLogger().Error("failed to get certificates which expire soon", "err", err) app.GetLogger().Error("failed to get certificates which expire soon", "err", err)
return return
@ -56,11 +66,37 @@ func (s *CertificateService) InitSchedule(ctx context.Context) error {
app.GetLogger().Error("failed to send notification", "err", err) app.GetLogger().Error("failed to send notification", "err", err)
} }
}) })
// 每日清理过期证书
app.GetScheduler().MustAdd("certificateExpiredCleanup", "0 0 * * *", func() {
settings, err := s.settingsRepo.GetByName(ctx, "persistence")
if err != nil {
app.GetLogger().Error("failed to get persistence settings", "err", err)
return
}
var settingsContent *domain.PersistenceSettingsContent
json.Unmarshal([]byte(settings.Content), &settingsContent)
if settingsContent != nil && settingsContent.ExpiredCertificatesMaxDaysRetention != 0 {
ret, err := s.certificateRepo.DeleteWhere(
context.Background(),
dbx.NewExp(fmt.Sprintf("expireAt<DATETIME('now', '-%d days')", settingsContent.ExpiredCertificatesMaxDaysRetention)),
)
if err != nil {
app.GetLogger().Error("failed to delete expired certificates", "err", err)
}
if ret > 0 {
app.GetLogger().Info(fmt.Sprintf("cleanup %d expired certificates", ret))
}
}
})
return nil return nil
} }
func (s *CertificateService) ArchiveFile(ctx context.Context, req *dtos.CertificateArchiveFileReq) (*dtos.CertificateArchiveFileResp, error) { func (s *CertificateService) ArchiveFile(ctx context.Context, req *dtos.CertificateArchiveFileReq) (*dtos.CertificateArchiveFileResp, error) {
certificate, err := s.certRepo.GetById(ctx, req.CertificateId) certificate, err := s.certificateRepo.GetById(ctx, req.CertificateId)
if err != nil { if err != nil {
return nil, err return nil, err
} }

View File

@ -176,7 +176,7 @@ func createDeployer(options *deployerOptions) (deployer.Deployer, error) {
AccessKeyId: access.AccessKeyId, AccessKeyId: access.AccessKeyId,
AccessKeySecret: access.AccessKeySecret, AccessKeySecret: access.AccessKeySecret,
Region: maputil.GetString(options.ProviderDeployConfig, "region"), Region: maputil.GetString(options.ProviderDeployConfig, "region"),
ServiceVersion: maputil.GetString(options.ProviderDeployConfig, "serviceVersion"), ServiceVersion: maputil.GetOrDefaultString(options.ProviderDeployConfig, "serviceVersion", "3.0"),
Domain: maputil.GetString(options.ProviderDeployConfig, "domain"), Domain: maputil.GetString(options.ProviderDeployConfig, "domain"),
}) })
return deployer, err return deployer, err

View File

@ -14,12 +14,10 @@ type Settings struct {
} }
type NotifyTemplatesSettingsContent struct { type NotifyTemplatesSettingsContent struct {
NotifyTemplates []NotifyTemplate `json:"notifyTemplates"` NotifyTemplates []struct {
} Subject string `json:"subject"`
Message string `json:"message"`
type NotifyTemplate struct { } `json:"notifyTemplates"`
Subject string `json:"subject"`
Message string `json:"message"`
} }
type NotifyChannelsSettingsContent map[string]map[string]any type NotifyChannelsSettingsContent map[string]map[string]any
@ -37,3 +35,8 @@ func (s *Settings) GetNotifyChannelConfig(channel string) (map[string]any, error
return v, nil return v, nil
} }
type PersistenceSettingsContent struct {
WorkflowRunsMaxDaysRetention int `json:"workflowRunsMaxDaysRetention"`
ExpiredCertificatesMaxDaysRetention int `json:"expiredCertificatesMaxDaysRetention"`
}

View File

@ -23,7 +23,6 @@ type DeployerConfig struct {
// 阿里云地域。 // 阿里云地域。
Region string `json:"region"` Region string `json:"region"`
// 服务版本。 // 服务版本。
// 零值时默认为 "3.0"。
ServiceVersion string `json:"serviceVersion"` ServiceVersion string `json:"serviceVersion"`
// 自定义域名(不支持泛域名)。 // 自定义域名(不支持泛域名)。
Domain string `json:"domain"` Domain string `json:"domain"`
@ -70,7 +69,7 @@ func (d *DeployerProvider) WithLogger(logger *slog.Logger) deployer.Deployer {
func (d *DeployerProvider) Deploy(ctx context.Context, certPem string, privkeyPem string) (*deployer.DeployResult, error) { func (d *DeployerProvider) Deploy(ctx context.Context, certPem string, privkeyPem string) (*deployer.DeployResult, error) {
switch d.config.ServiceVersion { switch d.config.ServiceVersion {
case "", "3.0": case "3.0":
if err := d.deployToFC3(ctx, certPem, privkeyPem); err != nil { if err := d.deployToFC3(ctx, certPem, privkeyPem); err != nil {
return nil, err return nil, err
} }

View File

@ -67,11 +67,9 @@ func (r *CertificateRepository) GetByWorkflowNodeId(ctx context.Context, workflo
dbx.Params{"workflowNodeId": workflowNodeId}, dbx.Params{"workflowNodeId": workflowNodeId},
) )
if err != nil { if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, domain.ErrRecordNotFound
}
return nil, err return nil, err
} }
if len(records) == 0 { if len(records) == 0 {
return nil, domain.ErrRecordNotFound return nil, domain.ErrRecordNotFound
} }
@ -125,6 +123,29 @@ func (r *CertificateRepository) Save(ctx context.Context, certificate *domain.Ce
return certificate, nil return certificate, nil
} }
func (r *CertificateRepository) DeleteWhere(ctx context.Context, exprs ...dbx.Expression) (int, error) {
records, err := app.GetApp().FindAllRecords(domain.CollectionNameCertificate, exprs...)
if err != nil {
return 0, nil
}
var ret int
var errs []error
for _, record := range records {
if err := app.GetApp().Delete(record); err != nil {
errs = append(errs, err)
} else {
ret++
}
}
if len(errs) > 0 {
return ret, errors.Join(errs...)
}
return ret, nil
}
func (r *CertificateRepository) castRecordToModel(record *core.Record) (*domain.Certificate, error) { func (r *CertificateRepository) castRecordToModel(record *core.Record) (*domain.Certificate, error) {
if record == nil { if record == nil {
return nil, fmt.Errorf("record is nil") return nil, fmt.Errorf("record is nil")

View File

@ -6,6 +6,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core" "github.com/pocketbase/pocketbase/core"
"github.com/usual2970/certimate/internal/app" "github.com/usual2970/certimate/internal/app"
"github.com/usual2970/certimate/internal/domain" "github.com/usual2970/certimate/internal/domain"
@ -96,6 +97,29 @@ func (r *WorkflowRunRepository) Save(ctx context.Context, workflowRun *domain.Wo
return workflowRun, nil return workflowRun, nil
} }
func (r *WorkflowRunRepository) DeleteWhere(ctx context.Context, exprs ...dbx.Expression) (int, error) {
records, err := app.GetApp().FindAllRecords(domain.CollectionNameWorkflowRun, exprs...)
if err != nil {
return 0, nil
}
var ret int
var errs []error
for _, record := range records {
if err := app.GetApp().Delete(record); err != nil {
errs = append(errs, err)
} else {
ret++
}
}
if len(errs) > 0 {
return ret, errors.Join(errs...)
}
return ret, nil
}
func (r *WorkflowRunRepository) castRecordToModel(record *core.Record) (*domain.WorkflowRun, error) { func (r *WorkflowRunRepository) castRecordToModel(record *core.Record) (*domain.WorkflowRun, error) {
if record == nil { if record == nil {
return nil, fmt.Errorf("record is nil") return nil, fmt.Errorf("record is nil")

View File

@ -23,17 +23,15 @@ var (
) )
func Register(router *router.Router[*core.RequestEvent]) { func Register(router *router.Router[*core.RequestEvent]) {
certificateRepo := repository.NewCertificateRepository()
certificateSvc = certificate.NewCertificateService(certificateRepo)
workflowRepo := repository.NewWorkflowRepository() workflowRepo := repository.NewWorkflowRepository()
workflowRunRepo := repository.NewWorkflowRunRepository() workflowRunRepo := repository.NewWorkflowRunRepository()
workflowSvc = workflow.NewWorkflowService(workflowRepo, workflowRunRepo) certificateRepo := repository.NewCertificateRepository()
statisticsRepo := repository.NewStatisticsRepository()
statisticsSvc = statistics.NewStatisticsService(statisticsRepo)
settingsRepo := repository.NewSettingsRepository() settingsRepo := repository.NewSettingsRepository()
statisticsRepo := repository.NewStatisticsRepository()
certificateSvc = certificate.NewCertificateService(certificateRepo, settingsRepo)
workflowSvc = workflow.NewWorkflowService(workflowRepo, workflowRunRepo, settingsRepo)
statisticsSvc = statistics.NewStatisticsService(statisticsRepo)
notifySvc = notify.NewNotifyService(settingsRepo) notifySvc = notify.NewNotifyService(settingsRepo)
group := router.Group("/api") group := router.Group("/api")

View File

@ -10,10 +10,11 @@ import (
func Register() { func Register() {
workflowRepo := repository.NewWorkflowRepository() workflowRepo := repository.NewWorkflowRepository()
workflowRunRepo := repository.NewWorkflowRunRepository() workflowRunRepo := repository.NewWorkflowRunRepository()
workflowSvc := workflow.NewWorkflowService(workflowRepo, workflowRunRepo)
certificateRepo := repository.NewCertificateRepository() certificateRepo := repository.NewCertificateRepository()
certificateSvc := certificate.NewCertificateService(certificateRepo) settingsRepo := repository.NewSettingsRepository()
workflowSvc := workflow.NewWorkflowService(workflowRepo, workflowRunRepo, settingsRepo)
certificateSvc := certificate.NewCertificateService(certificateRepo, settingsRepo)
if err := InitWorkflowScheduler(workflowSvc); err != nil { if err := InitWorkflowScheduler(workflowSvc); err != nil {
app.GetLogger().Error("failed to init workflow scheduler", "err", err) app.GetLogger().Error("failed to init workflow scheduler", "err", 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() { err := scheduler.Add(fmt.Sprintf("workflow#%s", workflowId), record.GetString("triggerCron"), func() {
workflowSrv := NewWorkflowService(repository.NewWorkflowRepository(), repository.NewWorkflowRunRepository()) workflowSrv := NewWorkflowService(repository.NewWorkflowRepository(), repository.NewWorkflowRunRepository(), repository.NewSettingsRepository())
workflowSrv.StartRun(ctx, &dtos.WorkflowStartRunReq{ workflowSrv.StartRun(ctx, &dtos.WorkflowStartRunReq{
WorkflowId: workflowId, WorkflowId: workflowId,
RunTrigger: domain.WorkflowTriggerTypeAuto, RunTrigger: domain.WorkflowTriggerTypeAuto,

View File

@ -2,10 +2,13 @@ package workflow
import ( import (
"context" "context"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"time" "time"
"github.com/pocketbase/dbx"
"github.com/usual2970/certimate/internal/app" "github.com/usual2970/certimate/internal/app"
"github.com/usual2970/certimate/internal/domain" "github.com/usual2970/certimate/internal/domain"
"github.com/usual2970/certimate/internal/domain/dtos" "github.com/usual2970/certimate/internal/domain/dtos"
@ -21,6 +24,11 @@ type workflowRepository interface {
type workflowRunRepository interface { type workflowRunRepository interface {
GetById(ctx context.Context, id string) (*domain.WorkflowRun, error) GetById(ctx context.Context, id string) (*domain.WorkflowRun, error)
Save(ctx context.Context, workflowRun *domain.WorkflowRun) (*domain.WorkflowRun, error) Save(ctx context.Context, workflowRun *domain.WorkflowRun) (*domain.WorkflowRun, error)
DeleteWhere(ctx context.Context, exprs ...dbx.Expression) (int, error)
}
type settingsRepository interface {
GetByName(ctx context.Context, name string) (*domain.Settings, error)
} }
type WorkflowService struct { type WorkflowService struct {
@ -28,40 +36,71 @@ type WorkflowService struct {
workflowRepo workflowRepository workflowRepo workflowRepository
workflowRunRepo workflowRunRepository workflowRunRepo workflowRunRepository
settingsRepo settingsRepository
} }
func NewWorkflowService(workflowRepo workflowRepository, workflowRunRepo workflowRunRepository) *WorkflowService { func NewWorkflowService(workflowRepo workflowRepository, workflowRunRepo workflowRunRepository, settingsRepo settingsRepository) *WorkflowService {
srv := &WorkflowService{ srv := &WorkflowService{
dispatcher: dispatcher.GetSingletonDispatcher(), dispatcher: dispatcher.GetSingletonDispatcher(),
workflowRepo: workflowRepo, workflowRepo: workflowRepo,
workflowRunRepo: workflowRunRepo, workflowRunRepo: workflowRunRepo,
settingsRepo: settingsRepo,
} }
return srv return srv
} }
func (s *WorkflowService) InitSchedule(ctx context.Context) error { func (s *WorkflowService) InitSchedule(ctx context.Context) error {
workflows, err := s.workflowRepo.ListEnabledAuto(ctx) // 每日清理工作流执行历史
if err != nil { app.GetScheduler().MustAdd("workflowHistoryRunsCleanup", "0 0 * * *", func() {
return err settings, err := s.settingsRepo.GetByName(ctx, "persistence")
}
scheduler := app.GetScheduler()
for _, workflow := range workflows {
var errs []error
err := scheduler.Add(fmt.Sprintf("workflow#%s", workflow.Id), workflow.TriggerCron, func() {
s.StartRun(ctx, &dtos.WorkflowStartRunReq{
WorkflowId: workflow.Id,
RunTrigger: domain.WorkflowTriggerTypeAuto,
})
})
if err != nil { if err != nil {
errs = append(errs, err) app.GetLogger().Error("failed to get persistence settings", "err", err)
return
} }
if len(errs) > 0 { var settingsContent *domain.PersistenceSettingsContent
return errors.Join(errs...) json.Unmarshal([]byte(settings.Content), &settingsContent)
if settingsContent != nil && settingsContent.WorkflowRunsMaxDaysRetention != 0 {
ret, err := s.workflowRunRepo.DeleteWhere(
context.Background(),
dbx.NewExp(fmt.Sprintf("status!='%s'", string(domain.WorkflowRunStatusTypePending))),
dbx.NewExp(fmt.Sprintf("status!='%s'", string(domain.WorkflowRunStatusTypeRunning))),
dbx.NewExp(fmt.Sprintf("endedAt<DATETIME('now', '-%d days')", settingsContent.WorkflowRunsMaxDaysRetention)),
)
if err != nil {
app.GetLogger().Error("failed to delete workflow history runs", "err", err)
}
if ret > 0 {
app.GetLogger().Info(fmt.Sprintf("cleanup %d workflow history runs", ret))
}
}
})
// 工作流
{
workflows, err := s.workflowRepo.ListEnabledAuto(ctx)
if err != nil {
return err
}
for _, workflow := range workflows {
var errs []error
err := app.GetScheduler().Add(fmt.Sprintf("workflow#%s", workflow.Id), workflow.TriggerCron, func() {
s.StartRun(ctx, &dtos.WorkflowStartRunReq{
WorkflowId: workflow.Id,
RunTrigger: domain.WorkflowTriggerTypeAuto,
})
})
if err != nil {
errs = append(errs, err)
}
if len(errs) > 0 {
return errors.Join(errs...)
}
} }
} }

View File

@ -1,5 +1,5 @@
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Form, type FormInstance, Input } from "antd"; import { AutoComplete, Form, type FormInstance, Input } from "antd";
import { createSchemaFieldRule } from "antd-zod"; import { createSchemaFieldRule } from "antd-zod";
import { z } from "zod"; import { z } from "zod";
@ -92,7 +92,11 @@ const AccessFormAzureConfig = ({ form: formInst, formName, disabled, initialValu
rules={[formRule]} rules={[formRule]}
tooltip={<span dangerouslySetInnerHTML={{ __html: t("access.form.azure_cloud_name.tooltip") }}></span>} tooltip={<span dangerouslySetInnerHTML={{ __html: t("access.form.azure_cloud_name.tooltip") }}></span>}
> >
<Input placeholder={t("access.form.azure_cloud_name.placeholder")} /> <AutoComplete
options={["public", "azureusgovernment", "azurechina"].map((value) => ({ value }))}
placeholder={t("access.form.azure_cloud_name.placeholder")}
filterOption={(inputValue, option) => option!.value.toLowerCase().includes(inputValue.toLowerCase())}
/>
</Form.Item> </Form.Item>
</Form> </Form>
); );

View File

@ -3,6 +3,7 @@ export const SETTINGS_NAMES = Object.freeze({
NOTIFY_TEMPLATES: "notifyTemplates", NOTIFY_TEMPLATES: "notifyTemplates",
NOTIFY_CHANNELS: "notifyChannels", NOTIFY_CHANNELS: "notifyChannels",
SSL_PROVIDER: "sslProvider", SSL_PROVIDER: "sslProvider",
PERSISTENCE: "persistence",
} as const); } as const);
export type SettingsNames = (typeof SETTINGS_NAMES)[keyof typeof SETTINGS_NAMES]; export type SettingsNames = (typeof SETTINGS_NAMES)[keyof typeof SETTINGS_NAMES];
@ -165,3 +166,10 @@ export type SSLProviderGoogleTrustServicesConfig = {
eabHmacKey: string; eabHmacKey: string;
}; };
// #endregion // #endregion
// #region Settings: Persistence
export type PersistenceSettingsContent = {
workflowRunsMaxDaysRetention?: number;
expiredCertificatesMaxDaysRetention?: number;
};
// #endregion

View File

@ -90,5 +90,15 @@
"settings.sslprovider.form.gts_eab_kid.tooltip": "For more information, see <a href=\"https://cloud.google.com/certificate-manager/docs/public-ca-tutorial\" target=\"_blank\">https://cloud.google.com/certificate-manager/docs/public-ca-tutorial</a>", "settings.sslprovider.form.gts_eab_kid.tooltip": "For more information, see <a href=\"https://cloud.google.com/certificate-manager/docs/public-ca-tutorial\" target=\"_blank\">https://cloud.google.com/certificate-manager/docs/public-ca-tutorial</a>",
"settings.sslprovider.form.gts_eab_hmac_key.label": "EAB HMAC Key", "settings.sslprovider.form.gts_eab_hmac_key.label": "EAB HMAC Key",
"settings.sslprovider.form.gts_eab_hmac_key.placeholder": "Please enter EAB HMAC Key", "settings.sslprovider.form.gts_eab_hmac_key.placeholder": "Please enter EAB HMAC Key",
"settings.sslprovider.form.gts_eab_hmac_key.tooltip": "For more information, see <a href=\"https://cloud.google.com/certificate-manager/docs/public-ca-tutorial\" target=\"_blank\">https://cloud.google.com/certificate-manager/docs/public-ca-tutorial</a>" "settings.sslprovider.form.gts_eab_hmac_key.tooltip": "For more information, see <a href=\"https://cloud.google.com/certificate-manager/docs/public-ca-tutorial\" target=\"_blank\">https://cloud.google.com/certificate-manager/docs/public-ca-tutorial</a>",
"settings.persistence.tab": "Persistence",
"settings.persistence.form.workflow_runs_max_days.label": "Max days retention of workflow history runs",
"settings.persistence.form.workflow_runs_max_days.placeholder": "Please enter the maximum retention days of workflow history runs",
"settings.persistence.form.workflow_runs_max_days.unit": "days",
"settings.persistence.form.workflow_runs_max_days.extra": "Set to <b>0</b> to disable cleanup workflow history runs. It is recommended to set it to <b>180</b> days or more.",
"settings.persistence.form.expired_certificates_max_days.label": "Max days retention of expired certificates",
"settings.persistence.form.expired_certificates_max_days.placeholder": "Please enter the maximum retention days of expired certificates",
"settings.persistence.form.expired_certificates_max_days.unit": "days",
"settings.persistence.form.expired_certificates_max_days.extra": "Set to <b>0</emb> to disable cleanup expired certificates."
} }

View File

@ -71,7 +71,7 @@
"settings.notification.channel.form.wecom_webhook_url.placeholder": "请输入机器人 Webhook 地址", "settings.notification.channel.form.wecom_webhook_url.placeholder": "请输入机器人 Webhook 地址",
"settings.notification.channel.form.wecom_webhook_url.tooltip": "这是什么?请参阅 <a href=\"https://open.work.weixin.qq.com/help2/pc/18401#%E5%85%AD%E3%80%81%E7%BE%A4%E6%9C%BA%E5%99%A8%E4%BA%BAWebhook%E5%9C%B0%E5%9D%80\" target=\"_blank\">https://open.work.weixin.qq.com/help2/pc/18401</a>", "settings.notification.channel.form.wecom_webhook_url.tooltip": "这是什么?请参阅 <a href=\"https://open.work.weixin.qq.com/help2/pc/18401#%E5%85%AD%E3%80%81%E7%BE%A4%E6%9C%BA%E5%99%A8%E4%BA%BAWebhook%E5%9C%B0%E5%9D%80\" target=\"_blank\">https://open.work.weixin.qq.com/help2/pc/18401</a>",
"settings.sslprovider.tab": "证书颁发机构CA", "settings.sslprovider.tab": "证书颁发机构",
"settings.sslprovider.form.provider.label": "ACME 服务商", "settings.sslprovider.form.provider.label": "ACME 服务商",
"settings.sslprovider.form.provider.option.letsencrypt.label": "Let's Encrypt", "settings.sslprovider.form.provider.option.letsencrypt.label": "Let's Encrypt",
"settings.sslprovider.form.provider.option.letsencrypt_staging.label": "Let's Encrypt 测试环境", "settings.sslprovider.form.provider.option.letsencrypt_staging.label": "Let's Encrypt 测试环境",
@ -90,5 +90,15 @@
"settings.sslprovider.form.gts_eab_kid.tooltip": "这是什么?请参阅 <a href=\"https://cloud.google.com/certificate-manager/docs/public-ca-tutorial\" target=\"_blank\">https://cloud.google.com/certificate-manager/docs/public-ca-tutorial</a>", "settings.sslprovider.form.gts_eab_kid.tooltip": "这是什么?请参阅 <a href=\"https://cloud.google.com/certificate-manager/docs/public-ca-tutorial\" target=\"_blank\">https://cloud.google.com/certificate-manager/docs/public-ca-tutorial</a>",
"settings.sslprovider.form.gts_eab_hmac_key.label": "EAB HMAC Key", "settings.sslprovider.form.gts_eab_hmac_key.label": "EAB HMAC Key",
"settings.sslprovider.form.gts_eab_hmac_key.placeholder": "请输入 EAB HMAC Key", "settings.sslprovider.form.gts_eab_hmac_key.placeholder": "请输入 EAB HMAC Key",
"settings.sslprovider.form.gts_eab_hmac_key.tooltip": "这是什么?请参阅 <a href=\"https://cloud.google.com/certificate-manager/docs/public-ca-tutorial\" target=\"_blank\">https://cloud.google.com/certificate-manager/docs/public-ca-tutorial</a>" "settings.sslprovider.form.gts_eab_hmac_key.tooltip": "这是什么?请参阅 <a href=\"https://cloud.google.com/certificate-manager/docs/public-ca-tutorial\" target=\"_blank\">https://cloud.google.com/certificate-manager/docs/public-ca-tutorial</a>",
"settings.persistence.tab": "数据持久化",
"settings.persistence.form.workflow_runs_max_days.label": "工作流执行历史保留天数",
"settings.persistence.form.workflow_runs_max_days.placeholder": "请输入执行历史保留天数",
"settings.persistence.form.workflow_runs_max_days.unit": "天",
"settings.persistence.form.workflow_runs_max_days.extra": "设置为 <b>0</b> 表示永久保留,不会自动清理。建议设置为 <b>180</b> 天以上。",
"settings.persistence.form.expired_certificates_max_days.label": "证书过期后保留天数",
"settings.persistence.form.expired_certificates_max_days.placeholder": "请输入过期证书保留天数",
"settings.persistence.form.expired_certificates_max_days.unit": "天",
"settings.persistence.form.expired_certificates_max_days.extra": "设置为 <b>0</b> 表示永久保留,不会自动清理。"
} }

View File

@ -85,7 +85,7 @@
"workflow_node.deploy.form.provider_access.placeholder": "请选择主机提供商授权", "workflow_node.deploy.form.provider_access.placeholder": "请选择主机提供商授权",
"workflow_node.deploy.form.provider_access.tooltip": "用于部署证书,注意与申请阶段所需的 DNS 提供商相区分。", "workflow_node.deploy.form.provider_access.tooltip": "用于部署证书,注意与申请阶段所需的 DNS 提供商相区分。",
"workflow_node.deploy.form.provider_access.button": "新建", "workflow_node.deploy.form.provider_access.button": "新建",
"workflow_node.deploy.form.provider_access.guide_for_local": "小贴士:由于表单限制,你同样需要为本地部署选择一个授权 —— 即使它是空白的。<br>请注意如果你使用 Docker 安装 Certimate“本地部署”将会部署到容器内而非宿主机上。", "workflow_node.deploy.form.provider_access.guide_for_local": "小贴士:由于表单限制,你同样需要为本地部署选择一个授权 —— 即使它是空白的。<br>请注意如果你使用 Docker 安装 Certimate“本地部署”将会部署到容器内而非宿主机上。",
"workflow_node.deploy.form.certificate.label": "待部署证书", "workflow_node.deploy.form.certificate.label": "待部署证书",
"workflow_node.deploy.form.certificate.placeholder": "请选择待部署证书", "workflow_node.deploy.form.certificate.placeholder": "请选择待部署证书",
"workflow_node.deploy.form.certificate.tooltip": "待部署证书来自之前的申请阶段。如果选项为空请先确保前序节点配置正确。", "workflow_node.deploy.form.certificate.tooltip": "待部署证书来自之前的申请阶段。如果选项为空请先确保前序节点配置正确。",

View File

@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
import { Outlet, useLocation, useNavigate } from "react-router-dom"; import { Outlet, useLocation, useNavigate } from "react-router-dom";
import { import {
ApiOutlined as ApiOutlinedIcon, ApiOutlined as ApiOutlinedIcon,
DatabaseOutlined as DatabaseOutlinedIcon,
LockOutlined as LockOutlinedIcon, LockOutlined as LockOutlinedIcon,
SendOutlined as SendOutlinedIcon, SendOutlined as SendOutlinedIcon,
UserOutlined as UserOutlinedIcon, UserOutlined as UserOutlinedIcon,
@ -69,6 +70,15 @@ const Settings = () => {
</Space> </Space>
), ),
}, },
{
key: "persistence",
label: (
<Space>
<DatabaseOutlinedIcon />
<label>{t("settings.persistence.tab")}</label>
</Space>
),
},
]} ]}
activeTabKey={tabValue} activeTabKey={tabValue}
onTabChange={(key) => { onTabChange={(key) => {

View File

@ -18,7 +18,7 @@ const SettingsAccount = () => {
const [notificationApi, NotificationContextHolder] = notification.useNotification(); const [notificationApi, NotificationContextHolder] = notification.useNotification();
const formSchema = z.object({ const formSchema = z.object({
username: z.string({ message: "settings.account.form.email.placeholder" }).email({ message: t("common.errmsg.email_invalid") }), username: z.string({ message: t("settings.account.form.email.placeholder") }).email({ message: t("common.errmsg.email_invalid") }),
}); });
const formRule = createSchemaFieldRule(formSchema); const formRule = createSchemaFieldRule(formSchema);
const { const {

View File

@ -0,0 +1,132 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Button, Form, InputNumber, Skeleton, message, notification } from "antd";
import { createSchemaFieldRule } from "antd-zod";
import { produce } from "immer";
import { z } from "zod";
import Show from "@/components/Show";
import { type PersistenceSettingsContent, SETTINGS_NAMES, type SettingsModel } from "@/domain/settings";
import { useAntdForm } from "@/hooks";
import { get as getSettings, save as saveSettings } from "@/repository/settings";
import { getErrMsg } from "@/utils/error";
const SettingsPersistence = () => {
const { t } = useTranslation();
const [messageApi, MessageContextHolder] = message.useMessage();
const [notificationApi, NotificationContextHolder] = notification.useNotification();
const [settings, setSettings] = useState<SettingsModel<PersistenceSettingsContent>>();
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchData = async () => {
setLoading(true);
const settings = await getSettings<PersistenceSettingsContent>(SETTINGS_NAMES.PERSISTENCE);
setSettings(settings);
setLoading(false);
};
fetchData();
}, []);
const formSchema = z.object({
workflowRunsMaxDaysRetention: z
.number({ message: t("settings.persistence.form.workflow_runs_max_days.placeholder") })
.gte(0, t("settings.persistence.form.workflow_runs_max_days.placeholder")),
expiredCertificatesMaxDaysRetention: z
.number({ message: t("settings.persistence.form.expired_certificates_max_days.placeholder") })
.gte(0, t("settings.persistence.form.expired_certificates_max_days.placeholder")),
});
const formRule = createSchemaFieldRule(formSchema);
const {
form: formInst,
formPending,
formProps,
} = useAntdForm<z.infer<typeof formSchema>>({
initialValues: {
workflowRunsMaxDaysRetention: settings?.content?.workflowRunsMaxDaysRetention ?? 0,
expiredCertificatesMaxDaysRetention: settings?.content?.expiredCertificatesMaxDaysRetention ?? 0,
},
onSubmit: async (values) => {
try {
await saveSettings(
produce(settings!, (draft) => {
draft.content ??= {} as PersistenceSettingsContent;
draft.content.workflowRunsMaxDaysRetention = values.workflowRunsMaxDaysRetention;
draft.content.expiredCertificatesMaxDaysRetention = values.expiredCertificatesMaxDaysRetention;
})
);
messageApi.success(t("common.text.operation_succeeded"));
} catch (err) {
notificationApi.error({ message: t("common.text.request_error"), description: getErrMsg(err) });
throw err;
}
},
});
const [formChanged, setFormChanged] = useState(false);
const handleInputChange = () => {
const changed =
formInst.getFieldValue("workflowRunsMaxDaysRetention") !== formProps.initialValues?.workflowRunsMaxDaysRetention ||
formInst.getFieldValue("expiredCertificatesMaxDaysRetention") !== formProps.initialValues?.workflowRunsMaxDaysRetention;
setFormChanged(changed);
};
return (
<>
{MessageContextHolder}
{NotificationContextHolder}
<Show when={!loading} fallback={<Skeleton active />}>
<div className="md:max-w-[40rem]">
<Form {...formProps} form={formInst} disabled={formPending} layout="vertical">
<Form.Item
name="workflowRunsMaxDaysRetention"
label={t("settings.persistence.form.workflow_runs_max_days.label")}
extra={<span dangerouslySetInnerHTML={{ __html: t("settings.persistence.form.workflow_runs_max_days.extra") }}></span>}
rules={[formRule]}
>
<InputNumber
className="w-full"
min={0}
max={36500}
placeholder={t("settings.persistence.form.workflow_runs_max_days.placeholder")}
addonAfter={t("settings.persistence.form.workflow_runs_max_days.unit")}
onChange={handleInputChange}
/>
</Form.Item>
<Form.Item
name="expiredCertificatesMaxDaysRetention"
label={t("settings.persistence.form.expired_certificates_max_days.label")}
extra={<span dangerouslySetInnerHTML={{ __html: t("settings.persistence.form.expired_certificates_max_days.extra") }}></span>}
rules={[formRule]}
>
<InputNumber
className="w-full"
min={0}
max={36500}
placeholder={t("settings.persistence.form.expired_certificates_max_days.placeholder")}
addonAfter={t("settings.persistence.form.expired_certificates_max_days.unit")}
onChange={handleInputChange}
/>
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" disabled={!formChanged} loading={formPending}>
{t("common.button.save")}
</Button>
</Form.Item>
</Form>
</div>
</Show>
</>
);
};
export default SettingsPersistence;

View File

@ -10,6 +10,7 @@ import Settings from "./pages/settings/Settings";
import SettingsAccount from "./pages/settings/SettingsAccount"; import SettingsAccount from "./pages/settings/SettingsAccount";
import SettingsNotification from "./pages/settings/SettingsNotification"; import SettingsNotification from "./pages/settings/SettingsNotification";
import SettingsPassword from "./pages/settings/SettingsPassword"; import SettingsPassword from "./pages/settings/SettingsPassword";
import SettingsPersistence from "./pages/settings/SettingsPersistence";
import SettingsSSLProvider from "./pages/settings/SettingsSSLProvider"; import SettingsSSLProvider from "./pages/settings/SettingsSSLProvider";
import WorkflowDetail from "./pages/workflows/WorkflowDetail"; import WorkflowDetail from "./pages/workflows/WorkflowDetail";
import WorkflowList from "./pages/workflows/WorkflowList"; import WorkflowList from "./pages/workflows/WorkflowList";
@ -64,6 +65,10 @@ export const router = createHashRouter([
path: "/settings/ssl-provider", path: "/settings/ssl-provider",
element: <SettingsSSLProvider />, element: <SettingsSSLProvider />,
}, },
{
path: "/settings/persistence",
element: <SettingsPersistence />,
},
], ],
}, },
], ],