feat(releases): replay actions (#932)

* feat(releases): replay actions

* feat(releases): replay actions component

* fix: update filter actions

* fix: select filter_id from ras
This commit is contained in:
ze0s 2023-05-15 21:30:04 +02:00 committed by GitHub
parent 97333d334f
commit 6898ad8315
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 752 additions and 189 deletions

View file

@ -99,7 +99,7 @@ func (s *service) RunAction(ctx context.Context, action *domain.Action, release
payload := &domain.NotificationPayload{ payload := &domain.NotificationPayload{
Event: domain.NotificationEventPushApproved, Event: domain.NotificationEventPushApproved,
ReleaseName: release.TorrentName, ReleaseName: release.TorrentName,
Filter: release.Filter.Name, Filter: release.FilterName,
Indexer: release.Indexer, Indexer: release.Indexer,
InfoHash: release.TorrentHash, InfoHash: release.TorrentHash,
Size: release.Size, Size: release.Size,

View file

@ -19,7 +19,8 @@ import (
type Service interface { type Service interface {
Store(ctx context.Context, action domain.Action) (*domain.Action, error) Store(ctx context.Context, action domain.Action) (*domain.Action, error)
List(ctx context.Context) ([]domain.Action, error) List(ctx context.Context) ([]domain.Action, error)
Delete(actionID int) error Get(ctx context.Context, req *domain.GetActionRequest) (*domain.Action, error)
Delete(ctx context.Context, req *domain.DeleteActionRequest) error
DeleteByFilterID(ctx context.Context, filterID int) error DeleteByFilterID(ctx context.Context, filterID int) error
ToggleEnabled(actionID int) error ToggleEnabled(actionID int) error
@ -51,18 +52,37 @@ func (s *service) Store(ctx context.Context, action domain.Action) (*domain.Acti
return s.repo.Store(ctx, action) return s.repo.Store(ctx, action)
} }
func (s *service) Delete(actionID int) error { func (s *service) List(ctx context.Context) ([]domain.Action, error) {
return s.repo.Delete(actionID) return s.repo.List(ctx)
}
func (s *service) Get(ctx context.Context, req *domain.GetActionRequest) (*domain.Action, error) {
a, err := s.repo.Get(ctx, req)
if err != nil {
return nil, err
}
// optionally attach download client to action
if a.ClientID > 0 {
client, err := s.clientSvc.FindByID(ctx, a.ClientID)
if err != nil {
return nil, err
}
a.Client = client
}
return a, nil
}
func (s *service) Delete(ctx context.Context, req *domain.DeleteActionRequest) error {
return s.repo.Delete(ctx, req)
} }
func (s *service) DeleteByFilterID(ctx context.Context, filterID int) error { func (s *service) DeleteByFilterID(ctx context.Context, filterID int) error {
return s.repo.DeleteByFilterID(ctx, filterID) return s.repo.DeleteByFilterID(ctx, filterID)
} }
func (s *service) List(ctx context.Context) ([]domain.Action, error) {
return s.repo.List(ctx)
}
func (s *service) ToggleEnabled(actionID int) error { func (s *service) ToggleEnabled(actionID int) error {
return s.repo.ToggleEnabled(actionID) return s.repo.ToggleEnabled(actionID)
} }

View file

@ -280,30 +280,117 @@ func (r *ActionRepo) List(ctx context.Context) ([]domain.Action, error) {
a.ClientID = clientID.Int32 a.ClientID = clientID.Int32
actions = append(actions, a) actions = append(actions, a)
}
if err := rows.Err(); err != nil { if err := rows.Err(); err != nil {
return nil, errors.Wrap(err, "rows error") return nil, errors.Wrap(err, "rows error")
} }
}
return actions, nil return actions, nil
} }
func (r *ActionRepo) Delete(actionID int) error { func (r *ActionRepo) Get(ctx context.Context, req *domain.GetActionRequest) (*domain.Action, error) {
queryBuilder := r.db.squirrel.
Select(
"id",
"name",
"type",
"enabled",
"exec_cmd",
"exec_args",
"watch_folder",
"category",
"tags",
"label",
"save_path",
"paused",
"ignore_rules",
"limit_download_speed",
"limit_upload_speed",
"limit_ratio",
"limit_seed_time",
"reannounce_skip",
"reannounce_delete",
"reannounce_interval",
"reannounce_max_attempts",
"webhook_host",
"webhook_type",
"webhook_method",
"webhook_data",
"client_id",
"filter_id",
).
From("action").
Where(sq.Eq{"id": req.Id})
query, args, err := queryBuilder.ToSql()
if err != nil {
return nil, errors.Wrap(err, "error building query")
}
row := r.db.handler.QueryRowContext(ctx, query, args...)
if err != nil {
return nil, errors.Wrap(err, "error executing query")
}
if err := row.Err(); err != nil {
return nil, errors.Wrap(err, "rows error")
}
var a domain.Action
var execCmd, execArgs, watchFolder, category, tags, label, savePath, webhookHost, webhookType, webhookMethod, webhookData sql.NullString
var limitUl, limitDl, limitSeedTime sql.NullInt64
var limitRatio sql.NullFloat64
var clientID, filterID sql.NullInt32
var paused, ignoreRules sql.NullBool
if err := row.Scan(&a.ID, &a.Name, &a.Type, &a.Enabled, &execCmd, &execArgs, &watchFolder, &category, &tags, &label, &savePath, &paused, &ignoreRules, &limitDl, &limitUl, &limitRatio, &limitSeedTime, &a.ReAnnounceSkip, &a.ReAnnounceDelete, &a.ReAnnounceInterval, &a.ReAnnounceMaxAttempts, &webhookHost, &webhookType, &webhookMethod, &webhookData, &clientID, &filterID); err != nil {
if err == sql.ErrNoRows {
return nil, nil
}
return nil, errors.Wrap(err, "error scanning row")
}
a.Category = category.String
a.Tags = tags.String
a.Label = label.String
a.SavePath = savePath.String
a.Paused = paused.Bool
a.IgnoreRules = ignoreRules.Bool
a.LimitDownloadSpeed = limitDl.Int64
a.LimitUploadSpeed = limitUl.Int64
a.LimitRatio = limitRatio.Float64
a.LimitSeedTime = limitSeedTime.Int64
a.WebhookHost = webhookHost.String
a.WebhookType = webhookType.String
a.WebhookMethod = webhookMethod.String
a.WebhookData = webhookData.String
a.ClientID = clientID.Int32
a.FilterID = int(filterID.Int32)
return &a, nil
}
func (r *ActionRepo) Delete(ctx context.Context, req *domain.DeleteActionRequest) error {
queryBuilder := r.db.squirrel. queryBuilder := r.db.squirrel.
Delete("action"). Delete("action").
Where(sq.Eq{"id": actionID}) Where(sq.Eq{"id": req.ActionId})
query, args, err := queryBuilder.ToSql() query, args, err := queryBuilder.ToSql()
if err != nil { if err != nil {
return errors.Wrap(err, "error building query") return errors.Wrap(err, "error building query")
} }
_, err = r.db.handler.Exec(query, args...) if _, err = r.db.handler.ExecContext(ctx, query, args...); err != nil {
if err != nil {
return errors.Wrap(err, "error executing query") return errors.Wrap(err, "error executing query")
} }
r.log.Debug().Msgf("action.delete: %v", actionID) r.log.Debug().Msgf("action.delete: %v", req.ActionId)
return nil return nil
} }
@ -506,20 +593,76 @@ func (r *ActionRepo) StoreFilterActions(ctx context.Context, actions []*domain.A
defer tx.Rollback() defer tx.Rollback()
deleteQueryBuilder := r.db.squirrel. for _, action := range actions {
Delete("action"). action := action
Where(sq.Eq{"filter_id": filterID})
deleteQuery, deleteArgs, err := deleteQueryBuilder.ToSql() if action.ID > 0 {
execCmd := toNullString(action.ExecCmd)
execArgs := toNullString(action.ExecArgs)
watchFolder := toNullString(action.WatchFolder)
category := toNullString(action.Category)
tags := toNullString(action.Tags)
label := toNullString(action.Label)
savePath := toNullString(action.SavePath)
contentLayout := toNullString(string(action.ContentLayout))
webhookHost := toNullString(action.WebhookHost)
webhookType := toNullString(action.WebhookType)
webhookMethod := toNullString(action.WebhookMethod)
webhookData := toNullString(action.WebhookData)
limitDL := toNullInt64(action.LimitDownloadSpeed)
limitUL := toNullInt64(action.LimitUploadSpeed)
limitRatio := toNullFloat64(action.LimitRatio)
limitSeedTime := toNullInt64(action.LimitSeedTime)
clientID := toNullInt32(action.ClientID)
var err error
queryBuilder := r.db.squirrel.
Update("action").
Set("name", action.Name).
Set("type", action.Type).
Set("enabled", action.Enabled).
Set("exec_cmd", execCmd).
Set("exec_args", execArgs).
Set("watch_folder", watchFolder).
Set("category", category).
Set("tags", tags).
Set("label", label).
Set("save_path", savePath).
Set("paused", action.Paused).
Set("ignore_rules", action.IgnoreRules).
Set("skip_hash_check", action.SkipHashCheck).
Set("content_layout", contentLayout).
Set("limit_upload_speed", limitUL).
Set("limit_download_speed", limitDL).
Set("limit_ratio", limitRatio).
Set("limit_seed_time", limitSeedTime).
Set("reannounce_skip", action.ReAnnounceSkip).
Set("reannounce_delete", action.ReAnnounceDelete).
Set("reannounce_interval", action.ReAnnounceInterval).
Set("reannounce_max_attempts", action.ReAnnounceMaxAttempts).
Set("webhook_host", webhookHost).
Set("webhook_type", webhookType).
Set("webhook_method", webhookMethod).
Set("webhook_data", webhookData).
Set("client_id", clientID).
Set("filter_id", filterID).
Where(sq.Eq{"id": action.ID})
query, args, err := queryBuilder.ToSql()
if err != nil { if err != nil {
return nil, errors.Wrap(err, "error building query") return nil, errors.Wrap(err, "error building query")
} }
_, err = tx.ExecContext(ctx, deleteQuery, deleteArgs...)
if err != nil { if _, err = tx.ExecContext(ctx, query, args...); err != nil {
return nil, errors.Wrap(err, "error executing query") return nil, errors.Wrap(err, "error executing query")
} }
for _, action := range actions { r.log.Trace().Msgf("action.StoreFilterActions: update %d", action.ID)
} else {
execCmd := toNullString(action.ExecCmd) execCmd := toNullString(action.ExecCmd)
execArgs := toNullString(action.ExecArgs) execArgs := toNullString(action.ExecArgs)
watchFolder := toNullString(action.WatchFolder) watchFolder := toNullString(action.WatchFolder)
@ -606,13 +749,15 @@ func (r *ActionRepo) StoreFilterActions(ctx context.Context, actions []*domain.A
// return values // return values
var retID int var retID int
err = queryBuilder.QueryRowContext(ctx).Scan(&retID) if err = queryBuilder.QueryRowContext(ctx).Scan(&retID); err != nil {
if err != nil {
return nil, errors.Wrap(err, "error executing query") return nil, errors.Wrap(err, "error executing query")
} }
action.ID = retID action.ID = retID
r.log.Trace().Msgf("action.StoreFilterActions: store %d", action.ID)
}
r.log.Debug().Msgf("action.StoreFilterActions: store '%v' type: '%v' on filter: %v", action.Name, action.Type, filterID) r.log.Debug().Msgf("action.StoreFilterActions: store '%v' type: '%v' on filter: %v", action.Name, action.Type, filterID)
} }

View file

@ -271,6 +271,7 @@ CREATE TABLE release_action_status
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
status TEXT, status TEXT,
action TEXT NOT NULL, action TEXT NOT NULL,
action_id INTEGER,
type TEXT NOT NULL, type TEXT NOT NULL,
client TEXT, client TEXT,
filter TEXT, filter TEXT,
@ -280,6 +281,7 @@ CREATE TABLE release_action_status
raw TEXT, raw TEXT,
log TEXT, log TEXT,
release_id INTEGER NOT NULL, release_id INTEGER NOT NULL,
FOREIGN KEY (action_id) REFERENCES "action"(id),
FOREIGN KEY (release_id) REFERENCES "release"(id) ON DELETE CASCADE, FOREIGN KEY (release_id) REFERENCES "release"(id) ON DELETE CASCADE,
FOREIGN KEY (filter_id) REFERENCES "filter"(id) ON DELETE SET NULL FOREIGN KEY (filter_id) REFERENCES "filter"(id) ON DELETE SET NULL
); );
@ -691,4 +693,10 @@ ADD COLUMN topic text;`,
ALTER TABLE filter ALTER TABLE filter
ADD COLUMN use_regex_description BOOLEAN DEFAULT FALSE;`, ADD COLUMN use_regex_description BOOLEAN DEFAULT FALSE;`,
`ALTER TABLE release_action_status
ADD action_id INTEGER;
ALTER TABLE release_action_status
ADD CONSTRAINT release_action_status_action_id_fk
FOREIGN KEY (action_id) REFERENCES action;`,
} }

View file

@ -79,8 +79,8 @@ func (repo *ReleaseRepo) StoreReleaseActionStatus(ctx context.Context, status *d
} else { } else {
queryBuilder := repo.db.squirrel. queryBuilder := repo.db.squirrel.
Insert("release_action_status"). Insert("release_action_status").
Columns("status", "action", "type", "client", "filter", "filter_id", "rejections", "timestamp", "release_id"). Columns("status", "action", "action_id", "type", "client", "filter", "filter_id", "rejections", "timestamp", "release_id").
Values(status.Status, status.Action, status.Type, status.Client, status.Filter, status.FilterID, pq.Array(status.Rejections), status.Timestamp.Format(time.RFC3339), status.ReleaseID). Values(status.Status, status.Action, status.ActionID, status.Type, status.Client, status.Filter, status.FilterID, pq.Array(status.Rejections), status.Timestamp.Format(time.RFC3339), status.ReleaseID).
Suffix("RETURNING id").RunWith(repo.db.handler) Suffix("RETURNING id").RunWith(repo.db.handler)
// return values // return values
@ -207,7 +207,7 @@ func (repo *ReleaseRepo) findReleases(ctx context.Context, tx *Tx, params domain
queryBuilder := repo.db.squirrel. queryBuilder := repo.db.squirrel.
Select("r.id", "r.filter_status", "r.rejections", "r.indexer", "r.filter", "r.protocol", "r.info_url", "r.download_url", "r.title", "r.torrent_name", "r.size", "r.timestamp", Select("r.id", "r.filter_status", "r.rejections", "r.indexer", "r.filter", "r.protocol", "r.info_url", "r.download_url", "r.title", "r.torrent_name", "r.size", "r.timestamp",
"ras.id", "ras.status", "ras.action", "ras.type", "ras.client", "ras.filter", "ras.rejections", "ras.timestamp"). "ras.id", "ras.status", "ras.action", "ras.action_id", "ras.type", "ras.client", "ras.filter", "ras.filter_id", "ras.release_id", "ras.rejections", "ras.timestamp").
Column(sq.Alias(countQuery, "page_total")). Column(sq.Alias(countQuery, "page_total")).
From("release r"). From("release r").
OrderBy("r.id DESC"). OrderBy("r.id DESC").
@ -242,22 +242,25 @@ func (repo *ReleaseRepo) findReleases(ctx context.Context, tx *Tx, params domain
var rlsindexer, rlsfilter, infoUrl, downloadUrl sql.NullString var rlsindexer, rlsfilter, infoUrl, downloadUrl sql.NullString
var rasId sql.NullInt64 var rasId, rasFilterId, rasReleaseId, rasActionId sql.NullInt64
var rasStatus, rasAction, rasType, rasClient, rasFilter sql.NullString var rasStatus, rasAction, rasType, rasClient, rasFilter sql.NullString
var rasRejections []sql.NullString var rasRejections []sql.NullString
var rasTimestamp sql.NullTime var rasTimestamp sql.NullTime
if err := rows.Scan(&rls.ID, &rls.FilterStatus, pq.Array(&rls.Rejections), &rlsindexer, &rlsfilter, &rls.Protocol, &infoUrl, &downloadUrl, &rls.Title, &rls.TorrentName, &rls.Size, &rls.Timestamp, &rasId, &rasStatus, &rasAction, &rasType, &rasClient, &rasFilter, pq.Array(&rasRejections), &rasTimestamp, &countItems); err != nil { if err := rows.Scan(&rls.ID, &rls.FilterStatus, pq.Array(&rls.Rejections), &rlsindexer, &rlsfilter, &rls.Protocol, &infoUrl, &downloadUrl, &rls.Title, &rls.TorrentName, &rls.Size, &rls.Timestamp, &rasId, &rasStatus, &rasAction, &rasActionId, &rasType, &rasClient, &rasFilter, &rasFilterId, &rasReleaseId, pq.Array(&rasRejections), &rasTimestamp, &countItems); err != nil {
return res, 0, 0, errors.Wrap(err, "error scanning row") return res, 0, 0, errors.Wrap(err, "error scanning row")
} }
ras.ID = rasId.Int64 ras.ID = rasId.Int64
ras.Status = domain.ReleasePushStatus(rasStatus.String) ras.Status = domain.ReleasePushStatus(rasStatus.String)
ras.Action = rasAction.String ras.Action = rasAction.String
ras.ActionID = rasActionId.Int64
ras.Type = domain.ActionType(rasType.String) ras.Type = domain.ActionType(rasType.String)
ras.Client = rasClient.String ras.Client = rasClient.String
ras.Filter = rasFilter.String ras.Filter = rasFilter.String
ras.FilterID = rasFilterId.Int64
ras.Timestamp = rasTimestamp.Time ras.Timestamp = rasTimestamp.Time
ras.ReleaseID = rasReleaseId.Int64
ras.Rejections = []string{} ras.Rejections = []string{}
for _, rejection := range rasRejections { for _, rejection := range rasRejections {
@ -351,7 +354,7 @@ func (repo *ReleaseRepo) GetIndexerOptions(ctx context.Context) ([]string, error
func (repo *ReleaseRepo) GetActionStatusByReleaseID(ctx context.Context, releaseID int64) ([]domain.ReleaseActionStatus, error) { func (repo *ReleaseRepo) GetActionStatusByReleaseID(ctx context.Context, releaseID int64) ([]domain.ReleaseActionStatus, error) {
queryBuilder := repo.db.squirrel. queryBuilder := repo.db.squirrel.
Select("id", "status", "action", "type", "client", "filter", "rejections", "timestamp"). Select("id", "status", "action", "action_id", "type", "client", "filter", "release_id", "rejections", "timestamp").
From("release_action_status"). From("release_action_status").
Where(sq.Eq{"release_id": releaseID}) Where(sq.Eq{"release_id": releaseID})
@ -378,11 +381,13 @@ func (repo *ReleaseRepo) GetActionStatusByReleaseID(ctx context.Context, release
var rls domain.ReleaseActionStatus var rls domain.ReleaseActionStatus
var client, filter sql.NullString var client, filter sql.NullString
var actionId sql.NullInt64
if err := rows.Scan(&rls.ID, &rls.Status, &rls.Action, &rls.Type, &client, &filter, pq.Array(&rls.Rejections), &rls.Timestamp); err != nil { if err := rows.Scan(&rls.ID, &rls.Status, &rls.Action, &actionId, &rls.Type, &client, &filter, &rls.ReleaseID, pq.Array(&rls.Rejections), &rls.Timestamp); err != nil {
return res, errors.Wrap(err, "error scanning row") return res, errors.Wrap(err, "error scanning row")
} }
rls.ActionID = actionId.Int64
rls.Client = client.String rls.Client = client.String
rls.Filter = filter.String rls.Filter = filter.String
@ -392,9 +397,96 @@ func (repo *ReleaseRepo) GetActionStatusByReleaseID(ctx context.Context, release
return res, nil return res, nil
} }
func (repo *ReleaseRepo) Get(ctx context.Context, req *domain.GetReleaseRequest) (*domain.Release, error) {
queryBuilder := repo.db.squirrel.
Select("r.id", "r.filter_status", "r.rejections", "r.indexer", "r.filter", "r.filter_id", "r.protocol", "r.info_url", "r.download_url", "r.title", "r.torrent_name", "r.size", "r.timestamp").
From("release r").
OrderBy("r.id DESC").
Where(sq.Eq{"r.id": req.Id})
query, args, err := queryBuilder.ToSql()
if err != nil {
return nil, errors.Wrap(err, "error building query")
}
repo.log.Trace().Str("database", "release.find").Msgf("query: '%v', args: '%v'", query, args)
row := repo.db.handler.QueryRowContext(ctx, query, args...)
if err != nil {
return nil, errors.Wrap(err, "error executing query")
}
if err := row.Err(); err != nil {
return nil, errors.Wrap(err, "error rows find release")
}
var rls domain.Release
var indexerName, filterName, infoUrl, downloadUrl sql.NullString
var filterId sql.NullInt64
if err := row.Scan(&rls.ID, &rls.FilterStatus, pq.Array(&rls.Rejections), &indexerName, &filterName, &filterId, &rls.Protocol, &infoUrl, &downloadUrl, &rls.Title, &rls.TorrentName, &rls.Size, &rls.Timestamp); err != nil {
if err == sql.ErrNoRows {
return nil, nil
}
return nil, errors.Wrap(err, "error scanning row")
}
rls.Indexer = indexerName.String
rls.FilterName = filterName.String
rls.FilterID = int(filterId.Int64)
rls.ActionStatus = make([]domain.ReleaseActionStatus, 0)
rls.InfoURL = infoUrl.String
rls.TorrentURL = downloadUrl.String
return &rls, nil
}
func (repo *ReleaseRepo) GetActionStatus(ctx context.Context, req *domain.GetReleaseActionStatusRequest) (*domain.ReleaseActionStatus, error) {
queryBuilder := repo.db.squirrel.
Select("id", "status", "action", "action_id", "type", "client", "filter", "filter_id", "release_id", "rejections", "timestamp").
From("release_action_status").
Where(sq.Eq{"id": req.Id})
query, args, err := queryBuilder.ToSql()
if err != nil {
return nil, errors.Wrap(err, "error building query")
}
row := repo.db.handler.QueryRowContext(ctx, query, args...)
if err != nil {
return nil, errors.Wrap(err, "error executing query")
}
if err := row.Err(); err != nil {
repo.log.Error().Stack().Err(err)
return nil, err
}
var rls domain.ReleaseActionStatus
var client, filter sql.NullString
var actionId, filterId sql.NullInt64
if err := row.Scan(&rls.ID, &rls.Status, &rls.Action, &actionId, &rls.Type, &client, &filter, &filterId, &rls.ReleaseID, pq.Array(&rls.Rejections), &rls.Timestamp); err != nil {
if err == sql.ErrNoRows {
return nil, nil
}
return nil, errors.Wrap(err, "error scanning row")
}
rls.ActionID = actionId.Int64
rls.Client = client.String
rls.Filter = filter.String
rls.FilterID = filterId.Int64
return &rls, nil
}
func (repo *ReleaseRepo) attachActionStatus(ctx context.Context, tx *Tx, releaseID int64) ([]domain.ReleaseActionStatus, error) { func (repo *ReleaseRepo) attachActionStatus(ctx context.Context, tx *Tx, releaseID int64) ([]domain.ReleaseActionStatus, error) {
queryBuilder := repo.db.squirrel. queryBuilder := repo.db.squirrel.
Select("id", "status", "action", "type", "client", "filter", "filter_id", "rejections", "timestamp"). Select("id", "status", "action", "action_id", "type", "client", "filter", "filter_id", "release_id", "rejections", "timestamp").
From("release_action_status"). From("release_action_status").
Where(sq.Eq{"release_id": releaseID}) Where(sq.Eq{"release_id": releaseID})
@ -420,12 +512,13 @@ func (repo *ReleaseRepo) attachActionStatus(ctx context.Context, tx *Tx, release
var rls domain.ReleaseActionStatus var rls domain.ReleaseActionStatus
var client, filter sql.NullString var client, filter sql.NullString
var filterID sql.NullInt64 var actionId, filterID sql.NullInt64
if err := rows.Scan(&rls.ID, &rls.Status, &rls.Action, &rls.Type, &client, &filter, &filterID, pq.Array(&rls.Rejections), &rls.Timestamp); err != nil { if err := rows.Scan(&rls.ID, &rls.Status, &rls.Action, &actionId, &rls.Type, &client, &filter, &filterID, &rls.ReleaseID, pq.Array(&rls.Rejections), &rls.Timestamp); err != nil {
return res, errors.Wrap(err, "error scanning row") return res, errors.Wrap(err, "error scanning row")
} }
rls.ActionID = actionId.Int64
rls.Client = client.String rls.Client = client.String
rls.Filter = filter.String rls.Filter = filter.String
rls.FilterID = filterID.Int64 rls.FilterID = filterID.Int64

View file

@ -254,6 +254,9 @@ CREATE TABLE release_action_status
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
status TEXT, status TEXT,
action TEXT NOT NULL, action TEXT NOT NULL,
action_id INTEGER
CONSTRAINT release_action_status_action_id_fk
REFERENCES action,
type TEXT NOT NULL, type TEXT NOT NULL,
client TEXT, client TEXT,
filter TEXT, filter TEXT,
@ -1084,4 +1087,58 @@ ADD COLUMN topic text;`,
ALTER TABLE filter ALTER TABLE filter
ADD COLUMN use_regex_description BOOLEAN DEFAULT FALSE;`, ADD COLUMN use_regex_description BOOLEAN DEFAULT FALSE;`,
`create table release_action_status_dg_tmp
(
id INTEGER
primary key,
status TEXT,
action TEXT not null,
action_id INTEGER
constraint release_action_status_action_id_fk
references action,
type TEXT not null,
rejections TEXT default '{}' not null,
timestamp TIMESTAMP default CURRENT_TIMESTAMP,
raw TEXT,
log TEXT,
release_id INTEGER not null
constraint release_action_status_release_id_fkey
references "release"
on delete cascade,
client TEXT,
filter TEXT,
filter_id INTEGER
constraint release_action_status_filter_id_fk
references filter
);
insert into release_action_status_dg_tmp(id, status, action, type, rejections, timestamp, raw, log, release_id, client,
filter, filter_id)
select id,
status,
action,
type,
rejections,
timestamp,
raw,
log,
release_id,
client,
filter,
filter_id
from release_action_status;
drop table release_action_status;
alter table release_action_status_dg_tmp
rename to release_action_status;
create index release_action_status_filter_id_index
on release_action_status (filter_id);
create index release_action_status_release_id_index
on release_action_status (release_id);
create index release_action_status_status_index
on release_action_status (status);`,
} }

View file

@ -14,10 +14,11 @@ import (
type ActionRepo interface { type ActionRepo interface {
Store(ctx context.Context, action Action) (*Action, error) Store(ctx context.Context, action Action) (*Action, error)
StoreFilterActions(ctx context.Context, actions []*Action, filterID int64) ([]*Action, error) StoreFilterActions(ctx context.Context, actions []*Action, filterID int64) ([]*Action, error)
DeleteByFilterID(ctx context.Context, filterID int) error
FindByFilterID(ctx context.Context, filterID int) ([]*Action, error) FindByFilterID(ctx context.Context, filterID int) ([]*Action, error)
List(ctx context.Context) ([]Action, error) List(ctx context.Context) ([]Action, error)
Delete(actionID int) error Get(ctx context.Context, req *GetActionRequest) (*Action, error)
Delete(ctx context.Context, req *DeleteActionRequest) error
DeleteByFilterID(ctx context.Context, filterID int) error
ToggleEnabled(actionID int) error ToggleEnabled(actionID int) error
} }
@ -125,3 +126,11 @@ const (
ActionContentLayoutSubfolderNone ActionContentLayout = "SUBFOLDER_NONE" ActionContentLayoutSubfolderNone ActionContentLayout = "SUBFOLDER_NONE"
ActionContentLayoutSubfolderCreate ActionContentLayout = "SUBFOLDER_CREATE" ActionContentLayoutSubfolderCreate ActionContentLayout = "SUBFOLDER_CREATE"
) )
type GetActionRequest struct {
Id int
}
type DeleteActionRequest struct {
ActionId int
}

View file

@ -30,12 +30,14 @@ type ReleaseRepo interface {
Store(ctx context.Context, release *Release) (*Release, error) Store(ctx context.Context, release *Release) (*Release, error)
Find(ctx context.Context, params ReleaseQueryParams) (res []*Release, nextCursor int64, count int64, err error) Find(ctx context.Context, params ReleaseQueryParams) (res []*Release, nextCursor int64, count int64, err error)
FindRecent(ctx context.Context) ([]*Release, error) FindRecent(ctx context.Context) ([]*Release, error)
Get(ctx context.Context, req *GetReleaseRequest) (*Release, error)
GetIndexerOptions(ctx context.Context) ([]string, error) GetIndexerOptions(ctx context.Context) ([]string, error)
GetActionStatusByReleaseID(ctx context.Context, releaseID int64) ([]ReleaseActionStatus, error)
Stats(ctx context.Context) (*ReleaseStats, error) Stats(ctx context.Context) (*ReleaseStats, error)
StoreReleaseActionStatus(ctx context.Context, status *ReleaseActionStatus) error
Delete(ctx context.Context) error Delete(ctx context.Context) error
CanDownloadShow(ctx context.Context, title string, season int, episode int) (bool, error) CanDownloadShow(ctx context.Context, title string, season int, episode int) (bool, error)
GetActionStatus(ctx context.Context, req *GetReleaseActionStatusRequest) (*ReleaseActionStatus, error)
StoreReleaseActionStatus(ctx context.Context, status *ReleaseActionStatus) error
} }
type Release struct { type Release struct {
@ -100,13 +102,14 @@ type ReleaseActionStatus struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Status ReleasePushStatus `json:"status"` Status ReleasePushStatus `json:"status"`
Action string `json:"action"` Action string `json:"action"`
ActionID int64 `json:"action_id"`
Type ActionType `json:"type"` Type ActionType `json:"type"`
Client string `json:"client"` Client string `json:"client"`
Filter string `json:"filter"` Filter string `json:"filter"`
FilterID int64 `json:"-"` FilterID int64 `json:"filter_id"`
Rejections []string `json:"rejections"` Rejections []string `json:"rejections"`
ReleaseID int64 `json:"release_id"`
Timestamp time.Time `json:"timestamp"` Timestamp time.Time `json:"timestamp"`
ReleaseID int64 `json:"-"`
} }
func NewReleaseActionStatus(action *Action, release *Release) *ReleaseActionStatus { func NewReleaseActionStatus(action *Action, release *Release) *ReleaseActionStatus {
@ -114,9 +117,10 @@ func NewReleaseActionStatus(action *Action, release *Release) *ReleaseActionStat
ID: 0, ID: 0,
Status: ReleasePushStatusPending, Status: ReleasePushStatusPending,
Action: action.Name, Action: action.Name,
ActionID: int64(action.ID),
Type: action.Type, Type: action.Type,
Filter: release.Filter.Name, Filter: release.FilterName,
FilterID: int64(release.Filter.ID), FilterID: int64(release.FilterID),
Rejections: []string{}, Rejections: []string{},
Timestamp: time.Now(), Timestamp: time.Now(),
ReleaseID: release.ID, ReleaseID: release.ID,
@ -153,6 +157,8 @@ const (
func (r ReleasePushStatus) String() string { func (r ReleasePushStatus) String() string {
switch r { switch r {
case ReleasePushStatusPending:
return "Pending"
case ReleasePushStatusApproved: case ReleasePushStatusApproved:
return "Approved" return "Approved"
case ReleasePushStatusRejected: case ReleasePushStatusRejected:
@ -227,6 +233,20 @@ type ReleaseQueryParams struct {
Search string Search string
} }
type ReleaseActionRetryReq struct {
ReleaseId int
ActionStatusId int
ActionId int
}
type GetReleaseRequest struct {
Id int
}
type GetReleaseActionStatusRequest struct {
Id int
}
func NewRelease(indexer string) *Release { func NewRelease(indexer string) *Release {
r := &Release{ r := &Release{
Indexer: indexer, Indexer: indexer,

View file

@ -11,13 +11,14 @@ import (
"strconv" "strconv"
"github.com/autobrr/autobrr/internal/domain" "github.com/autobrr/autobrr/internal/domain"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
) )
type actionService interface { type actionService interface {
List(ctx context.Context) ([]domain.Action, error) List(ctx context.Context) ([]domain.Action, error)
Store(ctx context.Context, action domain.Action) (*domain.Action, error) Store(ctx context.Context, action domain.Action) (*domain.Action, error)
Delete(actionID int) error Delete(ctx context.Context, req *domain.DeleteActionRequest) error
ToggleEnabled(actionID int) error ToggleEnabled(actionID int) error
} }
@ -36,15 +37,19 @@ func newActionHandler(encoder encoder, service actionService) *actionHandler {
func (h actionHandler) Routes(r chi.Router) { func (h actionHandler) Routes(r chi.Router) {
r.Get("/", h.getActions) r.Get("/", h.getActions)
r.Post("/", h.storeAction) r.Post("/", h.storeAction)
r.Delete("/{id}", h.deleteAction)
r.Put("/{id}", h.updateAction) r.Route("/{id}", func(r chi.Router) {
r.Patch("/{id}/toggleEnabled", h.toggleActionEnabled) r.Delete("/", h.deleteAction)
r.Put("/", h.updateAction)
r.Patch("/toggleEnabled", h.toggleActionEnabled)
})
} }
func (h actionHandler) getActions(w http.ResponseWriter, r *http.Request) { func (h actionHandler) getActions(w http.ResponseWriter, r *http.Request) {
actions, err := h.service.List(r.Context()) actions, err := h.service.List(r.Context())
if err != nil { if err != nil {
// encode error h.encoder.Error(w, err)
return
} }
h.encoder.StatusResponse(w, http.StatusOK, actions) h.encoder.StatusResponse(w, http.StatusOK, actions)
@ -57,13 +62,14 @@ func (h actionHandler) storeAction(w http.ResponseWriter, r *http.Request) {
) )
if err := json.NewDecoder(r.Body).Decode(&data); err != nil { if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
// encode error h.encoder.Error(w, err)
return return
} }
action, err := h.service.Store(ctx, data) action, err := h.service.Store(ctx, data)
if err != nil { if err != nil {
// encode error h.encoder.Error(w, err)
return
} }
h.encoder.StatusResponse(w, http.StatusCreated, action) h.encoder.StatusResponse(w, http.StatusCreated, action)
@ -76,13 +82,14 @@ func (h actionHandler) updateAction(w http.ResponseWriter, r *http.Request) {
) )
if err := json.NewDecoder(r.Body).Decode(&data); err != nil { if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
// encode error h.encoder.Error(w, err)
return return
} }
action, err := h.service.Store(ctx, data) action, err := h.service.Store(ctx, data)
if err != nil { if err != nil {
// encode error h.encoder.Error(w, err)
return
} }
h.encoder.StatusResponse(w, http.StatusCreated, action) h.encoder.StatusResponse(w, http.StatusCreated, action)
@ -91,11 +98,13 @@ func (h actionHandler) updateAction(w http.ResponseWriter, r *http.Request) {
func (h actionHandler) deleteAction(w http.ResponseWriter, r *http.Request) { func (h actionHandler) deleteAction(w http.ResponseWriter, r *http.Request) {
actionID, err := parseInt(chi.URLParam(r, "id")) actionID, err := parseInt(chi.URLParam(r, "id"))
if err != nil { if err != nil {
h.encoder.StatusResponse(w, http.StatusBadRequest, errors.New("bad param id")) h.encoder.StatusError(w, http.StatusBadRequest, errors.New("bad param id"))
return
} }
if err := h.service.Delete(actionID); err != nil { if err := h.service.Delete(r.Context(), &domain.DeleteActionRequest{ActionId: actionID}); err != nil {
// encode error h.encoder.Error(w, err)
return
} }
h.encoder.StatusResponse(w, http.StatusNoContent, nil) h.encoder.StatusResponse(w, http.StatusNoContent, nil)
@ -104,11 +113,13 @@ func (h actionHandler) deleteAction(w http.ResponseWriter, r *http.Request) {
func (h actionHandler) toggleActionEnabled(w http.ResponseWriter, r *http.Request) { func (h actionHandler) toggleActionEnabled(w http.ResponseWriter, r *http.Request) {
actionID, err := parseInt(chi.URLParam(r, "id")) actionID, err := parseInt(chi.URLParam(r, "id"))
if err != nil { if err != nil {
h.encoder.StatusResponse(w, http.StatusBadRequest, errors.New("bad param id")) h.encoder.StatusError(w, http.StatusBadRequest, errors.New("bad param id"))
return
} }
if err := h.service.ToggleEnabled(actionID); err != nil { if err := h.service.ToggleEnabled(actionID); err != nil {
// encode error h.encoder.Error(w, err)
return
} }
h.encoder.StatusResponse(w, http.StatusCreated, nil) h.encoder.StatusResponse(w, http.StatusCreated, nil)

View file

@ -10,6 +10,7 @@ import (
"strconv" "strconv"
"github.com/autobrr/autobrr/internal/domain" "github.com/autobrr/autobrr/internal/domain"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
) )
@ -19,6 +20,7 @@ type releaseService interface {
GetIndexerOptions(ctx context.Context) ([]string, error) GetIndexerOptions(ctx context.Context) ([]string, error)
Stats(ctx context.Context) (*domain.ReleaseStats, error) Stats(ctx context.Context) (*domain.ReleaseStats, error)
Delete(ctx context.Context) error Delete(ctx context.Context) error
Retry(ctx context.Context, req *domain.ReleaseActionRetryReq) error
} }
type releaseHandler struct { type releaseHandler struct {
@ -39,6 +41,10 @@ func (h releaseHandler) Routes(r chi.Router) {
r.Get("/stats", h.getStats) r.Get("/stats", h.getStats)
r.Get("/indexers", h.getIndexerOptions) r.Get("/indexers", h.getIndexerOptions)
r.Delete("/all", h.deleteReleases) r.Delete("/all", h.deleteReleases)
r.Route("/{releaseId}", func(r chi.Router) {
r.Post("/actions/{actionStatusId}/retry", h.retryAction)
})
} }
func (h releaseHandler) findReleases(w http.ResponseWriter, r *http.Request) { func (h releaseHandler) findReleases(w http.ResponseWriter, r *http.Request) {
@ -186,3 +192,46 @@ func (h releaseHandler) deleteReleases(w http.ResponseWriter, r *http.Request) {
h.encoder.NoContent(w) h.encoder.NoContent(w)
} }
func (h releaseHandler) retryAction(w http.ResponseWriter, r *http.Request) {
var (
req *domain.ReleaseActionRetryReq
err error
)
releaseIdParam := chi.URLParam(r, "releaseId")
if releaseIdParam == "" {
h.encoder.StatusError(w, http.StatusBadRequest, err)
return
}
releaseId, err := strconv.Atoi(releaseIdParam)
if err != nil {
h.encoder.StatusError(w, http.StatusBadRequest, err)
return
}
actionStatusIdParam := chi.URLParam(r, "actionStatusId")
if actionStatusIdParam == "" {
h.encoder.StatusError(w, http.StatusBadRequest, err)
return
}
actionStatusId, err := strconv.Atoi(actionStatusIdParam)
if err != nil {
h.encoder.StatusError(w, http.StatusBadRequest, err)
return
}
req = &domain.ReleaseActionRetryReq{
ReleaseId: releaseId,
ActionStatusId: actionStatusId,
}
if err := h.service.Retry(r.Context(), req); err != nil {
h.encoder.Error(w, err)
return
}
h.encoder.NoContent(w)
}

View file

@ -19,6 +19,8 @@ import (
type Service interface { type Service interface {
Find(ctx context.Context, query domain.ReleaseQueryParams) (res []*domain.Release, nextCursor int64, count int64, err error) Find(ctx context.Context, query domain.ReleaseQueryParams) (res []*domain.Release, nextCursor int64, count int64, err error)
FindRecent(ctx context.Context) ([]*domain.Release, error) FindRecent(ctx context.Context) ([]*domain.Release, error)
Get(ctx context.Context, req *domain.GetReleaseRequest) (*domain.Release, error)
GetActionStatus(ctx context.Context, req *domain.GetReleaseActionStatusRequest) (*domain.ReleaseActionStatus, error)
GetIndexerOptions(ctx context.Context) ([]string, error) GetIndexerOptions(ctx context.Context) ([]string, error)
Stats(ctx context.Context) (*domain.ReleaseStats, error) Stats(ctx context.Context) (*domain.ReleaseStats, error)
Store(ctx context.Context, release *domain.Release) error Store(ctx context.Context, release *domain.Release) error
@ -27,6 +29,7 @@ type Service interface {
Process(release *domain.Release) Process(release *domain.Release)
ProcessMultiple(releases []*domain.Release) ProcessMultiple(releases []*domain.Release)
Retry(ctx context.Context, req *domain.ReleaseActionRetryReq) error
} }
type actionClientTypeKey struct { type actionClientTypeKey struct {
@ -59,6 +62,14 @@ func (s *service) FindRecent(ctx context.Context) (res []*domain.Release, err er
return s.repo.FindRecent(ctx) return s.repo.FindRecent(ctx)
} }
func (s *service) Get(ctx context.Context, req *domain.GetReleaseRequest) (*domain.Release, error) {
return s.repo.Get(ctx, req)
}
func (s *service) GetActionStatus(ctx context.Context, req *domain.GetReleaseActionStatusRequest) (*domain.ReleaseActionStatus, error) {
return s.repo.GetActionStatus(ctx, req)
}
func (s *service) GetIndexerOptions(ctx context.Context) ([]string, error) { func (s *service) GetIndexerOptions(ctx context.Context) ([]string, error) {
return s.repo.GetIndexerOptions(ctx) return s.repo.GetIndexerOptions(ctx)
} }
@ -138,13 +149,13 @@ func (s *service) Process(release *domain.Release) {
} }
if !match { if !match {
l.Trace().Msgf("release.Process: indexer: %s, filter: %s release: %s, no match. rejections: %s", release.Indexer, release.Filter.Name, release.TorrentName, release.RejectionsString()) l.Trace().Msgf("release.Process: indexer: %s, filter: %s release: %s, no match. rejections: %s", release.Indexer, release.FilterName, release.TorrentName, release.RejectionsString())
l.Debug().Msgf("release rejected: %s", release.RejectionsString()) l.Debug().Msgf("release rejected: %s", release.RejectionsString())
continue continue
} }
l.Info().Msgf("Matched '%s' (%s) for %s", release.TorrentName, release.Filter.Name, release.Indexer) l.Info().Msgf("Matched '%s' (%s) for %s", release.TorrentName, release.FilterName, release.Indexer)
// save release here to only save those with rejections from actions instead of all releases // save release here to only save those with rejections from actions instead of all releases
if release.ID == 0 { if release.ID == 0 {
@ -158,7 +169,7 @@ func (s *service) Process(release *domain.Release) {
// sleep for the delay period specified in the filter before running actions // sleep for the delay period specified in the filter before running actions
delay := release.Filter.Delay delay := release.Filter.Delay
if delay > 0 { if delay > 0 {
l.Debug().Msgf("Delaying processing of '%s' (%s) for %s by %d seconds as specified in the filter", release.TorrentName, release.Filter.Name, release.Indexer, delay) l.Debug().Msgf("Delaying processing of '%s' (%s) for %s by %d seconds as specified in the filter", release.TorrentName, release.FilterName, release.Indexer, delay)
time.Sleep(time.Duration(delay) * time.Second) time.Sleep(time.Duration(delay) * time.Second)
} }
@ -170,30 +181,30 @@ func (s *service) Process(release *domain.Release) {
// only run enabled actions // only run enabled actions
if !act.Enabled { if !act.Enabled {
l.Trace().Msgf("release.Process: indexer: %s, filter: %s release: %s action '%s' not enabled, skip", release.Indexer, release.Filter.Name, release.TorrentName, act.Name) l.Trace().Msgf("release.Process: indexer: %s, filter: %s release: %s action '%s' not enabled, skip", release.Indexer, release.FilterName, release.TorrentName, act.Name)
continue continue
} }
l.Trace().Msgf("release.Process: indexer: %s, filter: %s release: %s , run action: %s", release.Indexer, release.Filter.Name, release.TorrentName, act.Name) l.Trace().Msgf("release.Process: indexer: %s, filter: %s release: %s , run action: %s", release.Indexer, release.FilterName, release.TorrentName, act.Name)
// keep track of actiom clients to avoid sending the same thing all over again // keep track of actiom clients to avoid sending the same thing all over again
_, tried := triedActionClients[actionClientTypeKey{Type: act.Type, ClientID: act.ClientID}] _, tried := triedActionClients[actionClientTypeKey{Type: act.Type, ClientID: act.ClientID}]
if tried { if tried {
l.Trace().Msgf("release.Process: indexer: %s, filter: %s release: %s action client already tried, skip", release.Indexer, release.Filter.Name, release.TorrentName) l.Trace().Msgf("release.Process: indexer: %s, filter: %s release: %s action client already tried, skip", release.Indexer, release.FilterName, release.TorrentName)
continue continue
} }
// run action // run action
status, err := s.runAction(ctx, act, release) status, err := s.runAction(ctx, act, release)
if err != nil { if err != nil {
l.Error().Stack().Err(err).Msgf("release.Process: error running actions for filter: %s", release.Filter.Name) l.Error().Stack().Err(err).Msgf("release.Process: error running actions for filter: %s", release.FilterName)
//continue //continue
} }
rejections = status.Rejections rejections = status.Rejections
if err := s.StoreReleaseActionStatus(ctx, status); err != nil { if err := s.StoreReleaseActionStatus(ctx, status); err != nil {
s.log.Error().Err(err).Msgf("release.Process: error storing action status for filter: %s", release.Filter.Name) s.log.Error().Err(err).Msgf("release.Process: error storing action status for filter: %s", release.FilterName)
} }
if len(rejections) > 0 { if len(rejections) > 0 {
@ -237,12 +248,12 @@ func (s *service) runAction(ctx context.Context, action *domain.Action, release
status := domain.NewReleaseActionStatus(action, release) status := domain.NewReleaseActionStatus(action, release)
if err := s.StoreReleaseActionStatus(ctx, status); err != nil { if err := s.StoreReleaseActionStatus(ctx, status); err != nil {
s.log.Error().Err(err).Msgf("release.runAction: error storing action for filter: %s", release.Filter.Name) s.log.Error().Err(err).Msgf("release.runAction: error storing action for filter: %s", release.FilterName)
} }
rejections, err := s.actionSvc.RunAction(ctx, action, release) rejections, err := s.actionSvc.RunAction(ctx, action, release)
if err != nil { if err != nil {
s.log.Error().Stack().Err(err).Msgf("release.runAction: error running actions for filter: %s", release.Filter.Name) s.log.Error().Stack().Err(err).Msgf("release.runAction: error running actions for filter: %s", release.FilterName)
status.Status = domain.ReleasePushStatusErr status.Status = domain.ReleasePushStatusErr
status.Rejections = []string{err.Error()} status.Rejections = []string{err.Error()}
@ -261,3 +272,54 @@ func (s *service) runAction(ctx context.Context, action *domain.Action, release
return status, nil return status, nil
} }
func (s *service) retryAction(ctx context.Context, action *domain.Action, release *domain.Release) error {
actionStatus, err := s.runAction(ctx, action, release)
if err != nil {
s.log.Error().Err(err).Msgf("release.retryAction: error running actions for filter: %s", release.FilterName)
if err := s.StoreReleaseActionStatus(ctx, actionStatus); err != nil {
s.log.Error().Err(err).Msgf("release.retryAction: error storing filterAction status for filter: %s", release.FilterName)
return err
}
return err
}
if err := s.StoreReleaseActionStatus(ctx, actionStatus); err != nil {
s.log.Error().Err(err).Msgf("release.retryAction: error storing filterAction status for filter: %s", release.FilterName)
return err
}
return nil
}
func (s *service) Retry(ctx context.Context, req *domain.ReleaseActionRetryReq) error {
// get release
release, err := s.Get(ctx, &domain.GetReleaseRequest{Id: req.ReleaseId})
if err != nil {
return err
}
// get release filter action status
status, err := s.GetActionStatus(ctx, &domain.GetReleaseActionStatusRequest{Id: req.ActionStatusId})
if err != nil {
return err
}
// get filter action with action id from status
filterAction, err := s.actionSvc.Get(ctx, &domain.GetActionRequest{Id: int(status.ActionID)})
if err != nil {
return err
}
// run filterAction
if err := s.retryAction(ctx, filterAction, release); err != nil {
s.log.Error().Err(err).Msgf("release.Retry: error re-running action: %s", filterAction.Name)
return err
}
s.log.Info().Msgf("successfully replayed action %s for release %s", filterAction.Name, release.TorrentName)
return nil
}

View file

@ -193,7 +193,8 @@ export const APIClient = {
}, },
indexerOptions: () => appClient.Get<string[]>("api/release/indexers"), indexerOptions: () => appClient.Get<string[]>("api/release/indexers"),
stats: () => appClient.Get<ReleaseStats>("api/release/stats"), stats: () => appClient.Get<ReleaseStats>("api/release/stats"),
delete: () => appClient.Delete("api/release/all") delete: () => appClient.Delete("api/release/all"),
replayAction: (releaseId: number, actionId: number) => appClient.Post(`api/release/${releaseId}/actions/${actionId}/retry`)
}, },
updates: { updates: {
check: () => appClient.Get("api/updates/check"), check: () => appClient.Get("api/updates/check"),

View file

@ -5,11 +5,17 @@
import * as React from "react"; import * as React from "react";
import { formatDistanceToNowStrict } from "date-fns"; import { formatDistanceToNowStrict } from "date-fns";
import { CheckIcon } from "@heroicons/react/24/solid"; import { ArrowPathIcon, CheckIcon } from "@heroicons/react/24/solid";
import { ClockIcon, ExclamationCircleIcon, NoSymbolIcon } from "@heroicons/react/24/outline"; import { ClockIcon, ExclamationCircleIcon, NoSymbolIcon } from "@heroicons/react/24/outline";
import { classNames, simplifyDate } from "@utils"; import { classNames, simplifyDate } from "@utils";
import { Tooltip } from "../tooltips/Tooltip"; import { Tooltip } from "../tooltips/Tooltip";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { APIClient } from "@api/APIClient";
import { filterKeys } from "@screens/filters/list";
import { toast } from "react-hot-toast";
import Toast from "@components/notifications/Toast";
import { RingResizeSpinner } from "@components/Icons";
interface CellProps { interface CellProps {
value: string; value: string;
@ -57,6 +63,46 @@ export const TitleCell = ({ value }: CellProps) => (
</div> </div>
); );
interface RetryActionButtonProps {
status: ReleaseActionStatus;
}
interface RetryAction {
releaseId: number;
actionId: number;
}
const RetryActionButton = ({ status }: RetryActionButtonProps) => {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: (vars: RetryAction) => APIClient.release.replayAction(vars.releaseId, vars.actionId),
onSuccess: () => {
// Invalidate filters just in case, most likely not necessary but can't hurt.
queryClient.invalidateQueries({ queryKey: filterKeys.lists() });
toast.custom((t) => (
<Toast type="success" body={`${status?.action} replayed`} t={t} />
));
}
});
const replayAction = () => {
console.log("replay action");
mutation.mutate({ releaseId: status.release_id,actionId: status.id });
};
return (
<button className="flex items-center px-1.5 py-1 ml-2 border-gray-500 bg-gray-700 rounded hover:bg-gray-600" onClick={replayAction}>
<span className="mr-1.5">Retry</span>
{mutation.isLoading
? <RingResizeSpinner className="text-blue-500 w-4 h-4 iconHeight" aria-hidden="true" />
: <ArrowPathIcon className="h-4 w-4" />
}
</button>
);
};
interface ReleaseStatusCellProps { interface ReleaseStatusCellProps {
value: ReleaseActionStatus[]; value: ReleaseActionStatus[];
} }
@ -64,30 +110,36 @@ interface ReleaseStatusCellProps {
interface StatusCellMapEntry { interface StatusCellMapEntry {
colors: string; colors: string;
icon: React.ReactElement; icon: React.ReactElement;
textFormatter: (text: string) => React.ReactElement; textFormatter: (status: ReleaseActionStatus) => React.ReactElement;
} }
const StatusCellMap: Record<string, StatusCellMapEntry> = { const StatusCellMap: Record<string, StatusCellMapEntry> = {
"PUSH_ERROR": { "PUSH_ERROR": {
colors: "bg-pink-100 text-pink-800 hover:bg-pink-300", colors: "bg-pink-100 text-pink-800 hover:bg-pink-300",
icon: <ExclamationCircleIcon className="h-5 w-5" aria-hidden="true" />, icon: <ExclamationCircleIcon className="h-5 w-5" aria-hidden="true" />,
textFormatter: (text: string) => ( textFormatter: (status: ReleaseActionStatus) => (
<> <>
<span>
Action Action
{" "} {" "}
<span className="font-bold underline underline-offset-2 decoration-2 decoration-red-500"> <span className="font-bold underline underline-offset-2 decoration-2 decoration-red-500">
error error
</span> </span>
{": "} {": "}
{text} {status.action}
</span>
<div>
{status.action_id > 0 && <RetryActionButton status={status} />}
</div>
</> </>
) )
}, },
"PUSH_REJECTED": { "PUSH_REJECTED": {
colors: "bg-blue-100 dark:bg-blue-100 text-blue-400 dark:text-blue-800 hover:bg-blue-300 dark:hover:bg-blue-400", colors: "bg-blue-100 dark:bg-blue-100 text-blue-400 dark:text-blue-800 hover:bg-blue-300 dark:hover:bg-blue-400",
icon: <NoSymbolIcon className="h-5 w-5" aria-hidden="true" />, icon: <NoSymbolIcon className="h-5 w-5" aria-hidden="true" />,
textFormatter: (text: string) => ( textFormatter: (status: ReleaseActionStatus) => (
<> <>
<span>
Action Action
{" "} {" "}
<span <span
@ -96,37 +148,51 @@ const StatusCellMap: Record<string, StatusCellMapEntry> = {
rejected rejected
</span> </span>
{": "} {": "}
{text} {status.action}
</span>
<div>
{status.action_id > 0 && <RetryActionButton status={status} />}
</div>
</> </>
) )
}, },
"PUSH_APPROVED": { "PUSH_APPROVED": {
colors: "bg-green-100 text-green-800 hover:bg-green-300", colors: "bg-green-100 text-green-800 hover:bg-green-300",
icon: <CheckIcon className="h-5 w-5" aria-hidden="true" />, icon: <CheckIcon className="h-5 w-5" aria-hidden="true" />,
textFormatter: (text: string) => ( textFormatter: (status: ReleaseActionStatus) => (
<> <>
<span>
Action Action
{" "} {" "}
<span className="font-bold underline underline-offset-2 decoration-2 decoration-green-500"> <span className="font-bold underline underline-offset-2 decoration-2 decoration-green-500">
approved approved
</span> </span>
{": "} {": "}
{text} {status.action}
</span>
{/*<div>*/}
{/* {status.action_id > 0 && <RetryActionButton status={status} />}*/}
{/*</div>*/}
</> </>
) )
}, },
"PENDING": { "PENDING": {
colors: "bg-yellow-100 text-yellow-800 hover:bg-yellow-200", colors: "bg-yellow-100 text-yellow-800 hover:bg-yellow-200",
icon: <ClockIcon className="h-5 w-5" aria-hidden="true" />, icon: <ClockIcon className="h-5 w-5" aria-hidden="true" />,
textFormatter: (text: string) => ( textFormatter: (status: ReleaseActionStatus) => (
<> <>
<span>
Action Action
{" "} {" "}
<span className="font-bold underline underline-offset-2 decoration-2 decoration-yellow-500"> <span className="font-bold underline underline-offset-2 decoration-2 decoration-yellow-500">
pending pending
</span> </span>
{": "} {": "}
{text} {status.action}
</span>
<div>
{status.action_id > 0 && <RetryActionButton status={status} />}
</div>
</> </>
) )
} }
@ -156,7 +222,7 @@ export const ReleaseStatusCell = ({ value }: ReleaseStatusCellProps) => (
> >
<Tooltip <Tooltip
label={StatusCellMap[v.status].icon} label={StatusCellMap[v.status].icon}
title={StatusCellMap[v.status].textFormatter(v.action)} title={StatusCellMap[v.status].textFormatter(v)}
> >
<div className="mb-1"> <div className="mb-1">
<CellLine title="Type">{v.type}</CellLine> <CellLine title="Type">{v.type}</CellLine>

View file

@ -48,7 +48,7 @@ export const Tooltip = ({
})} })}
> >
{title ? ( {title ? (
<div className="p-2 border-b border-gray-300 bg-gray-100 dark:border-gray-700 dark:bg-gray-800 rounded-t-md"> <div className="flex justify-between items-center p-2 border-b border-gray-300 bg-gray-100 dark:border-gray-700 dark:bg-gray-800 rounded-t-md">
{title} {title}
</div> </div>
) : null} ) : null}

View file

@ -4,11 +4,12 @@
*/ */
import React, { Fragment, useEffect, useRef, useState } from "react"; import React, { Fragment, useEffect, useRef, useState } from "react";
import { useQuery } from "@tanstack/react-query"; import { useMutation, useQuery } from "@tanstack/react-query";
import { Field, FieldArray, FieldProps, FormikValues, useFormikContext } from "formik"; import { Field, FieldArray, FieldProps, FormikValues, useFormikContext } from "formik";
import { Dialog, Switch as SwitchBasic, Transition } from "@headlessui/react"; import { Dialog, Switch as SwitchBasic, Transition } from "@headlessui/react";
import { ChevronRightIcon } from "@heroicons/react/24/solid"; import { ChevronRightIcon } from "@heroicons/react/24/solid";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { toast } from "react-hot-toast";
import { import {
ActionContentLayoutOptions, ActionContentLayoutOptions,
@ -25,6 +26,7 @@ import { classNames } from "@utils";
import { DeleteModal } from "@components/modals"; import { DeleteModal } from "@components/modals";
import { CollapsableSection } from "./details"; import { CollapsableSection } from "./details";
import { TextArea } from "@components/inputs/input"; import { TextArea } from "@components/inputs/input";
import Toast from "@components/notifications/Toast";
interface FilterActionsProps { interface FilterActionsProps {
filter: Filter; filter: Filter;
@ -543,6 +545,23 @@ function FilterActionsItem({ action, clients, idx, initialEdit, remove }: Filter
const [deleteModalIsOpen, toggleDeleteModal] = useToggle(false); const [deleteModalIsOpen, toggleDeleteModal] = useToggle(false);
const [edit, toggleEdit] = useToggle(initialEdit); const [edit, toggleEdit] = useToggle(initialEdit);
const removeMutation = useMutation({
mutationFn: (id: number) => APIClient.actions.delete(id),
onSuccess: () => {
remove(idx);
// Invalidate filters just in case, most likely not necessary but can't hurt.
// queryClient.invalidateQueries({ queryKey: filterKeys.detail(id) });
toast.custom((t) => (
<Toast type="success" body={`Action ${action?.name} was deleted`} t={t} />
));
}
});
const removeAction = (id: number) => {
removeMutation.mutate(id);
};
return ( return (
<li> <li>
<div <div
@ -622,7 +641,7 @@ function FilterActionsItem({ action, clients, idx, initialEdit, remove }: Filter
isOpen={deleteModalIsOpen} isOpen={deleteModalIsOpen}
buttonRef={cancelButtonRef} buttonRef={cancelButtonRef}
toggle={toggleDeleteModal} toggle={toggleDeleteModal}
deleteAction={() => remove(idx)} deleteAction={() => removeAction(action.id)}
title="Remove filter action" title="Remove filter action"
text="Are you sure you want to remove this action? This action cannot be undone." text="Are you sure you want to remove this action? This action cannot be undone."
/> />

View file

@ -23,9 +23,12 @@ interface ReleaseActionStatus {
id: number; id: number;
status: string; status: string;
action: string; action: string;
action_id: number;
type: string; type: string;
client: string; client: string;
filter: string; filter: string;
filter_id: number;
release_id: number;
rejections: string[]; rejections: string[];
timestamp: string timestamp: string
} }