Merge pull request #421 from fudiwei/feat/new-workflow

feat: enhance workflow
This commit is contained in:
Yoan.liu 2025-01-17 11:01:30 +08:00 committed by GitHub
commit 0d3e426dff
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
56 changed files with 708 additions and 476 deletions

2
go.mod
View File

@ -39,7 +39,7 @@ require (
github.com/volcengine/volc-sdk-golang v1.0.189
github.com/volcengine/volcengine-go-sdk v1.0.177
golang.org/x/crypto v0.32.0
golang.org/x/exp v0.0.0-20241217172543-b2144cdd0a67
golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8
k8s.io/api v0.32.0
k8s.io/apimachinery v0.32.0
k8s.io/client-go v0.32.0

4
go.sum
View File

@ -957,8 +957,8 @@ golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u0
golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU=
golang.org/x/exp v0.0.0-20241217172543-b2144cdd0a67 h1:1UoZQm6f0P/ZO0w1Ri+f+ifG/gXhegadRdwBIXEFWDo=
golang.org/x/exp v0.0.0-20241217172543-b2144cdd0a67/go.mod h1:qj5a5QZpwLU2NLQudwIN5koi3beDhSAlJwa67PuM98c=
golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8 h1:yqrTHse8TCMW1M1ZCP+VAR/l0kKxwaAIqN/il7x4voA=
golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8/go.mod h1:tujkw807nyEEAamNbDrEGzRav+ilXA7PCRAd6xsmwiU=
golang.org/x/image v0.0.0-20180708004352-c73c2afc3b81/go.mod h1:ux5Hcp/YLpHSI86hEcLt0YII63i6oz57MZXIpbrjZUs=
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=

View File

@ -13,30 +13,30 @@ import (
)
const (
defaultExpireSubject = "有 ${COUNT} 张证书即将过期"
defaultExpireSubject = "有 ${COUNT} 张证书即将过期"
defaultExpireMessage = "有 ${COUNT} 张证书即将过期,域名分别为 ${DOMAINS},请保持关注!"
)
type CertificateRepository interface {
ListExpireSoon(ctx context.Context) ([]domain.Certificate, error)
type certificateRepository interface {
ListExpireSoon(ctx context.Context) ([]*domain.Certificate, error)
}
type certificateService struct {
repo CertificateRepository
type CertificateService struct {
repo certificateRepository
}
func NewCertificateService(repo CertificateRepository) *certificateService {
return &certificateService{
func NewCertificateService(repo certificateRepository) *CertificateService {
return &CertificateService{
repo: repo,
}
}
func (s *certificateService) InitSchedule(ctx context.Context) error {
func (s *CertificateService) InitSchedule(ctx context.Context) error {
scheduler := app.GetScheduler()
err := scheduler.Add("certificate", "0 0 * * *", func() {
certs, err := s.repo.ListExpireSoon(context.Background())
if err != nil {
app.GetLogger().Error("failed to get expire soon certificate", "err", err)
app.GetLogger().Error("failed to get certificates which expire soon", "err", err)
return
}
@ -46,7 +46,7 @@ func (s *certificateService) InitSchedule(ctx context.Context) error {
}
if err := notify.SendToAllChannels(notification.Subject, notification.Message); err != nil {
app.GetLogger().Error("failed to send expire soon certificate", "err", err)
app.GetLogger().Error("failed to send notification", "err", err)
}
})
if err != nil {
@ -58,13 +58,11 @@ func (s *certificateService) InitSchedule(ctx context.Context) error {
return nil
}
type certificateNotification struct {
Subject string `json:"subject"`
Message string `json:"message"`
}
func buildExpireSoonNotification(records []domain.Certificate) *certificateNotification {
if len(records) == 0 {
func buildExpireSoonNotification(certificates []*domain.Certificate) *struct {
Subject string
Message string
} {
if len(certificates) == 0 {
return nil
}
@ -85,9 +83,9 @@ func buildExpireSoonNotification(records []domain.Certificate) *certificateNotif
}
// 替换变量
count := len(records)
count := len(certificates)
domains := make([]string, count)
for i, record := range records {
for i, record := range certificates {
domains[i] = record.SubjectAltNames
}
countStr := strconv.Itoa(count)
@ -98,8 +96,8 @@ func buildExpireSoonNotification(records []domain.Certificate) *certificateNotif
message = strings.ReplaceAll(message, "${DOMAINS}", domainStr)
// 返回消息
return &certificateNotification{
Subject: subject,
Message: message,
}
return &struct {
Subject string
Message string
}{Subject: subject, Message: message}
}

View File

@ -7,11 +7,11 @@ import (
type Access struct {
Meta
Name string `json:"name" db:"name"`
Provider string `json:"provider" db:"provider"`
Config string `json:"config" db:"config"`
Usage string `json:"usage" db:"usage"`
DeletedAt time.Time `json:"deleted" db:"deleted"`
Name string `json:"name" db:"name"`
Provider string `json:"provider" db:"provider"`
Config string `json:"config" db:"config"`
Usage string `json:"usage" db:"usage"`
DeletedAt *time.Time `json:"deleted" db:"deleted"`
}
func (a *Access) UnmarshalConfigToMap() (map[string]any, error) {

View File

@ -23,4 +23,5 @@ type Certificate struct {
WorkflowId string `json:"workflowId" db:"workflowId"`
WorkflowNodeId string `json:"workflowNodeId" db:"workflowNodeId"`
WorkflowOutputId string `json:"workflowOutputId" db:"workflowOutputId"`
DeletedAt *time.Time `json:"deleted" db:"deleted"`
}

View File

@ -1,33 +0,0 @@
package domain
var (
ErrInvalidParams = NewXError(400, "invalid params")
ErrRecordNotFound = NewXError(404, "record not found")
)
func IsRecordNotFound(err error) bool {
if e, ok := err.(*XError); ok {
return e.GetCode() == ErrRecordNotFound.GetCode()
}
return false
}
type XError struct {
Code int `json:"code"`
Msg string `json:"msg"`
}
func NewXError(code int, msg string) *XError {
return &XError{code, msg}
}
func (e *XError) Error() string {
return e.Msg
}
func (e *XError) GetCode() int {
if e.Code == 0 {
return 100
}
return e.Code
}

30
internal/domain/error.go Normal file
View File

@ -0,0 +1,30 @@
package domain
var (
ErrInvalidParams = NewError(400, "invalid params")
ErrRecordNotFound = NewError(404, "record not found")
)
type Error struct {
Code int `json:"code"`
Msg string `json:"msg"`
}
func NewError(code int, msg string) *Error {
if code == 0 {
code = -1
}
return &Error{code, msg}
}
func (e *Error) Error() string {
return e.Msg
}
func IsRecordNotFoundError(err error) bool {
if e, ok := err.(*Error); ok {
return e.Code == ErrRecordNotFound.Code
}
return false
}

View File

@ -12,15 +12,15 @@ const (
notifyTestBody = "欢迎使用 Certimate ,这是一条测试通知。"
)
type SettingsRepository interface {
type settingsRepository interface {
GetByName(ctx context.Context, name string) (*domain.Settings, error)
}
type NotifyService struct {
settingRepo SettingsRepository
settingRepo settingsRepository
}
func NewNotifyService(settingRepo SettingsRepository) *NotifyService {
func NewNotifyService(settingRepo settingsRepository) *NotifyService {
return &NotifyService{
settingRepo: settingRepo,
}

View File

@ -75,7 +75,7 @@ func (d *LocalDeployer) Deploy(ctx context.Context, certPem string, privkeyPem s
if d.config.PreCommand != "" {
stdout, stderr, err := execCommand(d.config.ShellEnv, d.config.PreCommand)
if err != nil {
return nil, xerrors.Wrapf(err, "failed to run pre-command, stdout: %s, stderr: %s", stdout, stderr)
return nil, xerrors.Wrapf(err, "failed to execute pre-command, stdout: %s, stderr: %s", stdout, stderr)
}
d.logger.Logt("pre-command executed", stdout)
@ -132,7 +132,7 @@ func (d *LocalDeployer) Deploy(ctx context.Context, certPem string, privkeyPem s
if d.config.PostCommand != "" {
stdout, stderr, err := execCommand(d.config.ShellEnv, d.config.PostCommand)
if err != nil {
return nil, xerrors.Wrapf(err, "failed to run command, stdout: %s, stderr: %s", stdout, stderr)
return nil, xerrors.Wrapf(err, "failed to execute post-command, stdout: %s, stderr: %s", stdout, stderr)
}
d.logger.Logt("post-command executed", stdout)
@ -154,7 +154,7 @@ func execCommand(shellEnv ShellEnvType, command string) (string, string, error)
case SHELL_ENV_POWERSHELL:
cmd = exec.Command("powershell", "-Command", command)
case "":
case ShellEnvType(""):
if runtime.GOOS == "windows" {
cmd = exec.Command("cmd", "/C", command)
} else {
@ -165,14 +165,13 @@ func execCommand(shellEnv ShellEnvType, command string) (string, string, error)
return "", "", fmt.Errorf("unsupported shell env: %s", shellEnv)
}
var stdoutBuf bytes.Buffer
cmd.Stdout = &stdoutBuf
var stderrBuf bytes.Buffer
cmd.Stderr = &stderrBuf
stdoutBuf := bytes.NewBuffer(nil)
cmd.Stdout = stdoutBuf
stderrBuf := bytes.NewBuffer(nil)
cmd.Stderr = stderrBuf
err := cmd.Run()
if err != nil {
return "", "", xerrors.Wrap(err, "failed to execute shell command")
return stdoutBuf.String(), stderrBuf.String(), xerrors.Wrap(err, "failed to execute command")
}
return stdoutBuf.String(), stderrBuf.String(), nil

View File

@ -20,6 +20,9 @@ var (
fJksAlias string
fJksKeypass string
fJksStorepass string
fShellEnv string
fPreCommand string
fPostCommand string
)
func init() {
@ -33,6 +36,9 @@ func init() {
flag.StringVar(&fJksAlias, argsPrefix+"JKSALIAS", "", "")
flag.StringVar(&fJksKeypass, argsPrefix+"JKSKEYPASS", "", "")
flag.StringVar(&fJksStorepass, argsPrefix+"JKSSTOREPASS", "", "")
flag.StringVar(&fShellEnv, argsPrefix+"SHELLENV", "", "")
flag.StringVar(&fPreCommand, argsPrefix+"PRECOMMAND", "", "")
flag.StringVar(&fPostCommand, argsPrefix+"POSTCOMMAND", "", "")
}
/*
@ -46,7 +52,10 @@ Shell command to run this test:
--CERTIMATE_DEPLOYER_LOCAL_PFXPASSWORD="your-pfx-password" \
--CERTIMATE_DEPLOYER_LOCAL_JKSALIAS="your-jks-alias" \
--CERTIMATE_DEPLOYER_LOCAL_JKSKEYPASS="your-jks-keypass" \
--CERTIMATE_DEPLOYER_LOCAL_JKSSTOREPASS="your-jks-storepass"
--CERTIMATE_DEPLOYER_LOCAL_JKSSTOREPASS="your-jks-storepass" \
--CERTIMATE_DEPLOYER_LOCAL_SHELLENV="sh" \
--CERTIMATE_DEPLOYER_LOCAL_PRECOMMAND="echo 'hello world'" \
--CERTIMATE_DEPLOYER_LOCAL_POSTCOMMAND="echo 'bye-bye world'"
*/
func TestDeploy(t *testing.T) {
flag.Parse()
@ -58,11 +67,18 @@ func TestDeploy(t *testing.T) {
fmt.Sprintf("INPUTKEYPATH: %v", fInputKeyPath),
fmt.Sprintf("OUTPUTCERTPATH: %v", fOutputCertPath),
fmt.Sprintf("OUTPUTKEYPATH: %v", fOutputKeyPath),
fmt.Sprintf("SHELLENV: %v", fShellEnv),
fmt.Sprintf("PRECOMMAND: %v", fPreCommand),
fmt.Sprintf("POSTCOMMAND: %v", fPostCommand),
}, "\n"))
deployer, err := provider.New(&provider.LocalDeployerConfig{
OutputCertPath: fOutputCertPath,
OutputKeyPath: fOutputKeyPath,
OutputFormat: provider.OUTPUT_FORMAT_PEM,
OutputCertPath: fOutputCertPath + ".pem",
OutputKeyPath: fOutputKeyPath + ".pem",
ShellEnv: provider.ShellEnvType(fShellEnv),
PreCommand: fPreCommand,
PostCommand: fPostCommand,
})
if err != nil {
t.Errorf("err: %+v", err)
@ -77,7 +93,7 @@ func TestDeploy(t *testing.T) {
return
}
fstat1, err := os.Stat(fOutputCertPath)
fstat1, err := os.Stat(fOutputCertPath + ".pem")
if err != nil {
t.Errorf("err: %+v", err)
return
@ -86,7 +102,7 @@ func TestDeploy(t *testing.T) {
return
}
fstat2, err := os.Stat(fOutputKeyPath)
fstat2, err := os.Stat(fOutputKeyPath + ".pem")
if err != nil {
t.Errorf("err: %+v", err)
return
@ -104,14 +120,12 @@ func TestDeploy(t *testing.T) {
fmt.Sprintf("INPUTCERTPATH: %v", fInputCertPath),
fmt.Sprintf("INPUTKEYPATH: %v", fInputKeyPath),
fmt.Sprintf("OUTPUTCERTPATH: %v", fOutputCertPath),
fmt.Sprintf("OUTPUTKEYPATH: %v", fOutputKeyPath),
fmt.Sprintf("PFXPASSWORD: %v", fPfxPassword),
}, "\n"))
deployer, err := provider.New(&provider.LocalDeployerConfig{
OutputFormat: provider.OUTPUT_FORMAT_PFX,
OutputCertPath: fOutputCertPath,
OutputKeyPath: fOutputKeyPath,
OutputCertPath: fOutputCertPath + ".pfx",
PfxPassword: fPfxPassword,
})
if err != nil {
@ -127,7 +141,7 @@ func TestDeploy(t *testing.T) {
return
}
fstat, err := os.Stat(fOutputCertPath)
fstat, err := os.Stat(fOutputCertPath + ".pfx")
if err != nil {
t.Errorf("err: %+v", err)
return
@ -145,7 +159,6 @@ func TestDeploy(t *testing.T) {
fmt.Sprintf("INPUTCERTPATH: %v", fInputCertPath),
fmt.Sprintf("INPUTKEYPATH: %v", fInputKeyPath),
fmt.Sprintf("OUTPUTCERTPATH: %v", fOutputCertPath),
fmt.Sprintf("OUTPUTKEYPATH: %v", fOutputKeyPath),
fmt.Sprintf("JKSALIAS: %v", fJksAlias),
fmt.Sprintf("JKSKEYPASS: %v", fJksKeypass),
fmt.Sprintf("JKSSTOREPASS: %v", fJksStorepass),
@ -153,8 +166,7 @@ func TestDeploy(t *testing.T) {
deployer, err := provider.New(&provider.LocalDeployerConfig{
OutputFormat: provider.OUTPUT_FORMAT_JKS,
OutputCertPath: fOutputCertPath,
OutputKeyPath: fOutputKeyPath,
OutputCertPath: fOutputCertPath + ".jks",
JksAlias: fJksAlias,
JksKeypass: fJksKeypass,
JksStorepass: fJksStorepass,
@ -172,7 +184,7 @@ func TestDeploy(t *testing.T) {
return
}
fstat, err := os.Stat(fOutputCertPath)
fstat, err := os.Stat(fOutputCertPath + ".jks")
if err != nil {
t.Errorf("err: %+v", err)
return

View File

@ -103,7 +103,7 @@ func (d *SshDeployer) Deploy(ctx context.Context, certPem string, privkeyPem str
if d.config.PreCommand != "" {
stdout, stderr, err := execSshCommand(client, d.config.PreCommand)
if err != nil {
return nil, xerrors.Wrapf(err, "failed to run pre-command: stdout: %s, stderr: %s", stdout, stderr)
return nil, xerrors.Wrapf(err, "failed to execute pre-command: stdout: %s, stderr: %s", stdout, stderr)
}
d.logger.Logt("SSH pre-command executed", stdout)
@ -160,7 +160,7 @@ func (d *SshDeployer) Deploy(ctx context.Context, certPem string, privkeyPem str
if d.config.PostCommand != "" {
stdout, stderr, err := execSshCommand(client, d.config.PostCommand)
if err != nil {
return nil, xerrors.Wrapf(err, "failed to run command, stdout: %s, stderr: %s", stdout, stderr)
return nil, xerrors.Wrapf(err, "failed to execute post-command, stdout: %s, stderr: %s", stdout, stderr)
}
d.logger.Logt("SSH post-command executed", stdout)
@ -211,13 +211,13 @@ func execSshCommand(sshCli *ssh.Client, command string) (string, string, error)
}
defer session.Close()
var stdoutBuf bytes.Buffer
session.Stdout = &stdoutBuf
var stderrBuf bytes.Buffer
session.Stderr = &stderrBuf
stdoutBuf := bytes.NewBuffer(nil)
session.Stdout = stdoutBuf
stderrBuf := bytes.NewBuffer(nil)
session.Stderr = stderrBuf
err = session.Run(command)
if err != nil {
return "", "", err
return stdoutBuf.String(), stderrBuf.String(), xerrors.Wrap(err, "failed to execute ssh command")
}
return stdoutBuf.String(), stderrBuf.String(), nil

View File

@ -69,6 +69,7 @@ func TestDeploy(t *testing.T) {
SshPort: int32(fSshPort),
SshUsername: fSshUsername,
SshPassword: fSshPassword,
OutputFormat: provider.OUTPUT_FORMAT_PEM,
OutputCertPath: fOutputCertPath,
OutputKeyPath: fOutputKeyPath,
})

View File

@ -0,0 +1,25 @@
package types
import "reflect"
// 判断对象是否为 nil。
//
// 入参:
// - value待判断的对象。
//
// 出参:
// - 如果对象值为 nil则返回 true否则返回 false。
func IsNil(obj any) bool {
if obj == nil {
return true
}
v := reflect.ValueOf(obj)
if v.Kind() == reflect.Ptr {
return v.IsNil()
} else if v.Kind() == reflect.Interface {
return v.Elem().IsNil()
}
return false
}

View File

@ -4,7 +4,9 @@ import (
"context"
"database/sql"
"errors"
"fmt"
"github.com/pocketbase/pocketbase/models"
"github.com/usual2970/certimate/internal/app"
"github.com/usual2970/certimate/internal/domain"
)
@ -15,7 +17,7 @@ func NewAccessRepository() *AccessRepository {
return &AccessRepository{}
}
func (a *AccessRepository) GetById(ctx context.Context, id string) (*domain.Access, error) {
func (r *AccessRepository) GetById(ctx context.Context, id string) (*domain.Access, error) {
record, err := app.GetApp().Dao().FindRecordById("access", id)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
@ -24,6 +26,18 @@ func (a *AccessRepository) GetById(ctx context.Context, id string) (*domain.Acce
return nil, err
}
if !record.GetDateTime("deleted").Time().IsZero() {
return nil, domain.ErrRecordNotFound
}
return r.castRecordToModel(record)
}
func (r *AccessRepository) castRecordToModel(record *models.Record) (*domain.Access, error) {
if record == nil {
return nil, fmt.Errorf("record is nil")
}
access := &domain.Access{
Meta: domain.Meta{
Id: record.GetId(),

View File

@ -22,7 +22,11 @@ var g singleflight.Group
func (r *AcmeAccountRepository) GetByCAAndEmail(ca, email string) (*domain.AcmeAccount, error) {
resp, err, _ := g.Do(fmt.Sprintf("acme_account_%s_%s", ca, email), func() (interface{}, error) {
resp, err := app.GetApp().Dao().FindFirstRecordByFilter("acme_accounts", "ca={:ca} && email={:email}", dbx.Params{"ca": ca, "email": email})
resp, err := app.GetApp().Dao().FindFirstRecordByFilter(
"acme_accounts",
"ca={:ca} && email={:email}",
dbx.Params{"ca": ca, "email": email},
)
if err != nil {
return nil, err
}
@ -33,30 +37,15 @@ func (r *AcmeAccountRepository) GetByCAAndEmail(ca, email string) (*domain.AcmeA
}
if resp == nil {
return nil, fmt.Errorf("acme account not found")
return nil, domain.ErrRecordNotFound
}
record, ok := resp.(*models.Record)
if !ok {
return nil, fmt.Errorf("acme account not found")
return nil, domain.ErrRecordNotFound
}
resource := &registration.Resource{}
if err := record.UnmarshalJSONField("resource", resource); err != nil {
return nil, err
}
return &domain.AcmeAccount{
Meta: domain.Meta{
Id: record.GetId(),
CreatedAt: record.GetCreated().Time(),
UpdatedAt: record.GetUpdated().Time(),
},
CA: record.GetString("ca"),
Email: record.GetString("email"),
Key: record.GetString("key"),
Resource: resource,
}, nil
return r.castRecordToModel(record)
}
func (r *AcmeAccountRepository) Save(ca, email, key string, resource *registration.Resource) error {
@ -72,3 +61,27 @@ func (r *AcmeAccountRepository) Save(ca, email, key string, resource *registrati
record.Set("resource", resource)
return app.GetApp().Dao().Save(record)
}
func (r *AcmeAccountRepository) castRecordToModel(record *models.Record) (*domain.AcmeAccount, error) {
if record == nil {
return nil, fmt.Errorf("record is nil")
}
resource := &registration.Resource{}
if err := record.UnmarshalJSONField("resource", resource); err != nil {
return nil, err
}
acmeAccount := &domain.AcmeAccount{
Meta: domain.Meta{
Id: record.GetId(),
CreatedAt: record.GetCreated().Time(),
UpdatedAt: record.GetUpdated().Time(),
},
CA: record.GetString("ca"),
Email: record.GetString("email"),
Key: record.GetString("key"),
Resource: resource,
}
return acmeAccount, nil
}

View File

@ -2,7 +2,12 @@ package repository
import (
"context"
"database/sql"
"errors"
"fmt"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/models"
"github.com/usual2970/certimate/internal/app"
"github.com/usual2970/certimate/internal/domain"
)
@ -13,14 +18,89 @@ func NewCertificateRepository() *CertificateRepository {
return &CertificateRepository{}
}
func (c *CertificateRepository) ListExpireSoon(ctx context.Context) ([]domain.Certificate, error) {
certificates := []domain.Certificate{}
err := app.GetApp().Dao().DB().
NewQuery("SELECT * FROM certificate WHERE expireAt > DATETIME('now') AND expireAt < DATETIME('now', '+20 days')").
All(&certificates)
func (r *CertificateRepository) ListExpireSoon(ctx context.Context) ([]*domain.Certificate, error) {
records, err := app.GetApp().Dao().FindRecordsByFilter(
"certificate",
"expireAt>DATETIME('now') && expireAt<DATETIME('now', '+20 days') && deleted=null",
"-created",
0, 0,
)
if err != nil {
return nil, err
}
certificates := make([]*domain.Certificate, 0)
for _, record := range records {
certificate, err := r.castRecordToModel(record)
if err != nil {
return nil, err
}
certificates = append(certificates, certificate)
}
return certificates, nil
}
func (r *CertificateRepository) GetById(ctx context.Context, id string) (*domain.Certificate, error) {
record, err := app.GetApp().Dao().FindRecordById("certificate", id)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, domain.ErrRecordNotFound
}
return nil, err
}
if !record.GetDateTime("deleted").Time().IsZero() {
return nil, domain.ErrRecordNotFound
}
return r.castRecordToModel(record)
}
func (r *CertificateRepository) GetByWorkflowNodeId(ctx context.Context, workflowNodeId string) (*domain.Certificate, error) {
records, err := app.GetApp().Dao().FindRecordsByFilter(
"certificate",
"workflowNodeId={:workflowNodeId} && deleted=null",
"-created", 1, 0,
dbx.Params{"workflowNodeId": workflowNodeId},
)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, domain.ErrRecordNotFound
}
return nil, err
}
if len(records) == 0 {
return nil, domain.ErrRecordNotFound
}
return r.castRecordToModel(records[0])
}
func (r *CertificateRepository) castRecordToModel(record *models.Record) (*domain.Certificate, error) {
if record == nil {
return nil, fmt.Errorf("record is nil")
}
certificate := &domain.Certificate{
Meta: domain.Meta{
Id: record.GetId(),
CreatedAt: record.GetCreated().Time(),
UpdatedAt: record.GetUpdated().Time(),
},
Source: domain.CertificateSourceType(record.GetString("source")),
SubjectAltNames: record.GetString("subjectAltNames"),
Certificate: record.GetString("certificate"),
PrivateKey: record.GetString("privateKey"),
IssuerCertificate: record.GetString("issuerCertificate"),
EffectAt: record.GetDateTime("effectAt").Time(),
ExpireAt: record.GetDateTime("expireAt").Time(),
ACMECertUrl: record.GetString("acmeCertUrl"),
ACMECertStableUrl: record.GetString("acmeCertStableUrl"),
WorkflowId: record.GetString("workflowId"),
WorkflowNodeId: record.GetString("workflowNodeId"),
WorkflowOutputId: record.GetString("workflowOutputId"),
}
return certificate, nil
}

View File

@ -2,6 +2,8 @@ package repository
import (
"context"
"database/sql"
"errors"
"github.com/pocketbase/dbx"
"github.com/usual2970/certimate/internal/app"
@ -14,9 +16,16 @@ func NewSettingsRepository() *SettingsRepository {
return &SettingsRepository{}
}
func (s *SettingsRepository) GetByName(ctx context.Context, name string) (*domain.Settings, error) {
record, err := app.GetApp().Dao().FindFirstRecordByFilter("settings", "name={:name}", dbx.Params{"name": name})
func (r *SettingsRepository) GetByName(ctx context.Context, name string) (*domain.Settings, error) {
record, err := app.GetApp().Dao().FindFirstRecordByFilter(
"settings",
"name={:name}",
dbx.Params{"name": name},
)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, domain.ErrRecordNotFound
}
return nil, err
}

View File

@ -4,6 +4,7 @@ import (
"context"
"database/sql"
"errors"
"fmt"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/daos"
@ -18,29 +19,44 @@ func NewWorkflowRepository() *WorkflowRepository {
return &WorkflowRepository{}
}
func (w *WorkflowRepository) ListEnabledAuto(ctx context.Context) ([]domain.Workflow, error) {
func (r *WorkflowRepository) ListEnabledAuto(ctx context.Context) ([]*domain.Workflow, error) {
records, err := app.GetApp().Dao().FindRecordsByFilter(
"workflow",
"enabled={:enabled} && trigger={:trigger}",
"-created", 1000, 0, dbx.Params{"enabled": true, "trigger": domain.WorkflowTriggerTypeAuto},
"-created",
0, 0,
dbx.Params{"enabled": true, "trigger": domain.WorkflowTriggerTypeAuto},
)
if err != nil {
return nil, err
}
rs := make([]domain.Workflow, 0)
workflows := make([]*domain.Workflow, 0)
for _, record := range records {
workflow, err := record2Workflow(record)
workflow, err := r.castRecordToModel(record)
if err != nil {
return nil, err
}
rs = append(rs, *workflow)
workflows = append(workflows, workflow)
}
return rs, nil
return workflows, nil
}
func (w *WorkflowRepository) Save(ctx context.Context, workflow *domain.Workflow) error {
func (r *WorkflowRepository) GetById(ctx context.Context, id string) (*domain.Workflow, error) {
record, err := app.GetApp().Dao().FindRecordById("workflow", id)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, domain.ErrRecordNotFound
}
return nil, err
}
return r.castRecordToModel(record)
}
func (r *WorkflowRepository) Save(ctx context.Context, workflow *domain.Workflow) error {
collection, err := app.GetApp().Dao().FindCollectionByNameOrId(workflow.Table())
if err != nil {
return err
@ -73,7 +89,7 @@ func (w *WorkflowRepository) Save(ctx context.Context, workflow *domain.Workflow
return app.GetApp().Dao().SaveRecord(record)
}
func (w *WorkflowRepository) SaveRun(ctx context.Context, run *domain.WorkflowRun) error {
func (r *WorkflowRepository) SaveRun(ctx context.Context, workflowRun *domain.WorkflowRun) error {
collection, err := app.GetApp().Dao().FindCollectionByNameOrId("workflow_run")
if err != nil {
return err
@ -81,20 +97,20 @@ func (w *WorkflowRepository) SaveRun(ctx context.Context, run *domain.WorkflowRu
err = app.GetApp().Dao().RunInTransaction(func(txDao *daos.Dao) error {
record := models.NewRecord(collection)
record.Set("workflowId", run.WorkflowId)
record.Set("trigger", string(run.Trigger))
record.Set("status", string(run.Status))
record.Set("startedAt", run.StartedAt)
record.Set("endedAt", run.EndedAt)
record.Set("logs", run.Logs)
record.Set("error", run.Error)
record.Set("workflowId", workflowRun.WorkflowId)
record.Set("trigger", string(workflowRun.Trigger))
record.Set("status", string(workflowRun.Status))
record.Set("startedAt", workflowRun.StartedAt)
record.Set("endedAt", workflowRun.EndedAt)
record.Set("logs", workflowRun.Logs)
record.Set("error", workflowRun.Error)
err = txDao.SaveRecord(record)
if err != nil {
return err
}
// unable trigger sse using DB()
workflowRecord, err := txDao.FindRecordById("workflow", run.WorkflowId)
workflowRecord, err := txDao.FindRecordById("workflow", workflowRun.WorkflowId)
if err != nil {
return err
}
@ -112,19 +128,11 @@ func (w *WorkflowRepository) SaveRun(ctx context.Context, run *domain.WorkflowRu
return nil
}
func (w *WorkflowRepository) GetById(ctx context.Context, id string) (*domain.Workflow, error) {
record, err := app.GetApp().Dao().FindRecordById("workflow", id)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, domain.ErrRecordNotFound
}
return nil, err
func (r *WorkflowRepository) castRecordToModel(record *models.Record) (*domain.Workflow, error) {
if record == nil {
return nil, fmt.Errorf("record is nil")
}
return record2Workflow(record)
}
func record2Workflow(record *models.Record) (*domain.Workflow, error) {
content := &domain.WorkflowNode{}
if err := record.UnmarshalJSONField("content", content); err != nil {
return nil, err

View File

@ -17,8 +17,14 @@ func NewWorkflowOutputRepository() *WorkflowOutputRepository {
return &WorkflowOutputRepository{}
}
func (w *WorkflowOutputRepository) GetByNodeId(ctx context.Context, nodeId string) (*domain.WorkflowOutput, error) {
records, err := app.GetApp().Dao().FindRecordsByFilter("workflow_output", "nodeId={:nodeId}", "-created", 1, 0, dbx.Params{"nodeId": nodeId})
func (r *WorkflowOutputRepository) GetByNodeId(ctx context.Context, nodeId string) (*domain.WorkflowOutput, error) {
records, err := app.GetApp().Dao().FindRecordsByFilter(
"workflow_output",
"nodeId={:nodeId}",
"-created",
1, 0,
dbx.Params{"nodeId": nodeId},
)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, domain.ErrRecordNotFound
@ -56,44 +62,8 @@ func (w *WorkflowOutputRepository) GetByNodeId(ctx context.Context, nodeId strin
return rs, nil
}
func (w *WorkflowOutputRepository) GetCertificateByNodeId(ctx context.Context, nodeId string) (*domain.Certificate, error) {
records, err := app.GetApp().Dao().FindRecordsByFilter("certificate", "workflowNodeId={:workflowNodeId}", "-created", 1, 0, dbx.Params{"workflowNodeId": nodeId})
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, domain.ErrRecordNotFound
}
return nil, err
}
if len(records) == 0 {
return nil, domain.ErrRecordNotFound
}
record := records[0]
rs := &domain.Certificate{
Meta: domain.Meta{
Id: record.GetId(),
CreatedAt: record.GetCreated().Time(),
UpdatedAt: record.GetUpdated().Time(),
},
Source: domain.CertificateSourceType(record.GetString("source")),
SubjectAltNames: record.GetString("subjectAltNames"),
Certificate: record.GetString("certificate"),
PrivateKey: record.GetString("privateKey"),
IssuerCertificate: record.GetString("issuerCertificate"),
EffectAt: record.GetDateTime("effectAt").Time(),
ExpireAt: record.GetDateTime("expireAt").Time(),
ACMECertUrl: record.GetString("acmeCertUrl"),
ACMECertStableUrl: record.GetString("acmeCertStableUrl"),
WorkflowId: record.GetString("workflowId"),
WorkflowNodeId: record.GetString("workflowNodeId"),
WorkflowOutputId: record.GetString("workflowOutputId"),
}
return rs, nil
}
// 保存节点输出
func (w *WorkflowOutputRepository) Save(ctx context.Context, output *domain.WorkflowOutput, certificate *domain.Certificate, cb func(id string) error) error {
func (r *WorkflowOutputRepository) Save(ctx context.Context, output *domain.WorkflowOutput, certificate *domain.Certificate, cb func(id string) error) error {
var record *models.Record
var err error

View File

@ -37,5 +37,5 @@ func (handler *notifyHandler) test(c echo.Context) error {
return resp.Err(c, err)
}
return resp.Succ(c, nil)
return resp.Ok(c, nil)
}

View File

@ -14,7 +14,7 @@ type Response struct {
Data interface{} `json:"data"`
}
func Succ(e echo.Context, data interface{}) error {
func Ok(e echo.Context, data interface{}) error {
rs := &Response{
Code: 0,
Msg: "success",
@ -24,10 +24,11 @@ func Succ(e echo.Context, data interface{}) error {
}
func Err(e echo.Context, err error) error {
xerr, ok := err.(*domain.XError)
code := 100
code := 500
xerr, ok := err.(*domain.Error)
if ok {
code = xerr.GetCode()
code = xerr.Code
}
rs := &Response{

View File

@ -30,6 +30,6 @@ func (handler *statisticsHandler) get(c echo.Context) error {
if statistics, err := handler.service.Get(c.Request().Context()); err != nil {
return resp.Err(c, err)
} else {
return resp.Succ(c, statistics)
return resp.Ok(c, statistics)
}
}

View File

@ -36,5 +36,5 @@ func (handler *workflowHandler) run(c echo.Context) error {
if err := handler.service.Run(c.Request().Context(), req); err != nil {
return resp.Err(c, err)
}
return resp.Succ(c, nil)
return resp.Ok(c, nil)
}

View File

@ -6,15 +6,15 @@ import (
"github.com/usual2970/certimate/internal/domain"
)
type StatisticsRepository interface {
type statisticsRepository interface {
Get(ctx context.Context) (*domain.Statistics, error)
}
type StatisticsService struct {
repo StatisticsRepository
repo statisticsRepository
}
func NewStatisticsService(repo StatisticsRepository) *StatisticsService {
func NewStatisticsService(repo statisticsRepository) *StatisticsService {
return &StatisticsService{
repo: repo,
}

View File

@ -14,7 +14,7 @@ import (
const tableName = "workflow"
func RegisterEvents() error {
func Register() {
app := app.GetApp()
app.OnRecordAfterCreateRequest(tableName).Add(func(e *core.RecordCreateEvent) error {
@ -28,8 +28,6 @@ func RegisterEvents() error {
app.OnRecordAfterDeleteRequest(tableName).Add(func(e *core.RecordDeleteEvent) error {
return delete(e.HttpContext.Request().Context(), e.Record)
})
return nil
}
func update(ctx context.Context, record *models.Record) error {

View File

@ -5,6 +5,8 @@ import (
"strings"
"time"
"golang.org/x/exp/maps"
"github.com/usual2970/certimate/internal/applicant"
"github.com/usual2970/certimate/internal/domain"
"github.com/usual2970/certimate/internal/pkg/utils/certs"
@ -13,60 +15,45 @@ import (
type applyNode struct {
node *domain.WorkflowNode
outputRepo WorkflowOutputRepository
*Logger
certRepo certificateRepository
outputRepo workflowOutputRepository
*nodeLogger
}
var validityDuration = time.Hour * 24 * 10
func NewApplyNode(node *domain.WorkflowNode) *applyNode {
return &applyNode{
node: node,
Logger: NewLogger(node),
nodeLogger: NewNodeLogger(node),
outputRepo: repository.NewWorkflowOutputRepository(),
certRepo: repository.NewCertificateRepository(),
}
}
type WorkflowOutputRepository interface {
// 查询节点输出
GetByNodeId(ctx context.Context, nodeId string) (*domain.WorkflowOutput, error)
// 查询申请节点的证书
GetCertificateByNodeId(ctx context.Context, nodeId string) (*domain.Certificate, error)
// 保存节点输出
Save(ctx context.Context, output *domain.WorkflowOutput, certificate *domain.Certificate, cb func(id string) error) error
}
// 申请节点根据申请类型执行不同的操作
func (a *applyNode) Run(ctx context.Context) error {
a.AddOutput(ctx, a.node.Name, "开始执行")
// 查询是否申请过,已申请过则直接返回
// TODO: 先保持和 v0.2 一致,后续增加是否强制申请的参数
output, err := a.outputRepo.GetByNodeId(ctx, a.node.Id)
if err != nil && !domain.IsRecordNotFound(err) {
// 查询上次执行结果
lastOutput, err := a.outputRepo.GetByNodeId(ctx, a.node.Id)
if err != nil && !domain.IsRecordNotFoundError(err) {
a.AddOutput(ctx, a.node.Name, "查询申请记录失败", err.Error())
return err
}
if output != nil && output.Succeeded {
lastCertificate, _ := a.outputRepo.GetCertificateByNodeId(ctx, a.node.Id)
if lastCertificate != nil {
if time.Until(lastCertificate.ExpireAt) > validityDuration {
a.AddOutput(ctx, a.node.Name, "已申请过证书,且证书在有效期内")
return nil
}
}
// 检测是否可以跳过本次执行
if skippable, skipReason := a.checkCanSkip(ctx, lastOutput); skippable {
a.AddOutput(ctx, a.node.Name, skipReason)
return nil
}
// 获取Applicant
// 初始化申请器
applicant, err := applicant.NewWithApplyNode(a.node)
if err != nil {
a.AddOutput(ctx, a.node.Name, "获取申请对象失败", err.Error())
return err
}
// 申请
// 申请证书
applyResult, err := applicant.Apply()
if err != nil {
a.AddOutput(ctx, a.node.Name, "申请失败", err.Error())
@ -74,27 +61,12 @@ func (a *applyNode) Run(ctx context.Context) error {
}
a.AddOutput(ctx, a.node.Name, "申请成功")
// 记录申请结果
// 保持一个节点只有一个输出
outputId := ""
if output != nil {
outputId = output.Id
}
output = &domain.WorkflowOutput{
Meta: domain.Meta{Id: outputId},
WorkflowId: GetWorkflowId(ctx),
NodeId: a.node.Id,
Node: a.node,
Succeeded: true,
Outputs: a.node.Outputs,
}
// 解析证书并生成实体
certX509, err := certs.ParseCertificateFromPEM(applyResult.CertificateFullChain)
if err != nil {
a.AddOutput(ctx, a.node.Name, "解析证书失败", err.Error())
return err
}
certificate := &domain.Certificate{
Source: domain.CertificateSourceTypeWorkflow,
SubjectAltNames: strings.Join(certX509.DNSNames, ";"),
@ -109,7 +81,19 @@ func (a *applyNode) Run(ctx context.Context) error {
WorkflowNodeId: a.node.Id,
}
if err := a.outputRepo.Save(ctx, output, certificate, func(id string) error {
// 保存执行结果
// TODO: 先保持一个节点始终只有一个输出,后续增加版本控制
currentOutput := &domain.WorkflowOutput{
WorkflowId: GetWorkflowId(ctx),
NodeId: a.node.Id,
Node: a.node,
Succeeded: true,
Outputs: a.node.Outputs,
}
if lastOutput != nil {
currentOutput.Id = lastOutput.Id
}
if err := a.outputRepo.Save(ctx, currentOutput, certificate, func(id string) error {
if certificate != nil {
certificate.WorkflowOutputId = id
}
@ -119,8 +103,38 @@ func (a *applyNode) Run(ctx context.Context) error {
a.AddOutput(ctx, a.node.Name, "保存申请记录失败", err.Error())
return err
}
a.AddOutput(ctx, a.node.Name, "保存申请记录成功")
return nil
}
func (a *applyNode) checkCanSkip(ctx context.Context, lastOutput *domain.WorkflowOutput) (skip bool, reason string) {
const validityDuration = time.Hour * 24 * 10
// TODO: 可控制是否强制申请
if lastOutput != nil && lastOutput.Succeeded {
// 比较和上次申请时的关键配置(即影响证书签发的)参数是否一致
if lastOutput.Node.GetConfigString("domains") != a.node.GetConfigString("domains") {
return false, "配置项变化:域名"
}
if lastOutput.Node.GetConfigString("contactEmail") != a.node.GetConfigString("contactEmail") {
return false, "配置项变化:联系邮箱"
}
if lastOutput.Node.GetConfigString("provider") != a.node.GetConfigString("provider") {
return false, "配置项变化DNS 提供商授权"
}
if !maps.Equal(lastOutput.Node.GetConfigMap("providerConfig"), a.node.GetConfigMap("providerConfig")) {
return false, "配置项变化DNS 提供商参数"
}
if lastOutput.Node.GetConfigString("keyAlgorithm") != a.node.GetConfigString("keyAlgorithm") {
return false, "配置项变化:数字签名算法"
}
lastCertificate, _ := a.certRepo.GetByWorkflowNodeId(ctx, a.node.Id)
if lastCertificate != nil && time.Until(lastCertificate.ExpireAt) > validityDuration {
return true, "已申请过证书,且证书尚未临近过期"
}
}
return false, "无历史申请记录"
}

View File

@ -8,13 +8,13 @@ import (
type conditionNode struct {
node *domain.WorkflowNode
*Logger
*nodeLogger
}
func NewConditionNode(node *domain.WorkflowNode) *conditionNode {
return &conditionNode{
node: node,
Logger: NewLogger(node),
node: node,
nodeLogger: NewNodeLogger(node),
}
}

View File

@ -8,95 +8,109 @@ import (
"github.com/usual2970/certimate/internal/deployer"
"github.com/usual2970/certimate/internal/domain"
"github.com/usual2970/certimate/internal/repository"
"golang.org/x/exp/maps"
)
type deployNode struct {
node *domain.WorkflowNode
outputRepo WorkflowOutputRepository
*Logger
certRepo certificateRepository
outputRepo workflowOutputRepository
*nodeLogger
}
func NewDeployNode(node *domain.WorkflowNode) *deployNode {
return &deployNode{
node: node,
Logger: NewLogger(node),
nodeLogger: NewNodeLogger(node),
outputRepo: repository.NewWorkflowOutputRepository(),
certRepo: repository.NewCertificateRepository(),
}
}
func (d *deployNode) Run(ctx context.Context) error {
d.AddOutput(ctx, d.node.Name, "开始执行")
// 检查是否部署过(部署过则直接返回,和 v0.2 暂时保持一致)
output, err := d.outputRepo.GetByNodeId(ctx, d.node.Id)
if err != nil && !domain.IsRecordNotFound(err) {
// 查询上次执行结果
lastOutput, err := d.outputRepo.GetByNodeId(ctx, d.node.Id)
if err != nil && !domain.IsRecordNotFoundError(err) {
d.AddOutput(ctx, d.node.Name, "查询部署记录失败", err.Error())
return err
}
// 获取部署对象
// 获取证书
certSource := d.node.GetConfigString("certificate")
// 获取前序节点输出证书
certSource := d.node.GetConfigString("certificate")
certSourceSlice := strings.Split(certSource, "#")
if len(certSourceSlice) != 2 {
d.AddOutput(ctx, d.node.Name, "证书来源配置错误", certSource)
return fmt.Errorf("证书来源配置错误: %s", certSource)
}
cert, err := d.outputRepo.GetCertificateByNodeId(ctx, certSourceSlice[0])
certificate, err := d.certRepo.GetByWorkflowNodeId(ctx, certSourceSlice[0])
if err != nil {
d.AddOutput(ctx, d.node.Name, "获取证书失败", err.Error())
return err
}
// 未部署过,开始部署
// 部署过但是证书更新了,重新部署
// 部署过且证书未更新,直接返回
if d.deployed(output) && cert.CreatedAt.Before(output.UpdatedAt) {
d.AddOutput(ctx, d.node.Name, "已部署过且证书未更新")
// 检测是否可以跳过本次执行
if skippable, skipReason := d.checkCanSkip(ctx, lastOutput); skippable {
if certificate.CreatedAt.Before(lastOutput.UpdatedAt) {
d.AddOutput(ctx, d.node.Name, "已部署过且证书未更新")
} else {
d.AddOutput(ctx, d.node.Name, skipReason)
}
return nil
}
// 初始化部署器
deploy, err := deployer.NewWithDeployNode(d.node, struct {
Certificate string
PrivateKey string
}{Certificate: cert.Certificate, PrivateKey: cert.PrivateKey})
}{Certificate: certificate.Certificate, PrivateKey: certificate.PrivateKey})
if err != nil {
d.AddOutput(ctx, d.node.Name, "获取部署对象失败", err.Error())
return err
}
// 部署
// 部署证书
if err := deploy.Deploy(ctx); err != nil {
d.AddOutput(ctx, d.node.Name, "部署失败", err.Error())
return err
}
d.AddOutput(ctx, d.node.Name, "部署成功")
// 记录部署结果
outputId := ""
if output != nil {
outputId = output.Id
}
output = &domain.WorkflowOutput{
Meta: domain.Meta{Id: outputId},
// 保存执行结果
// TODO: 先保持一个节点始终只有一个输出,后续增加版本控制
currentOutput := &domain.WorkflowOutput{
Meta: domain.Meta{},
WorkflowId: GetWorkflowId(ctx),
NodeId: d.node.Id,
Node: d.node,
Succeeded: true,
}
if err := d.outputRepo.Save(ctx, output, nil, nil); err != nil {
if lastOutput != nil {
currentOutput.Id = lastOutput.Id
}
if err := d.outputRepo.Save(ctx, currentOutput, nil, nil); err != nil {
d.AddOutput(ctx, d.node.Name, "保存部署记录失败", err.Error())
return err
}
d.AddOutput(ctx, d.node.Name, "保存部署记录成功")
return nil
}
func (d *deployNode) deployed(output *domain.WorkflowOutput) bool {
return output != nil && output.Succeeded
func (d *deployNode) checkCanSkip(ctx context.Context, lastOutput *domain.WorkflowOutput) (skip bool, reason string) {
// TODO: 可控制是否强制部署
if lastOutput != nil && lastOutput.Succeeded {
// 比较和上次部署时的关键配置(即影响证书部署的)参数是否一致
if lastOutput.Node.GetConfigString("provider") != d.node.GetConfigString("provider") {
return false, "配置项变化:主机提供商授权"
}
if !maps.Equal(lastOutput.Node.GetConfigMap("providerConfig"), d.node.GetConfigMap("providerConfig")) {
return false, "配置项变化:主机提供商参数"
}
return true, "已部署过证书"
}
return false, "无历史部署记录"
}

View File

@ -8,20 +8,17 @@ import (
"github.com/usual2970/certimate/internal/repository"
)
type SettingRepository interface {
GetByName(ctx context.Context, name string) (*domain.Settings, error)
}
type notifyNode struct {
node *domain.WorkflowNode
settingRepo SettingRepository
*Logger
node *domain.WorkflowNode
settingsRepo settingRepository
*nodeLogger
}
func NewNotifyNode(node *domain.WorkflowNode) *notifyNode {
return &notifyNode{
node: node,
Logger: NewLogger(node),
settingRepo: repository.NewSettingsRepository(),
node: node,
nodeLogger: NewNodeLogger(node),
settingsRepo: repository.NewSettingsRepository(),
}
}
@ -29,18 +26,20 @@ func (n *notifyNode) Run(ctx context.Context) error {
n.AddOutput(ctx, n.node.Name, "开始执行")
// 获取通知配置
setting, err := n.settingRepo.GetByName(ctx, "notifyChannels")
settings, err := n.settingsRepo.GetByName(ctx, "notifyChannels")
if err != nil {
n.AddOutput(ctx, n.node.Name, "获取通知配置失败", err.Error())
return err
}
channelConfig, err := setting.GetNotifyChannelConfig(n.node.GetConfigString("channel"))
// 获取通知渠道
channelConfig, err := settings.GetNotifyChannelConfig(n.node.GetConfigString("channel"))
if err != nil {
n.AddOutput(ctx, n.node.Name, "获取通知渠道配置失败", err.Error())
return err
}
// 发送通知
if err := notify.SendToChannel(n.node.GetConfigString("subject"),
n.node.GetConfigString("message"),
n.node.GetConfigString("channel"),
@ -49,7 +48,7 @@ func (n *notifyNode) Run(ctx context.Context) error {
n.AddOutput(ctx, n.node.Name, "发送通知失败", err.Error())
return err
}
n.AddOutput(ctx, n.node.Name, "发送通知成功")
return nil
}

View File

@ -8,18 +8,18 @@ import (
"github.com/usual2970/certimate/internal/domain"
)
type NodeProcessor interface {
type nodeProcessor interface {
Run(ctx context.Context) error
Log(ctx context.Context) *domain.WorkflowRunLog
AddOutput(ctx context.Context, title, content string, err ...string)
}
type Logger struct {
type nodeLogger struct {
log *domain.WorkflowRunLog
}
func NewLogger(node *domain.WorkflowNode) *Logger {
return &Logger{
func NewNodeLogger(node *domain.WorkflowNode) *nodeLogger {
return &nodeLogger{
log: &domain.WorkflowRunLog{
NodeId: node.Id,
NodeName: node.Name,
@ -28,11 +28,11 @@ func NewLogger(node *domain.WorkflowNode) *Logger {
}
}
func (l *Logger) Log(ctx context.Context) *domain.WorkflowRunLog {
func (l *nodeLogger) Log(ctx context.Context) *domain.WorkflowRunLog {
return l.log
}
func (l *Logger) AddOutput(ctx context.Context, title, content string, err ...string) {
func (l *nodeLogger) AddOutput(ctx context.Context, title, content string, err ...string) {
output := domain.WorkflowRunLogOutput{
Time: time.Now().UTC().Format(time.RFC3339),
Title: title,
@ -45,7 +45,7 @@ func (l *Logger) AddOutput(ctx context.Context, title, content string, err ...st
l.log.Outputs = append(l.log.Outputs, output)
}
func GetProcessor(node *domain.WorkflowNode) (NodeProcessor, error) {
func GetProcessor(node *domain.WorkflowNode) (nodeProcessor, error) {
switch node.Type {
case domain.WorkflowNodeTypeStart:
return NewStartNode(node), nil
@ -60,3 +60,16 @@ func GetProcessor(node *domain.WorkflowNode) (NodeProcessor, error) {
}
return nil, errors.New("not implemented")
}
type certificateRepository interface {
GetByWorkflowNodeId(ctx context.Context, workflowNodeId string) (*domain.Certificate, error)
}
type workflowOutputRepository interface {
GetByNodeId(ctx context.Context, nodeId string) (*domain.WorkflowOutput, error)
Save(ctx context.Context, output *domain.WorkflowOutput, certificate *domain.Certificate, cb func(id string) error) error
}
type settingRepository interface {
GetByName(ctx context.Context, name string) (*domain.Settings, error)
}

View File

@ -8,21 +8,19 @@ import (
type startNode struct {
node *domain.WorkflowNode
*Logger
*nodeLogger
}
func NewStartNode(node *domain.WorkflowNode) *startNode {
return &startNode{
node: node,
Logger: NewLogger(node),
node: node,
nodeLogger: NewNodeLogger(node),
}
}
// 开始节点没有任何操作
func (s *startNode) Run(ctx context.Context) error {
s.AddOutput(ctx,
s.node.Name,
"完成",
)
// 开始节点没有任何操作
s.AddOutput(ctx, s.node.Name, "完成")
return nil
}

View File

@ -19,21 +19,21 @@ type workflowRunData struct {
Options *domain.WorkflowRunReq
}
type WorkflowRepository interface {
type workflowRepository interface {
ListEnabledAuto(ctx context.Context) ([]*domain.Workflow, error)
GetById(ctx context.Context, id string) (*domain.Workflow, error)
SaveRun(ctx context.Context, run *domain.WorkflowRun) error
Save(ctx context.Context, workflow *domain.Workflow) error
ListEnabledAuto(ctx context.Context) ([]domain.Workflow, error)
SaveRun(ctx context.Context, run *domain.WorkflowRun) error
}
type WorkflowService struct {
ch chan *workflowRunData
repo WorkflowRepository
repo workflowRepository
wg sync.WaitGroup
cancel context.CancelFunc
}
func NewWorkflowService(repo WorkflowRepository) *WorkflowService {
func NewWorkflowService(repo workflowRepository) *WorkflowService {
rs := &WorkflowService{
repo: repo,
ch: make(chan *workflowRunData, 1),

25
main.go
View File

@ -25,27 +25,24 @@ import (
func main() {
app := app.GetApp()
isGoRun := strings.HasPrefix(os.Args[0], os.TempDir())
// 获取启动命令中的http参数
var httpFlag string
flag.StringVar(&httpFlag, "http", "127.0.0.1:8090", "HTTP server address")
// "serve"影响解析
_ = flag.CommandLine.Parse(os.Args[2:])
var flagHttp string
var flagDir string
flag.StringVar(&flagHttp, "http", "127.0.0.1:8090", "HTTP server address")
flag.StringVar(&flagDir, "dir", "/pb_data/database", "Pocketbase data directory")
_ = flag.CommandLine.Parse(os.Args[2:]) // skip the first two arguments: "main.go serve"
migratecmd.MustRegister(app, app.RootCmd, migratecmd.Config{
// enable auto creation of migration files when making collection changes in the Admin UI
// (the isGoRun check is to enable it only during development)
Automigrate: isGoRun,
Automigrate: strings.HasPrefix(os.Args[0], os.TempDir()),
})
workflow.RegisterEvents()
app.OnBeforeServe().Add(func(e *core.ServeEvent) error {
routes.Register(e.Router)
scheduler.Register()
workflow.Register()
routes.Register(e.Router)
e.Router.GET(
"/*",
echo.StaticDirectoryHandler(ui.DistDirFS, false),
@ -57,11 +54,13 @@ func main() {
app.OnTerminate().Add(func(e *core.TerminateEvent) error {
routes.Unregister()
log.Println("Exit!")
return nil
})
log.Printf("Visit the website: http://%s", httpFlag)
log.Printf("Visit the website: http://%s", flagHttp)
if err := app.Start(); err != nil {
log.Fatal(err)

View File

@ -0,0 +1,54 @@
package migrations
import (
"encoding/json"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/daos"
m "github.com/pocketbase/pocketbase/migrations"
"github.com/pocketbase/pocketbase/models/schema"
)
func init() {
m.Register(func(db dbx.Builder) error {
dao := daos.New(db);
collection, err := dao.FindCollectionByNameOrId("4szxr9x43tpj6np")
if err != nil {
return err
}
// add
new_deleted := &schema.SchemaField{}
if err := json.Unmarshal([]byte(`{
"system": false,
"id": "klyf4nlq",
"name": "deleted",
"type": "date",
"required": false,
"presentable": false,
"unique": false,
"options": {
"min": "",
"max": ""
}
}`), new_deleted); err != nil {
return err
}
collection.Schema.AddField(new_deleted)
return dao.SaveCollection(collection)
}, func(db dbx.Builder) error {
dao := daos.New(db);
collection, err := dao.FindCollectionByNameOrId("4szxr9x43tpj6np")
if err != nil {
return err
}
// remove
collection.Schema.RemoveField("klyf4nlq")
return dao.SaveCollection(collection)
})
}

View File

@ -5,7 +5,7 @@ import { getPocketBase } from "@/repository/pocketbase";
export const notifyTest = async (channel: string) => {
const pb = getPocketBase();
const resp = await pb.send("/api/notify/test", {
const resp = await pb.send<BaseResponse>("/api/notify/test", {
method: "POST",
headers: {
"Content-Type": "application/json",

View File

@ -6,7 +6,7 @@ import { getPocketBase } from "@/repository/pocketbase";
export const get = async () => {
const pb = getPocketBase();
const resp = await pb.send("/api/statistics/get", {
const resp = await pb.send<BaseResponse<Statistics>>("/api/statistics/get", {
method: "GET",
});
@ -14,5 +14,5 @@ export const get = async () => {
throw new ClientResponseError({ status: resp.code, response: resp, data: {} });
}
return resp.data as Statistics;
return resp;
};

View File

@ -1,12 +1,12 @@
import { ClientResponseError, type RecordSubscription } from "pocketbase";
import { ClientResponseError } from "pocketbase";
import { WORKFLOW_TRIGGERS, type WorkflowModel } from "@/domain/workflow";
import { WORKFLOW_TRIGGERS } from "@/domain/workflow";
import { getPocketBase } from "@/repository/pocketbase";
export const run = async (id: string) => {
const pb = getPocketBase();
const resp = await pb.send("/api/workflow/run", {
const resp = await pb.send<BaseResponse>("/api/workflow/run", {
method: "POST",
headers: {
"Content-Type": "application/json",
@ -23,15 +23,3 @@ export const run = async (id: string) => {
return resp;
};
export const subscribe = async (id: string, cb: (e: RecordSubscription<WorkflowModel>) => void) => {
const pb = getPocketBase();
return pb.collection("workflow").subscribe(id, cb);
};
export const unsubscribe = async (id: string) => {
const pb = getPocketBase();
return pb.collection("workflow").unsubscribe(id);
};

View File

@ -129,7 +129,7 @@ const WorkflowRuns = ({ className, style, workflowId }: WorkflowRunsProps) => {
const [page, setPage] = useState<number>(1);
const [pageSize, setPageSize] = useState<number>(10);
const { loading } = useRequest(
const { loading, error: loadedError } = useRequest(
() => {
return listWorkflowRuns({
workflowId: workflowId,
@ -139,9 +139,9 @@ const WorkflowRuns = ({ className, style, workflowId }: WorkflowRunsProps) => {
},
{
refreshDeps: [workflowId, page, pageSize],
onSuccess: (data) => {
setTableData(data.items);
setTableTotal(data.totalItems);
onSuccess: (res) => {
setTableData(res.items);
setTableTotal(res.totalItems);
},
onError: (err) => {
if (err instanceof ClientResponseError && err.isAbort) {
@ -150,6 +150,8 @@ const WorkflowRuns = ({ className, style, workflowId }: WorkflowRunsProps) => {
console.error(err);
notificationApi.error({ message: t("common.text.request_error"), description: getErrMsg(err) });
throw err;
},
}
);
@ -164,7 +166,7 @@ const WorkflowRuns = ({ className, style, workflowId }: WorkflowRunsProps) => {
dataSource={tableData}
loading={loading}
locale={{
emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />,
emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={loadedError ? getErrMsg(loadedError) : undefined} />,
}}
pagination={{
current: page,

View File

@ -8,7 +8,7 @@ export interface CertificateModel extends BaseModel {
effectAt: ISO8601String;
expireAt: ISO8601String;
workflowId: string;
expand: {
expand?: {
workflowId?: WorkflowModel; // TODO: ugly, maybe to use an alias?
};
}

View File

@ -29,7 +29,7 @@ export type NotifyTemplate = {
};
export const defaultNotifyTemplate: NotifyTemplate = {
subject: "有 ${COUNT} 张证书即将过期",
subject: "有 ${COUNT} 张证书即将过期",
message: "有 ${COUNT} 张证书即将过期,域名分别为 ${DOMAINS},请保持关注!",
};
// #endregion

View File

@ -5,6 +5,7 @@
"certificate.action.view": "View certificate",
"certificate.action.delete": "Delete certificate",
"certificate.action.delete.confirm": "Are you sure to delete this certificate?",
"certificate.action.download": "Download certificate",
"certificate.props.subject_alt_names": "Name",

View File

@ -13,6 +13,6 @@
"dashboard.quick_actions": "Quick actions",
"dashboard.quick_actions.create_workflow": "Create workflow",
"dashboard.quick_actions.change_login_password": "Change login password",
"dashboard.quick_actions.notification_settings": "Notification settings",
"dashboard.quick_actions.certificate_authority_configuration": "Certificate authority configuration"
"dashboard.quick_actions.cofigure_notification": "Configure notificaion",
"dashboard.quick_actions.configure_ca": "Configure certificate authority"
}

View File

@ -20,7 +20,7 @@
"access.form.name.placeholder": "请输入授权名称",
"access.form.provider.label": "提供商",
"access.form.provider.placeholder": "请选择提供商",
"access.form.provider.tooltip": "提供商分为两种类型:<br>【DNS 提供商】的 DNS 托管方,通常等同于域名注册商,用于在申请证书时管理您的域名解析记录。<br>【主机提供商】的服务器或云服务的托管方,用于部署签发的证书。<br><br>该字段保存后不可修改。",
"access.form.provider.tooltip": "提供商分为两种类型:<br>【DNS 提供商】的 DNS 托管方,通常等同于域名注册商,用于在申请证书时管理您的域名解析记录。<br>【主机提供商】的服务器或云服务的托管方,用于部署签发的证书。<br><br>该字段保存后不可修改。",
"access.form.acmehttpreq_endpoint.label": "服务端点",
"access.form.acmehttpreq_endpoint.placeholder": "请输入服务端点",
"access.form.acmehttpreq_endpoint.tooltip": "这是什么?请参阅 <a href=\"https://go-acme.github.io/lego/dns/httpreq/\" target=\"_blank\">https://go-acme.github.io/lego/dns/httpreq/</a>",

View File

@ -5,6 +5,7 @@
"certificate.action.view": "查看证书",
"certificate.action.delete": "删除证书",
"certificate.action.delete.confirm": "确定要删除此证书吗?",
"certificate.action.download": "下载证书",
"certificate.props.subject_alt_names": "名称",

View File

@ -13,7 +13,6 @@
"dashboard.quick_actions": "快捷操作",
"dashboard.quick_actions.create_workflow": "新建工作流",
"dashboard.quick_actions.change_login_password": "修改登录密码",
"dashboard.quick_actions.notification_settings": "消息推送设置",
"dashboard.quick_actions.certificate_authority_configuration": "证书颁发机构配置"
"dashboard.quick_actions.cofigure_notification": "消息推送设置",
"dashboard.quick_actions.configure_ca": "证书颁发机构配置"
}

View File

@ -49,7 +49,7 @@
"workflow.detail.orchestration.action.release.confirm": "确定要发布更改吗?",
"workflow.detail.orchestration.action.release.failed.uncompleted": "流程编排未完成,请检查是否有节点未配置",
"workflow.detail.orchestration.action.run": "执行",
"workflow.detail.orchestration.action.run.confirm": "你有尚未发布的更改。确定要以最近一次发布的版本继续执行吗?",
"workflow.detail.orchestration.action.run.confirm": "你有尚未发布的更改。确定要以最近一次发布的版本继续执行吗?",
"workflow.detail.orchestration.action.run.prompt": "执行中……请稍后查看执行历史",
"workflow.detail.runs.tab": "执行历史"
}

View File

@ -7,7 +7,7 @@
"workflow_node.action.rename_branch": "重命名",
"workflow_node.action.remove_branch": "删除分支",
"workflow_node.unsaved_changes.confirm": "你有尚未保存的更改。确定要关闭面板吗?",
"workflow_node.unsaved_changes.confirm": "你有尚未保存的更改。确定要关闭面板吗?",
"workflow_node.start.label": "开始",
"workflow_node.start.form.trigger.label": "触发方式",

View File

@ -130,7 +130,7 @@ const AccessList = () => {
});
}, []);
const { loading } = useRequest(
const { loading, run: refreshData } = useRequest(
() => {
const startIndex = (page - 1) * pageSize;
const endIndex = startIndex + pageSize;
@ -142,9 +142,9 @@ const AccessList = () => {
},
{
refreshDeps: [accesses, page, pageSize],
onSuccess: (data) => {
setTableData(data.items);
setTableTotal(data.totalItems);
onSuccess: (res) => {
setTableData(res.items);
setTableTotal(res.totalItems);
},
}
);
@ -157,6 +157,7 @@ const AccessList = () => {
// TODO: 有关联数据的不允许被删除
try {
await deleteAccess(data);
refreshData();
} catch (err) {
console.error(err);
notificationApi.error({ message: t("common.text.request_error"), description: getErrMsg(err) });

View File

@ -4,13 +4,13 @@ import { useNavigate, useSearchParams } from "react-router-dom";
import { DeleteOutlined as DeleteOutlinedIcon, SelectOutlined as SelectOutlinedIcon } from "@ant-design/icons";
import { PageHeader } from "@ant-design/pro-components";
import { useRequest } from "ahooks";
import { Button, Divider, Empty, Menu, type MenuProps, Radio, Space, Table, type TableProps, Tooltip, Typography, notification, theme } from "antd";
import { Button, Divider, Empty, Menu, type MenuProps, Modal, Radio, Space, Table, type TableProps, Tooltip, Typography, notification, theme } from "antd";
import dayjs from "dayjs";
import { ClientResponseError } from "pocketbase";
import CertificateDetailDrawer from "@/components/certificate/CertificateDetailDrawer";
import { CERTIFICATE_SOURCES, type CertificateModel } from "@/domain/certificate";
import { type ListCertificateRequest, list as listCertificate } from "@/repository/certificate";
import { type ListCertificateRequest, list as listCertificate, remove as removeCertificate } from "@/repository/certificate";
import { getErrMsg } from "@/utils/error";
const CertificateList = () => {
@ -21,6 +21,7 @@ const CertificateList = () => {
const { token: themeToken } = theme.useToken();
const [modalApi, ModalContextHolder] = Modal.useModal();
const [notificationApi, NotificationContextHolder] = notification.useNotification();
const tableColumns: TableProps<CertificateModel>["columns"] = [
@ -169,14 +170,7 @@ const CertificateList = () => {
/>
<Tooltip title={t("certificate.action.delete")}>
<Button
color="danger"
icon={<DeleteOutlinedIcon />}
variant="text"
onClick={() => {
alert("TODO: 暂时不支持删除证书");
}}
/>
<Button color="danger" icon={<DeleteOutlinedIcon />} variant="text" onClick={() => handleDeleteClick(record)} />
</Tooltip>
</Button.Group>
),
@ -194,7 +188,11 @@ const CertificateList = () => {
const [page, setPage] = useState<number>(() => parseInt(+searchParams.get("page")! + "") || 1);
const [pageSize, setPageSize] = useState<number>(() => parseInt(+searchParams.get("perPage")! + "") || 10);
const { loading } = useRequest(
const {
loading,
error: loadedError,
run: refreshData,
} = useRequest(
() => {
return listCertificate({
page: page,
@ -204,9 +202,9 @@ const CertificateList = () => {
},
{
refreshDeps: [filters, page, pageSize],
onSuccess: (data) => {
setTableData(data.items);
setTableTotal(data.totalItems);
onSuccess: (res) => {
setTableData(res.items);
setTableTotal(res.totalItems);
},
onError: (err) => {
if (err instanceof ClientResponseError && err.isAbort) {
@ -215,12 +213,34 @@ const CertificateList = () => {
console.error(err);
notificationApi.error({ message: t("common.text.request_error"), description: getErrMsg(err) });
throw err;
},
}
);
const handleDeleteClick = (certificate: CertificateModel) => {
modalApi.confirm({
title: t("certificate.action.delete"),
content: t("certificate.action.delete.confirm"),
onOk: async () => {
try {
const resp = await removeCertificate(certificate);
if (resp) {
setTableData((prev) => prev.filter((item) => item.id !== certificate.id));
refreshData();
}
} catch (err) {
console.error(err);
notificationApi.error({ message: t("common.text.request_error"), description: getErrMsg(err) });
}
},
});
};
return (
<div className="p-4">
{ModalContextHolder}
{NotificationContextHolder}
<PageHeader title={t("certificate.page.title")} />
@ -230,7 +250,7 @@ const CertificateList = () => {
dataSource={tableData}
loading={loading}
locale={{
emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={t("certificate.nodata")} />,
emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={loadedError ? getErrMsg(loadedError) : t("certificate.nodata")} />,
}}
pagination={{
current: page,

View File

@ -29,31 +29,56 @@ import { ClientResponseError } from "pocketbase";
import { get as getStatistics } from "@/api/statistics";
import WorkflowRunDetailDrawer from "@/components/workflow/WorkflowRunDetailDrawer";
import { type Statistics } from "@/domain/statistics";
import { WORKFLOW_TRIGGERS } from "@/domain/workflow";
import { WORKFLOW_RUN_STATUSES, type WorkflowRunModel } from "@/domain/workflowRun";
import { list as listWorkflowRuns } from "@/repository/workflowRun";
import { getErrMsg } from "@/utils/error";
const { useBreakpoint } = Grid;
const Dashboard = () => {
const navigate = useNavigate();
const screens = useBreakpoint();
const { t } = useTranslation();
const { token: themeToken } = theme.useToken();
const breakpoints = Grid.useBreakpoint();
const [notificationApi, NotificationContextHolder] = notification.useNotification();
const statisticsGridSpans = {
xs: { flex: "50%" },
md: { flex: "50%" },
lg: { flex: "33.3333%" },
xl: { flex: "33.3333%" },
xxl: { flex: "20%" },
};
const [statistics, setStatistics] = useState<Statistics>();
const { loading: statisticsLoading } = useRequest(
() => {
return getStatistics();
},
{
onSuccess: (res) => {
setStatistics(res.data);
},
onError: (err) => {
if (err instanceof ClientResponseError && err.isAbort) {
return;
}
console.error(err);
notificationApi.error({ message: t("common.text.request_error"), description: getErrMsg(err) });
throw err;
},
}
);
const tableColumns: TableProps<WorkflowRunModel>["columns"] = [
{
key: "$index",
align: "center",
fixed: "left",
width: 50,
render: (_, __, index) => (page - 1) * pageSize + index + 1,
render: (_, __, index) => index + 1,
},
{
key: "name",
@ -98,20 +123,6 @@ const Dashboard = () => {
return <></>;
},
},
{
key: "trigger",
title: t("workflow_run.props.trigger"),
ellipsis: true,
render: (_, record) => {
if (record.trigger === WORKFLOW_TRIGGERS.AUTO) {
return t("workflow_run.props.trigger.auto");
} else if (record.trigger === WORKFLOW_TRIGGERS.MANUAL) {
return t("workflow_run.props.trigger.manual");
}
return <></>;
},
},
{
key: "startedAt",
title: t("workflow_run.props.started_at"),
@ -139,7 +150,6 @@ const Dashboard = () => {
{
key: "$action",
align: "end",
fixed: "right",
width: 120,
render: (_, record) => (
<Button.Group>
@ -149,24 +159,17 @@ const Dashboard = () => {
},
];
const [tableData, setTableData] = useState<WorkflowRunModel[]>([]);
const [_tableTotal, setTableTotal] = useState<number>(0);
const [page, _setPage] = useState<number>(1);
const [pageSize, _setPageSize] = useState<number>(3);
const { loading: loadingWorkflowRun } = useRequest(
const { loading: tableLoading } = useRequest(
() => {
return listWorkflowRuns({
page: page,
perPage: pageSize,
page: 1,
perPage: 5,
expand: true,
});
},
{
refreshDeps: [page, pageSize],
onSuccess: (data) => {
setTableData(data.items);
setTableTotal(data.totalItems > 3 ? 3 : data.totalItems);
onSuccess: (res) => {
setTableData(res.items);
},
onError: (err) => {
if (err instanceof ClientResponseError && err.isAbort) {
@ -175,34 +178,8 @@ const Dashboard = () => {
console.error(err);
notificationApi.error({ message: t("common.text.request_error"), description: getErrMsg(err) });
},
}
);
const statisticsGridSpans = {
xs: { flex: "50%" },
md: { flex: "50%" },
lg: { flex: "33.3333%" },
xl: { flex: "33.3333%" },
xxl: { flex: "20%" },
};
const [statistics, setStatistics] = useState<Statistics>();
const { loading } = useRequest(
() => {
return getStatistics();
},
{
onSuccess: (data) => {
setStatistics(data);
},
onError: (err) => {
if (err instanceof ClientResponseError && err.isAbort) {
return;
}
console.error(err);
notificationApi.error({ message: t("common.text.request_error"), description: getErrMsg(err) });
throw err;
},
}
);
@ -218,7 +195,7 @@ const Dashboard = () => {
<StatisticCard
icon={<SquareSigmaIcon size={48} strokeWidth={1} color={themeToken.colorInfo} />}
label={t("dashboard.statistics.all_certificates")}
loading={loading}
loading={statisticsLoading}
value={statistics?.certificateTotal ?? "-"}
suffix={t("dashboard.statistics.unit")}
onClick={() => navigate("/certificates")}
@ -228,7 +205,7 @@ const Dashboard = () => {
<StatisticCard
icon={<CalendarClockIcon size={48} strokeWidth={1} color={themeToken.colorWarning} />}
label={t("dashboard.statistics.expire_soon_certificates")}
loading={loading}
loading={statisticsLoading}
value={statistics?.certificateExpireSoon ?? "-"}
suffix={t("dashboard.statistics.unit")}
onClick={() => navigate("/certificates?state=expireSoon")}
@ -238,7 +215,7 @@ const Dashboard = () => {
<StatisticCard
icon={<CalendarX2Icon size={48} strokeWidth={1} color={themeToken.colorError} />}
label={t("dashboard.statistics.expired_certificates")}
loading={loading}
loading={statisticsLoading}
value={statistics?.certificateExpired ?? "-"}
suffix={t("dashboard.statistics.unit")}
onClick={() => navigate("/certificates?state=expired")}
@ -248,7 +225,7 @@ const Dashboard = () => {
<StatisticCard
icon={<WorkflowIcon size={48} strokeWidth={1} color={themeToken.colorInfo} />}
label={t("dashboard.statistics.all_workflows")}
loading={loading}
loading={statisticsLoading}
value={statistics?.workflowTotal ?? "-"}
suffix={t("dashboard.statistics.unit")}
onClick={() => navigate("/workflows")}
@ -258,7 +235,7 @@ const Dashboard = () => {
<StatisticCard
icon={<FolderCheckIcon size={48} strokeWidth={1} color={themeToken.colorSuccess} />}
label={t("dashboard.statistics.enabled_workflows")}
loading={loading}
loading={statisticsLoading}
value={statistics?.workflowEnabled ?? "-"}
suffix={t("dashboard.statistics.unit")}
onClick={() => navigate("/workflows?state=enabled")}
@ -268,34 +245,32 @@ const Dashboard = () => {
<Divider />
<Flex vertical={!screens.md} gap={16}>
<Card className="sm:h-full sm:w-[500px] sm:pb-32">
<div className="text-lg font-semibold">{t("dashboard.quick_actions")}</div>
<div className="mt-9">
<Button className="w-full" type="primary" size="large" icon={<PlusOutlined />} onClick={() => navigate("/workflows/new")}>
<Flex justify="stretch" vertical={!breakpoints.lg} gap={16}>
<Card className="max-lg:flex-1 lg:w-[360px]" title={t("dashboard.quick_actions")}>
<Space className="w-full" direction="vertical" size="large">
<Button block type="primary" size="large" icon={<PlusOutlined />} onClick={() => navigate("/workflows/new")}>
{t("dashboard.quick_actions.create_workflow")}
</Button>
<Button className="mt-5 w-full" size="large" icon={<LockOutlined />} onClick={() => navigate("/settings/password")}>
<Button block size="large" icon={<LockOutlined />} onClick={() => navigate("/settings/password")}>
{t("dashboard.quick_actions.change_login_password")}
</Button>
<Button className="mt-5 w-full" size="large" icon={<SendOutlined />} onClick={() => navigate("/settings/notification")}>
{t("dashboard.quick_actions.notification_settings")}
<Button block size="large" icon={<SendOutlined />} onClick={() => navigate("/settings/notification")}>
{t("dashboard.quick_actions.cofigure_notification")}
</Button>
<Button className="mt-5 w-full" size="large" icon={<ApiOutlined />} onClick={() => navigate("/settings/ssl-provider")}>
{t("dashboard.quick_actions.certificate_authority_configuration")}
<Button block size="large" icon={<ApiOutlined />} onClick={() => navigate("/settings/ssl-provider")}>
{t("dashboard.quick_actions.configure_ca")}
</Button>
</div>
</Space>
</Card>
<Card className="size-full">
<div className="text-lg font-semibold">{t("dashboard.latest_workflow_run")} </div>
<Card className="flex-1" title={t("dashboard.latest_workflow_run")}>
<Table<WorkflowRunModel>
className="mt-5"
columns={tableColumns}
dataSource={tableData}
loading={loadingWorkflowRun}
loading={tableLoading}
locale={{
emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />,
}}
pagination={false}
rowKey={(record: WorkflowRunModel) => record.id}
scroll={{ x: "max(100%, 960px)" }}
/>

View File

@ -16,7 +16,7 @@ import { createSchemaFieldRule } from "antd-zod";
import { isEqual } from "radash";
import { z } from "zod";
import { run as runWorkflow, subscribe as subscribeWorkflow, unsubscribe as unsubscribeWorkflow } from "@/api/workflow";
import { run as runWorkflow } from "@/api/workflow";
import ModalForm from "@/components/ModalForm";
import Show from "@/components/Show";
import WorkflowElements from "@/components/workflow/WorkflowElements";
@ -24,7 +24,7 @@ import WorkflowRuns from "@/components/workflow/WorkflowRuns";
import { isAllNodesValidated } from "@/domain/workflow";
import { WORKFLOW_RUN_STATUSES } from "@/domain/workflowRun";
import { useAntdForm, useZustandShallowSelector } from "@/hooks";
import { remove as removeWorkflow } from "@/repository/workflow";
import { remove as removeWorkflow, subscribe as subscribeWorkflow, unsubscribe as unsubscribeWorkflow } from "@/repository/workflow";
import { useWorkflowStore } from "@/stores/workflow";
import { getErrMsg } from "@/utils/error";
@ -109,7 +109,7 @@ const WorkflowDetail = () => {
content: t("workflow.action.delete.confirm"),
onOk: async () => {
try {
const resp: boolean = await removeWorkflow(workflow);
const resp = await removeWorkflow(workflow);
if (resp) {
navigate("/workflows", { replace: true });
}

View File

@ -240,7 +240,11 @@ const WorkflowList = () => {
const [page, setPage] = useState<number>(() => parseInt(+searchParams.get("page")! + "") || 1);
const [pageSize, setPageSize] = useState<number>(() => parseInt(+searchParams.get("perPage")! + "") || 10);
const { loading } = useRequest(
const {
loading,
error: loadedError,
run: refreshData,
} = useRequest(
() => {
return listWorkflow({
page: page,
@ -250,9 +254,9 @@ const WorkflowList = () => {
},
{
refreshDeps: [filters, page, pageSize],
onSuccess: (data) => {
setTableData(data.items);
setTableTotal(data.totalItems);
onSuccess: (res) => {
setTableData(res.items);
setTableTotal(res.totalItems);
},
onError: (err) => {
if (err instanceof ClientResponseError && err.isAbort) {
@ -261,6 +265,8 @@ const WorkflowList = () => {
console.error(err);
notificationApi.error({ message: t("common.text.request_error"), description: getErrMsg(err) });
throw err;
},
}
);
@ -302,9 +308,10 @@ const WorkflowList = () => {
content: t("workflow.action.delete.confirm"),
onOk: async () => {
try {
const resp: boolean = await removeWorkflow(workflow);
const resp = await removeWorkflow(workflow);
if (resp) {
setTableData((prev) => prev.filter((item) => item.id !== workflow.id));
refreshData();
}
} catch (err) {
console.error(err);
@ -341,7 +348,7 @@ const WorkflowList = () => {
dataSource={tableData}
loading={loading}
locale={{
emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={t("workflow.nodata")} />,
emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={loadedError ? getErrMsg(loadedError) : t("workflow.nodata")} />,
}}
pagination={{
current: page,

View File

@ -30,4 +30,5 @@ export const remove = async (record: MaybeModelRecordWithId<AccessModel>) => {
if ("provider" in record && record.provider === "pdns") record.provider = "powerdns";
await getPocketBase().collection(COLLECTION_NAME).update<AccessModel>(record.id!, record);
return true;
};

View File

@ -19,17 +19,18 @@ export const list = async (request: ListCertificateRequest) => {
const perPage = request.perPage || 10;
const options: RecordListOptions = {
sort: "-created",
expand: "workflowId",
filter: "deleted=null",
sort: "-created",
requestKey: null,
};
if (request.state === "expireSoon") {
options.filter = pb.filter("expireAt<{:expiredAt}", {
expiredAt: dayjs().add(15, "d").toDate(),
options.filter = pb.filter("expireAt<{:expiredAt} && deleted=null", {
expiredAt: dayjs().add(20, "d").toDate(),
});
} else if (request.state === "expired") {
options.filter = pb.filter("expireAt<={:expiredAt}", {
options.filter = pb.filter("expireAt<={:expiredAt} && deleted=null", {
expiredAt: new Date(),
});
}
@ -41,4 +42,5 @@ export const remove = async (record: MaybeModelRecordWithId<CertificateModel>) =
record = { ...record, deleted: dayjs.utc().format("YYYY-MM-DD HH:mm:ss") };
await getPocketBase().collection(COLLECTION_NAME).update<CertificateModel>(record.id!, record);
return true;
};

View File

@ -1,4 +1,4 @@
import { type RecordListOptions } from "pocketbase";
import { type RecordListOptions, type RecordSubscription } from "pocketbase";
import { type WorkflowModel } from "@/domain/workflow";
import { getPocketBase } from "./pocketbase";
@ -48,3 +48,15 @@ export const save = async (record: MaybeModelRecord<WorkflowModel>) => {
export const remove = async (record: MaybeModelRecordWithId<WorkflowModel>) => {
return await getPocketBase().collection(COLLECTION_NAME).delete(record.id);
};
export const subscribe = async (id: string, cb: (e: RecordSubscription<WorkflowModel>) => void) => {
const pb = getPocketBase();
return pb.collection("workflow").subscribe(id, cb);
};
export const unsubscribe = async (id: string) => {
const pb = getPocketBase();
return pb.collection("workflow").unsubscribe(id);
};

View File

@ -12,6 +12,12 @@ declare global {
declare type MaybeModelRecord<T extends BaseModel = BaseModel> = T | Omit<T, "id" | "created" | "updated" | "deleted">;
declare type MaybeModelRecordWithId<T extends BaseModel = BaseModel> = T | Pick<T, "id">;
declare interface BaseResponse<T = any> {
code: number;
msg: string;
data: T;
}
}
export {};