From 9a2e47b23a6d460acfce9b1b77e6f9fb06ca1b75 Mon Sep 17 00:00:00 2001
From: zeripath <art27@cantab.net>
Date: Sun, 17 May 2020 00:31:38 +0100
Subject: [PATCH] Refactor Cron and merge dashboard tasks (#10745)

* Refactor Cron and merge dashboard tasks

* Merge Cron and Dashboard tasks
* Make every cron task report a system notice on completion
* Refactor the creation of these tasks
* Ensure that execution counts of tasks is correct
* Allow cron tasks to be started from the cron page

* golangci-lint fixes

* Enforce that only one task with the same name can be registered

Signed-off-by: Andrew Thornton <art27@cantab.net>

* fix name check

Signed-off-by: Andrew Thornton <art27@cantab.net>

* as per @guillep2k

* as per @lafriks

Signed-off-by: Andrew Thornton <art27@cantab.net>

* Add git.CommandContext variants

Signed-off-by: Andrew Thornton <art27@cantab.net>

Co-authored-by: Lauris BH <lauris@nix.lv>
Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>
Co-authored-by: techknowlogick <techknowlogick@gitea.io>
---
 integrations/auth_ldap_test.go  |   4 +-
 models/admin.go                 |  15 ++-
 models/branches.go              |   5 +-
 models/error.go                 |  22 ++++
 models/repo.go                  |  65 +++++++-----
 models/user.go                  |  51 +++++----
 modules/auth/admin.go           |   2 +-
 modules/cron/cron.go            | 179 +++++++++++---------------------
 modules/cron/setting.go         |  72 +++++++++++++
 modules/cron/tasks.go           | 166 +++++++++++++++++++++++++++++
 modules/cron/tasks_basic.go     | 118 +++++++++++++++++++++
 modules/cron/tasks_extended.go  | 119 +++++++++++++++++++++
 modules/git/command.go          |  14 ++-
 modules/git/git.go              |   4 +-
 modules/migrations/update.go    |  10 +-
 modules/repository/check.go     | 102 +++++++++++-------
 modules/repository/hooks.go     |   9 +-
 modules/setting/cron.go         | 129 +----------------------
 modules/setting/setting.go      |   1 -
 options/locale/locale_en-US.ini |  36 ++++---
 routers/admin/admin.go          |  65 ++----------
 routers/init.go                 |  35 +++++++
 services/mirror/mirror.go       |   9 +-
 templates/admin/dashboard.tmpl  |  27 ++---
 templates/admin/monitor.tmpl    |  45 ++++----
 25 files changed, 851 insertions(+), 453 deletions(-)
 create mode 100644 modules/cron/setting.go
 create mode 100644 modules/cron/tasks.go
 create mode 100644 modules/cron/tasks_basic.go
 create mode 100644 modules/cron/tasks_extended.go

diff --git a/integrations/auth_ldap_test.go b/integrations/auth_ldap_test.go
index 6c6147f20e..520a611eab 100644
--- a/integrations/auth_ldap_test.go
+++ b/integrations/auth_ldap_test.go
@@ -151,7 +151,7 @@ func TestLDAPUserSync(t *testing.T) {
 	}
 	defer prepareTestEnv(t)()
 	addAuthSourceLDAP(t, "")
-	models.SyncExternalUsers(context.Background())
+	models.SyncExternalUsers(context.Background(), true)
 
 	session := loginUser(t, "user1")
 	// Check if users exists
@@ -216,7 +216,7 @@ func TestLDAPUserSSHKeySync(t *testing.T) {
 	defer prepareTestEnv(t)()
 	addAuthSourceLDAP(t, "sshPublicKey")
 
-	models.SyncExternalUsers(context.Background())
+	models.SyncExternalUsers(context.Background(), true)
 
 	// Check if users has SSH keys synced
 	for _, u := range gitLDAPUsers {
diff --git a/models/admin.go b/models/admin.go
index 271c5307a0..01f0ba3486 100644
--- a/models/admin.go
+++ b/models/admin.go
@@ -20,6 +20,8 @@ type NoticeType int
 const (
 	//NoticeRepository type
 	NoticeRepository NoticeType = iota + 1
+	// NoticeTask type
+	NoticeTask
 )
 
 // Notice represents a system notice for admin.
@@ -36,11 +38,14 @@ func (n *Notice) TrStr() string {
 }
 
 // CreateNotice creates new system notice.
-func CreateNotice(tp NoticeType, desc string) error {
-	return createNotice(x, tp, desc)
+func CreateNotice(tp NoticeType, desc string, args ...interface{}) error {
+	return createNotice(x, tp, desc, args...)
 }
 
-func createNotice(e Engine, tp NoticeType, desc string) error {
+func createNotice(e Engine, tp NoticeType, desc string, args ...interface{}) error {
+	if len(args) > 0 {
+		desc = fmt.Sprintf(desc, args...)
+	}
 	n := &Notice{
 		Type:        tp,
 		Description: desc,
@@ -50,8 +55,8 @@ func createNotice(e Engine, tp NoticeType, desc string) error {
 }
 
 // CreateRepositoryNotice creates new system notice with type NoticeRepository.
-func CreateRepositoryNotice(desc string) error {
-	return createNotice(x, NoticeRepository, desc)
+func CreateRepositoryNotice(desc string, args ...interface{}) error {
+	return createNotice(x, NoticeRepository, desc, args...)
 }
 
 // RemoveAllWithNotice removes all directories in given path and
diff --git a/models/branches.go b/models/branches.go
index e6b8d61a70..688e2d7f51 100644
--- a/models/branches.go
+++ b/models/branches.go
@@ -12,7 +12,6 @@ import (
 
 	"code.gitea.io/gitea/modules/base"
 	"code.gitea.io/gitea/modules/log"
-	"code.gitea.io/gitea/modules/setting"
 	"code.gitea.io/gitea/modules/timeutil"
 	"code.gitea.io/gitea/modules/util"
 
@@ -561,11 +560,11 @@ func RemoveDeletedBranch(repoID int64, branch string) error {
 }
 
 // RemoveOldDeletedBranches removes old deleted branches
-func RemoveOldDeletedBranches(ctx context.Context) {
+func RemoveOldDeletedBranches(ctx context.Context, olderThan time.Duration) {
 	// Nothing to do for shutdown or terminate
 	log.Trace("Doing: DeletedBranchesCleanup")
 
-	deleteBefore := time.Now().Add(-setting.Cron.DeletedBranchesCleanup.OlderThan)
+	deleteBefore := time.Now().Add(-olderThan)
 	_, err := x.Where("deleted_unix < ?", deleteBefore.Unix()).Delete(new(DeletedBranch))
 	if err != nil {
 		log.Error("DeletedBranchesCleanup: %v", err)
diff --git a/models/error.go b/models/error.go
index 7370bd1571..3b05a7152c 100644
--- a/models/error.go
+++ b/models/error.go
@@ -85,6 +85,28 @@ func (err ErrSSHDisabled) Error() string {
 	return "SSH is disabled"
 }
 
+// ErrCancelled represents an error due to context cancellation
+type ErrCancelled struct {
+	Message string
+}
+
+// IsErrCancelled checks if an error is a ErrCancelled.
+func IsErrCancelled(err error) bool {
+	_, ok := err.(ErrCancelled)
+	return ok
+}
+
+func (err ErrCancelled) Error() string {
+	return "Cancelled: " + err.Message
+}
+
+// ErrCancelledf returns an ErrCancelled for the provided format and args
+func ErrCancelledf(format string, args ...interface{}) error {
+	return ErrCancelled{
+		fmt.Sprintf(format, args...),
+	}
+}
+
 //  ____ ___
 // |    |   \______ ___________
 // |    |   /  ___// __ \_  __ \
diff --git a/models/repo.go b/models/repo.go
index 875c9ad1c3..f79740e747 100644
--- a/models/repo.go
+++ b/models/repo.go
@@ -1853,35 +1853,44 @@ func GetPrivateRepositoryCount(u *User) (int64, error) {
 }
 
 // DeleteRepositoryArchives deletes all repositories' archives.
-func DeleteRepositoryArchives() error {
+func DeleteRepositoryArchives(ctx context.Context) error {
 	return x.
 		Where("id > 0").
 		Iterate(new(Repository),
 			func(idx int, bean interface{}) error {
 				repo := bean.(*Repository)
+				select {
+				case <-ctx.Done():
+					return ErrCancelledf("before deleting repository archives for %s", repo.FullName())
+				default:
+				}
 				return os.RemoveAll(filepath.Join(repo.RepoPath(), "archives"))
 			})
 }
 
 // DeleteOldRepositoryArchives deletes old repository archives.
-func DeleteOldRepositoryArchives(ctx context.Context) {
+func DeleteOldRepositoryArchives(ctx context.Context, olderThan time.Duration) error {
 	log.Trace("Doing: ArchiveCleanup")
 
 	if err := x.Where("id > 0").Iterate(new(Repository), func(idx int, bean interface{}) error {
-		return deleteOldRepositoryArchives(ctx, idx, bean)
+		return deleteOldRepositoryArchives(ctx, olderThan, idx, bean)
 	}); err != nil {
-		log.Error("ArchiveClean: %v", err)
+		log.Trace("Error: ArchiveClean: %v", err)
+		return err
 	}
+
+	log.Trace("Finished: ArchiveCleanup")
+	return nil
 }
 
-func deleteOldRepositoryArchives(ctx context.Context, idx int, bean interface{}) error {
+func deleteOldRepositoryArchives(ctx context.Context, olderThan time.Duration, idx int, bean interface{}) error {
 	repo := bean.(*Repository)
 	basePath := filepath.Join(repo.RepoPath(), "archives")
 
 	for _, ty := range []string{"zip", "targz"} {
 		select {
 		case <-ctx.Done():
-			return fmt.Errorf("Aborted due to shutdown:\nin delete of old repository archives %v\nat delete file %s", repo, ty)
+			return ErrCancelledf("before deleting old repository archives with filetype %s for %s", ty, repo.FullName())
 		default:
 		}
 
@@ -1904,12 +1913,12 @@ func deleteOldRepositoryArchives(ctx context.Context, idx int, bean interface{})
 			return err
 		}
 
-		minimumOldestTime := time.Now().Add(-setting.Cron.ArchiveCleanup.OlderThan)
+		minimumOldestTime := time.Now().Add(-olderThan)
 		for _, info := range files {
 			if info.ModTime().Before(minimumOldestTime) && !info.IsDir() {
 				select {
 				case <-ctx.Done():
-					return fmt.Errorf("Aborted due to shutdown:\nin delete of old repository archives %v\nat delete file %s - %s", repo, ty, info.Name())
+					return ErrCancelledf("before deleting old repository archive file %s with filetype %s for %s", info.Name(), ty, repo.FullName())
 				default:
 				}
 				toDelete := filepath.Join(path, info.Name())
@@ -1936,13 +1945,13 @@ func repoStatsCheck(ctx context.Context, checker *repoChecker) {
 		return
 	}
 	for _, result := range results {
+		id := com.StrTo(result["id"]).MustInt64()
 		select {
 		case <-ctx.Done():
-			log.Warn("CheckRepoStats: Aborting due to shutdown")
+			log.Warn("CheckRepoStats: Cancelled before checking %s for Repo[%d]", checker.desc, id)
 			return
 		default:
 		}
-		id := com.StrTo(result["id"]).MustInt64()
 		log.Trace("Updating %s: %d", checker.desc, id)
 		_, err = x.Exec(checker.correctSQL, id, id)
 		if err != nil {
@@ -1952,7 +1961,7 @@ func repoStatsCheck(ctx context.Context, checker *repoChecker) {
 }
 
 // CheckRepoStats checks the repository stats
-func CheckRepoStats(ctx context.Context) {
+func CheckRepoStats(ctx context.Context) error {
 	log.Trace("Doing: CheckRepoStats")
 
 	checkers := []*repoChecker{
@@ -1987,13 +1996,13 @@ func CheckRepoStats(ctx context.Context) {
 			"issue count 'num_comments'",
 		},
 	}
-	for i := range checkers {
+	for _, checker := range checkers {
 		select {
 		case <-ctx.Done():
-			log.Warn("CheckRepoStats: Aborting due to shutdown")
-			return
+			log.Warn("CheckRepoStats: Cancelled before %s", checker.desc)
+			return ErrCancelledf("before checking %s", checker.desc)
 		default:
-			repoStatsCheck(ctx, checkers[i])
+			repoStatsCheck(ctx, checker)
 		}
 	}
 
@@ -2004,13 +2013,13 @@ func CheckRepoStats(ctx context.Context) {
 		log.Error("Select %s: %v", desc, err)
 	} else {
 		for _, result := range results {
+			id := com.StrTo(result["id"]).MustInt64()
 			select {
 			case <-ctx.Done():
-				log.Warn("CheckRepoStats: Aborting due to shutdown")
-				return
+				log.Warn("CheckRepoStats: Cancelled during %s for repo ID %d", desc, id)
+				return ErrCancelledf("during %s for repo ID %d", desc, id)
 			default:
 			}
-			id := com.StrTo(result["id"]).MustInt64()
 			log.Trace("Updating %s: %d", desc, id)
 			_, err = x.Exec("UPDATE `repository` SET num_closed_issues=(SELECT COUNT(*) FROM `issue` WHERE repo_id=? AND is_closed=? AND is_pull=?) WHERE id=?", id, true, false, id)
 			if err != nil {
@@ -2027,13 +2036,13 @@ func CheckRepoStats(ctx context.Context) {
 		log.Error("Select %s: %v", desc, err)
 	} else {
 		for _, result := range results {
+			id := com.StrTo(result["id"]).MustInt64()
 			select {
 			case <-ctx.Done():
-				log.Warn("CheckRepoStats: Aborting due to shutdown")
-				return
+				log.Warn("CheckRepoStats: Cancelled")
+				return ErrCancelledf("during %s for repo ID %d", desc, id)
 			default:
 			}
-			id := com.StrTo(result["id"]).MustInt64()
 			log.Trace("Updating %s: %d", desc, id)
 			_, err = x.Exec("UPDATE `repository` SET num_closed_pulls=(SELECT COUNT(*) FROM `issue` WHERE repo_id=? AND is_closed=? AND is_pull=?) WHERE id=?", id, true, true, id)
 			if err != nil {
@@ -2050,13 +2059,13 @@ func CheckRepoStats(ctx context.Context) {
 		log.Error("Select repository count 'num_forks': %v", err)
 	} else {
 		for _, result := range results {
+			id := com.StrTo(result["id"]).MustInt64()
 			select {
 			case <-ctx.Done():
-				log.Warn("CheckRepoStats: Aborting due to shutdown")
-				return
+				log.Warn("CheckRepoStats: Cancelled")
+				return ErrCancelledf("during %s for repo ID %d", desc, id)
 			default:
 			}
-			id := com.StrTo(result["id"]).MustInt64()
 			log.Trace("Updating repository count 'num_forks': %d", id)
 
 			repo, err := GetRepositoryByID(id)
@@ -2079,6 +2088,7 @@ func CheckRepoStats(ctx context.Context) {
 		}
 	}
 	// ***** END: Repository.NumForks *****
+	return nil
 }
 
 // SetArchiveRepoState sets if a repo is archived
@@ -2189,12 +2199,17 @@ func (repo *Repository) generateRandomAvatar(e Engine) error {
 }
 
 // RemoveRandomAvatars removes the randomly generated avatars that were created for repositories
-func RemoveRandomAvatars() error {
+func RemoveRandomAvatars(ctx context.Context) error {
 	return x.
 		Where("id > 0").BufferSize(setting.Database.IterateBufferSize).
 		Iterate(new(Repository),
 			func(idx int, bean interface{}) error {
 				repository := bean.(*Repository)
+				select {
+				case <-ctx.Done():
+					return ErrCancelledf("before random avatars removed for %s", repository.FullName())
+				default:
+				}
 				stringifiedID := strconv.FormatInt(repository.ID, 10)
 				if repository.Avatar == stringifiedID {
 					return repository.DeleteAvatar()
diff --git a/models/user.go b/models/user.go
index 01cce2506d..8875840db7 100644
--- a/models/user.go
+++ b/models/user.go
@@ -1321,16 +1321,30 @@ func DeleteUser(u *User) (err error) {
 	return sess.Commit()
 }
 
-// DeleteInactivateUsers deletes all inactivate users and email addresses.
-func DeleteInactivateUsers() (err error) {
+// DeleteInactiveUsers deletes all inactive users and email addresses.
+func DeleteInactiveUsers(ctx context.Context, olderThan time.Duration) (err error) {
 	users := make([]*User, 0, 10)
-	if err = x.
-		Where("is_active = ?", false).
-		Find(&users); err != nil {
-		return fmt.Errorf("get all inactive users: %v", err)
+	if olderThan > 0 {
+		if err = x.
+			Where("is_active = ? and created_unix < ?", false, time.Now().Add(-olderThan).Unix()).
+			Find(&users); err != nil {
+			return fmt.Errorf("get all inactive users: %v", err)
+		}
+	} else {
+		if err = x.
+			Where("is_active = ?", false).
+			Find(&users); err != nil {
+			return fmt.Errorf("get all inactive users: %v", err)
+		}
+
 	}
 	// FIXME: should only update authorized_keys file once after all deletions.
 	for _, u := range users {
+		select {
+		case <-ctx.Done():
+			return ErrCancelledf("Before delete inactive user %s", u.Name)
+		default:
+		}
 		if err = DeleteUser(u); err != nil {
 			// Ignore users that were set inactive by admin.
 			if IsErrUserOwnRepos(err) || IsErrUserHasOrgs(err) {
@@ -1814,25 +1828,23 @@ func synchronizeLdapSSHPublicKeys(usr *User, s *LoginSource, sshPublicKeys []str
 }
 
 // SyncExternalUsers is used to synchronize users with external authorization source
-func SyncExternalUsers(ctx context.Context) {
+func SyncExternalUsers(ctx context.Context, updateExisting bool) error {
 	log.Trace("Doing: SyncExternalUsers")
 
 	ls, err := LoginSources()
 	if err != nil {
 		log.Error("SyncExternalUsers: %v", err)
-		return
+		return err
 	}
 
-	updateExisting := setting.Cron.SyncExternalUsers.UpdateExisting
-
 	for _, s := range ls {
 		if !s.IsActived || !s.IsSyncEnabled {
 			continue
 		}
 		select {
 		case <-ctx.Done():
-			log.Warn("SyncExternalUsers: Aborted due to shutdown before update of %s", s.Name)
-			return
+			log.Warn("SyncExternalUsers: Cancelled before update of %s", s.Name)
+			return ErrCancelledf("Before update of %s", s.Name)
 		default:
 		}
 
@@ -1850,12 +1862,12 @@ func SyncExternalUsers(ctx context.Context) {
 				Find(&users)
 			if err != nil {
 				log.Error("SyncExternalUsers: %v", err)
-				return
+				return err
 			}
 			select {
 			case <-ctx.Done():
-				log.Warn("SyncExternalUsers: Aborted due to shutdown before update of %s", s.Name)
-				return
+				log.Warn("SyncExternalUsers: Cancelled before update of %s", s.Name)
+				return ErrCancelledf("Before update of %s", s.Name)
 			default:
 			}
 
@@ -1877,7 +1889,7 @@ func SyncExternalUsers(ctx context.Context) {
 			for _, su := range sr {
 				select {
 				case <-ctx.Done():
-					log.Warn("SyncExternalUsers: Aborted due to shutdown at update of %s before completed update of users", s.Name)
+					log.Warn("SyncExternalUsers: Cancelled at update of %s before completed update of users", s.Name)
 					// Rewrite authorized_keys file if LDAP Public SSH Key attribute is set and any key was added or removed
 					if sshKeysNeedUpdate {
 						err = RewriteAllPublicKeys()
@@ -1885,7 +1897,7 @@ func SyncExternalUsers(ctx context.Context) {
 							log.Error("RewriteAllPublicKeys: %v", err)
 						}
 					}
-					return
+					return ErrCancelledf("During update of %s before completed update of users", s.Name)
 				default:
 				}
 				if len(su.Username) == 0 {
@@ -1980,8 +1992,8 @@ func SyncExternalUsers(ctx context.Context) {
 
 			select {
 			case <-ctx.Done():
-				log.Warn("SyncExternalUsers: Aborted due to shutdown at update of %s before delete users", s.Name)
-				return
+				log.Warn("SyncExternalUsers: Cancelled during update of %s before delete users", s.Name)
+				return ErrCancelledf("During update of %s before delete users", s.Name)
 			default:
 			}
 
@@ -2008,4 +2020,5 @@ func SyncExternalUsers(ctx context.Context) {
 			}
 		}
 	}
+	return nil
 }
diff --git a/modules/auth/admin.go b/modules/auth/admin.go
index a772680680..af7197d2ae 100644
--- a/modules/auth/admin.go
+++ b/modules/auth/admin.go
@@ -51,7 +51,7 @@ func (f *AdminEditUserForm) Validate(ctx *macaron.Context, errs binding.Errors)
 
 // AdminDashboardForm form for admin dashboard operations
 type AdminDashboardForm struct {
-	Op int `binding:"required"`
+	Op string `binding:"required"`
 }
 
 // Validate validates form fields
diff --git a/modules/cron/cron.go b/modules/cron/cron.go
index 692642e4ce..ae309bd866 100644
--- a/modules/cron/cron.go
+++ b/modules/cron/cron.go
@@ -9,143 +9,86 @@ import (
 	"context"
 	"time"
 
-	"code.gitea.io/gitea/models"
 	"code.gitea.io/gitea/modules/graceful"
-	"code.gitea.io/gitea/modules/log"
-	"code.gitea.io/gitea/modules/migrations"
-	repo_module "code.gitea.io/gitea/modules/repository"
-	"code.gitea.io/gitea/modules/setting"
 	"code.gitea.io/gitea/modules/sync"
-	mirror_service "code.gitea.io/gitea/services/mirror"
 
 	"github.com/gogs/cron"
 )
 
-const (
-	mirrorUpdate            = "mirror_update"
-	gitFsck                 = "git_fsck"
-	checkRepos              = "check_repos"
-	archiveCleanup          = "archive_cleanup"
-	syncExternalUsers       = "sync_external_users"
-	deletedBranchesCleanup  = "deleted_branches_cleanup"
-	updateMigrationPosterID = "update_migration_post_id"
-)
-
 var c = cron.New()
 
 // Prevent duplicate running tasks.
 var taskStatusTable = sync.NewStatusTable()
 
-// Func defines a cron function body
-type Func func()
-
-// WithUnique wrap a cron func with an unique running check
-func WithUnique(name string, body func(context.Context)) Func {
-	return func() {
-		if !taskStatusTable.StartIfNotRunning(name) {
-			return
-		}
-		defer taskStatusTable.Stop(name)
-		graceful.GetManager().RunWithShutdownContext(body)
-	}
-}
-
 // NewContext begins cron tasks
 // Each cron task is run within the shutdown context as a running server
 // AtShutdown the cron server is stopped
 func NewContext() {
-	var (
-		entry *cron.Entry
-		err   error
-	)
-	if setting.Cron.UpdateMirror.Enabled {
-		entry, err = c.AddFunc("Update mirrors", setting.Cron.UpdateMirror.Schedule, WithUnique(mirrorUpdate, mirror_service.Update))
-		if err != nil {
-			log.Fatal("Cron[Update mirrors]: %v", err)
-		}
-		if setting.Cron.UpdateMirror.RunAtStart {
-			entry.Prev = time.Now()
-			entry.ExecTimes++
-			go WithUnique(mirrorUpdate, mirror_service.Update)()
-		}
-	}
-	if setting.Cron.RepoHealthCheck.Enabled {
-		entry, err = c.AddFunc("Repository health check", setting.Cron.RepoHealthCheck.Schedule, WithUnique(gitFsck, func(ctx context.Context) {
-			if err := repo_module.GitFsck(ctx); err != nil {
-				log.Error("GitFsck: %s", err)
-			}
-		}))
-		if err != nil {
-			log.Fatal("Cron[Repository health check]: %v", err)
-		}
-		if setting.Cron.RepoHealthCheck.RunAtStart {
-			entry.Prev = time.Now()
-			entry.ExecTimes++
-			go WithUnique(gitFsck, func(ctx context.Context) {
-				if err := repo_module.GitFsck(ctx); err != nil {
-					log.Error("GitFsck: %s", err)
-				}
-			})()
-		}
-	}
-	if setting.Cron.CheckRepoStats.Enabled {
-		entry, err = c.AddFunc("Check repository statistics", setting.Cron.CheckRepoStats.Schedule, WithUnique(checkRepos, models.CheckRepoStats))
-		if err != nil {
-			log.Fatal("Cron[Check repository statistics]: %v", err)
-		}
-		if setting.Cron.CheckRepoStats.RunAtStart {
-			entry.Prev = time.Now()
-			entry.ExecTimes++
-			go WithUnique(checkRepos, models.CheckRepoStats)()
-		}
-	}
-	if setting.Cron.ArchiveCleanup.Enabled {
-		entry, err = c.AddFunc("Clean up old repository archives", setting.Cron.ArchiveCleanup.Schedule, WithUnique(archiveCleanup, models.DeleteOldRepositoryArchives))
-		if err != nil {
-			log.Fatal("Cron[Clean up old repository archives]: %v", err)
-		}
-		if setting.Cron.ArchiveCleanup.RunAtStart {
-			entry.Prev = time.Now()
-			entry.ExecTimes++
-			go WithUnique(archiveCleanup, models.DeleteOldRepositoryArchives)()
-		}
-	}
-	if setting.Cron.SyncExternalUsers.Enabled {
-		entry, err = c.AddFunc("Synchronize external users", setting.Cron.SyncExternalUsers.Schedule, WithUnique(syncExternalUsers, models.SyncExternalUsers))
-		if err != nil {
-			log.Fatal("Cron[Synchronize external users]: %v", err)
-		}
-		if setting.Cron.SyncExternalUsers.RunAtStart {
-			entry.Prev = time.Now()
-			entry.ExecTimes++
-			go WithUnique(syncExternalUsers, models.SyncExternalUsers)()
-		}
-	}
-	if setting.Cron.DeletedBranchesCleanup.Enabled {
-		entry, err = c.AddFunc("Remove old deleted branches", setting.Cron.DeletedBranchesCleanup.Schedule, WithUnique(deletedBranchesCleanup, models.RemoveOldDeletedBranches))
-		if err != nil {
-			log.Fatal("Cron[Remove old deleted branches]: %v", err)
-		}
-		if setting.Cron.DeletedBranchesCleanup.RunAtStart {
-			entry.Prev = time.Now()
-			entry.ExecTimes++
-			go WithUnique(deletedBranchesCleanup, models.RemoveOldDeletedBranches)()
-		}
-	}
+	initBasicTasks()
+	initExtendedTasks()
 
-	entry, err = c.AddFunc("Update migrated repositories' issues and comments' posterid", setting.Cron.UpdateMigrationPosterID.Schedule, WithUnique(updateMigrationPosterID, migrations.UpdateMigrationPosterID))
-	if err != nil {
-		log.Fatal("Cron[Update migrated repositories]: %v", err)
+	lock.Lock()
+	for _, task := range tasks {
+		if task.IsEnabled() && task.DoRunAtStart() {
+			go task.Run()
+		}
 	}
-	entry.Prev = time.Now()
-	entry.ExecTimes++
-	go WithUnique(updateMigrationPosterID, migrations.UpdateMigrationPosterID)()
 
 	c.Start()
-	graceful.GetManager().RunAtShutdown(context.Background(), c.Stop)
+	started = true
+	lock.Unlock()
+	graceful.GetManager().RunAtShutdown(context.Background(), func() {
+		c.Stop()
+		lock.Lock()
+		started = false
+		lock.Unlock()
+	})
+
 }
 
-// ListTasks returns all running cron tasks.
-func ListTasks() []*cron.Entry {
-	return c.Entries()
+// TaskTableRow represents a task row in the tasks table
+type TaskTableRow struct {
+	Name      string
+	Spec      string
+	Next      time.Time
+	Prev      time.Time
+	ExecTimes int64
+}
+
+// TaskTable represents a table of tasks
+type TaskTable []*TaskTableRow
+
+// ListTasks returns all running cron tasks.
+func ListTasks() TaskTable {
+	entries := c.Entries()
+	eMap := map[string]*cron.Entry{}
+	for _, e := range entries {
+		eMap[e.Description] = e
+	}
+	lock.Lock()
+	defer lock.Unlock()
+	tTable := make([]*TaskTableRow, 0, len(tasks))
+	for _, task := range tasks {
+		spec := "-"
+		var (
+			next time.Time
+			prev time.Time
+		)
+		if e, ok := eMap[task.Name]; ok {
+			spec = e.Spec
+			next = e.Next
+			prev = e.Prev
+		}
+		task.lock.Lock()
+		tTable = append(tTable, &TaskTableRow{
+			Name:      task.Name,
+			Spec:      spec,
+			Next:      next,
+			Prev:      prev,
+			ExecTimes: task.ExecTimes,
+		})
+		task.lock.Unlock()
+	}
+
+	return tTable
 }
diff --git a/modules/cron/setting.go b/modules/cron/setting.go
new file mode 100644
index 0000000000..dd93d03986
--- /dev/null
+++ b/modules/cron/setting.go
@@ -0,0 +1,72 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// Use of this source code is governed by a MIT-style
+// license that can be found in the LICENSE file.
+
+package cron
+
+import (
+	"time"
+
+	"code.gitea.io/gitea/models"
+	"github.com/unknwon/i18n"
+)
+
+// Config represents a basic configuration interface that cron task
+type Config interface {
+	IsEnabled() bool
+	DoRunAtStart() bool
+	GetSchedule() string
+	FormatMessage(name, status string, doer *models.User, args ...interface{}) string
+}
+
+// BaseConfig represents the basic config for a Cron task
+type BaseConfig struct {
+	Enabled    bool
+	RunAtStart bool
+	Schedule   string
+}
+
+// OlderThanConfig represents a cron task with OlderThan setting
+type OlderThanConfig struct {
+	BaseConfig
+	OlderThan time.Duration
+}
+
+// UpdateExistingConfig represents a cron task with UpdateExisting setting
+type UpdateExistingConfig struct {
+	BaseConfig
+	UpdateExisting bool
+}
+
+// GetSchedule returns the schedule for the base config
+func (b *BaseConfig) GetSchedule() string {
+	return b.Schedule
+}
+
+// IsEnabled returns the enabled status for the config
+func (b *BaseConfig) IsEnabled() bool {
+	return b.Enabled
+}
+
+// DoRunAtStart returns whether the task should be run at the start
+func (b *BaseConfig) DoRunAtStart() bool {
+	return b.RunAtStart
+}
+
+// FormatMessage returns a message for the task
+func (b *BaseConfig) FormatMessage(name, status string, doer *models.User, args ...interface{}) string {
+	realArgs := make([]interface{}, 0, len(args)+2)
+	realArgs = append(realArgs, i18n.Tr("en-US", "admin.dashboard."+name))
+	if doer == nil {
+		realArgs = append(realArgs, "(Cron)")
+	} else {
+		realArgs = append(realArgs, doer.Name)
+	}
+	if len(args) > 0 {
+		realArgs = append(realArgs, args...)
+	}
+	if doer == nil || (doer.ID == -1 && doer.Name == "(Cron)") {
+		return i18n.Tr("en-US", "admin.dashboard.cron."+status, realArgs...)
+	}
+	return i18n.Tr("en-US", "admin.dashboard.task."+status, realArgs...)
+}
diff --git a/modules/cron/tasks.go b/modules/cron/tasks.go
new file mode 100644
index 0000000000..a97326bd0f
--- /dev/null
+++ b/modules/cron/tasks.go
@@ -0,0 +1,166 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// Use of this source code is governed by a MIT-style
+// license that can be found in the LICENSE file.
+
+package cron
+
+import (
+	"context"
+	"fmt"
+	"reflect"
+	"sync"
+
+	"code.gitea.io/gitea/models"
+	"code.gitea.io/gitea/modules/graceful"
+	"code.gitea.io/gitea/modules/log"
+	"code.gitea.io/gitea/modules/process"
+	"code.gitea.io/gitea/modules/setting"
+)
+
+var lock = sync.Mutex{}
+var started = false
+var tasks = []*Task{}
+var tasksMap = map[string]*Task{}
+
+// Task represents a Cron task
+type Task struct {
+	lock      sync.Mutex
+	Name      string
+	config    Config
+	fun       func(context.Context, *models.User, Config) error
+	ExecTimes int64
+}
+
+// DoRunAtStart returns if this task should run at the start
+func (t *Task) DoRunAtStart() bool {
+	return t.config.DoRunAtStart()
+}
+
+// IsEnabled returns if this task is enabled as cron task
+func (t *Task) IsEnabled() bool {
+	return t.config.IsEnabled()
+}
+
+// GetConfig will return a copy of the task's config
+func (t *Task) GetConfig() Config {
+	if reflect.TypeOf(t.config).Kind() == reflect.Ptr {
+		// Pointer:
+		return reflect.New(reflect.ValueOf(t.config).Elem().Type()).Interface().(Config)
+	}
+	// Not pointer:
+	return reflect.New(reflect.TypeOf(t.config)).Elem().Interface().(Config)
+}
+
+// Run will run the task incrementing the cron counter with no user defined
+func (t *Task) Run() {
+	t.RunWithUser(&models.User{
+		ID:        -1,
+		Name:      "(Cron)",
+		LowerName: "(cron)",
+	}, t.config)
+}
+
+// RunWithUser will run the task incrementing the cron counter at the time with User
+func (t *Task) RunWithUser(doer *models.User, config Config) {
+	if !taskStatusTable.StartIfNotRunning(t.Name) {
+		return
+	}
+	t.lock.Lock()
+	if config == nil {
+		config = t.config
+	}
+	t.ExecTimes++
+	t.lock.Unlock()
+	defer func() {
+		taskStatusTable.Stop(t.Name)
+		if err := recover(); err != nil {
+			// Recover a panic within the
+			combinedErr := fmt.Errorf("%s\n%s", err, log.Stack(2))
+			log.Error("PANIC whilst running task: %s Value: %v", t.Name, combinedErr)
+		}
+	}()
+	graceful.GetManager().RunWithShutdownContext(func(baseCtx context.Context) {
+		ctx, cancel := context.WithCancel(baseCtx)
+		defer cancel()
+		pm := process.GetManager()
+		pid := pm.Add(config.FormatMessage(t.Name, "process", doer), cancel)
+		defer pm.Remove(pid)
+		if err := t.fun(ctx, doer, config); err != nil {
+			if models.IsErrCancelled(err) {
+				message := err.(models.ErrCancelled).Message
+				if err := models.CreateNotice(models.NoticeTask, config.FormatMessage(t.Name, "aborted", doer, message)); err != nil {
+					log.Error("CreateNotice: %v", err)
+				}
+				return
+			}
+			if err := models.CreateNotice(models.NoticeTask, config.FormatMessage(t.Name, "error", doer, err)); err != nil {
+				log.Error("CreateNotice: %v", err)
+			}
+			return
+		}
+		if err := models.CreateNotice(models.NoticeTask, config.FormatMessage(t.Name, "finished", doer)); err != nil {
+			log.Error("CreateNotice: %v", err)
+		}
+	})
+}
+
+// GetTask gets the named task
+func GetTask(name string) *Task {
+	lock.Lock()
+	defer lock.Unlock()
+	log.Info("Getting %s in %v", name, tasksMap[name])
+
+	return tasksMap[name]
+}
+
+// RegisterTask allows a task to be registered with the cron service
+func RegisterTask(name string, config Config, fun func(context.Context, *models.User, Config) error) error {
+	log.Debug("Registering task: %s", name)
+	_, err := setting.GetCronSettings(name, config)
+	if err != nil {
+		log.Error("Unable to register cron task with name: %s Error: %v", name, err)
+		return err
+	}
+
+	task := &Task{
+		Name:   name,
+		config: config,
+		fun:    fun,
+	}
+	lock.Lock()
+	locked := true
+	defer func() {
+		if locked {
+			lock.Unlock()
+		}
+	}()
+	if _, has := tasksMap[task.Name]; has {
+		log.Error("A task with this name: %s has already been registered", name)
+		return fmt.Errorf("duplicate task with name: %s", task.Name)
+	}
+
+	if config.IsEnabled() {
+		// We cannot use the entry return as there is no way to lock it
+		if _, err = c.AddJob(name, config.GetSchedule(), task); err != nil {
+			log.Error("Unable to register cron task with name: %s Error: %v", name, err)
+			return err
+		}
+	}
+
+	tasks = append(tasks, task)
+	tasksMap[task.Name] = task
+	if started && config.IsEnabled() && config.DoRunAtStart() {
+		lock.Unlock()
+		locked = false
+		task.Run()
+	}
+
+	return nil
+}
+
+// RegisterTaskFatal will register a task but if there is an error log.Fatal
+func RegisterTaskFatal(name string, config Config, fun func(context.Context, *models.User, Config) error) {
+	if err := RegisterTask(name, config, fun); err != nil {
+		log.Fatal("Unable to register cron task %s Error: %v", name, err)
+	}
+}
diff --git a/modules/cron/tasks_basic.go b/modules/cron/tasks_basic.go
new file mode 100644
index 0000000000..438c4a5004
--- /dev/null
+++ b/modules/cron/tasks_basic.go
@@ -0,0 +1,118 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// Use of this source code is governed by a MIT-style
+// license that can be found in the LICENSE file.
+
+package cron
+
+import (
+	"context"
+	"time"
+
+	"code.gitea.io/gitea/models"
+	"code.gitea.io/gitea/modules/migrations"
+	repository_service "code.gitea.io/gitea/modules/repository"
+	mirror_service "code.gitea.io/gitea/services/mirror"
+)
+
+func registerUpdateMirrorTask() {
+	RegisterTaskFatal("update_mirrors", &BaseConfig{
+		Enabled:    true,
+		RunAtStart: false,
+		Schedule:   "@every 10m",
+	}, func(ctx context.Context, _ *models.User, _ Config) error {
+		return mirror_service.Update(ctx)
+	})
+}
+
+func registerRepoHealthCheck() {
+	type RepoHealthCheckConfig struct {
+		BaseConfig
+		Timeout time.Duration
+		Args    []string `delim:" "`
+	}
+	RegisterTaskFatal("repo_health_check", &RepoHealthCheckConfig{
+		BaseConfig: BaseConfig{
+			Enabled:    true,
+			RunAtStart: false,
+			Schedule:   "@every 24h",
+		},
+		Timeout: 60 * time.Second,
+		Args:    []string{},
+	}, func(ctx context.Context, _ *models.User, config Config) error {
+		rhcConfig := config.(*RepoHealthCheckConfig)
+		return repository_service.GitFsck(ctx, rhcConfig.Timeout, rhcConfig.Args)
+	})
+}
+
+func registerCheckRepoStats() {
+	RegisterTaskFatal("check_repo_stats", &BaseConfig{
+		Enabled:    true,
+		RunAtStart: true,
+		Schedule:   "@every 24h",
+	}, func(ctx context.Context, _ *models.User, _ Config) error {
+		return models.CheckRepoStats(ctx)
+	})
+}
+
+func registerArchiveCleanup() {
+	RegisterTaskFatal("archive_cleanup", &OlderThanConfig{
+		BaseConfig: BaseConfig{
+			Enabled:    true,
+			RunAtStart: true,
+			Schedule:   "@every 24h",
+		},
+		OlderThan: 24 * time.Hour,
+	}, func(ctx context.Context, _ *models.User, config Config) error {
+		acConfig := config.(*OlderThanConfig)
+		return models.DeleteOldRepositoryArchives(ctx, acConfig.OlderThan)
+	})
+}
+
+func registerSyncExternalUsers() {
+	RegisterTaskFatal("sync_external_users", &UpdateExistingConfig{
+		BaseConfig: BaseConfig{
+			Enabled:    true,
+			RunAtStart: false,
+			Schedule:   "@every 24h",
+		},
+		UpdateExisting: true,
+	}, func(ctx context.Context, _ *models.User, config Config) error {
+		realConfig := config.(*UpdateExistingConfig)
+		return models.SyncExternalUsers(ctx, realConfig.UpdateExisting)
+	})
+}
+
+func registerDeletedBranchesCleanup() {
+	RegisterTaskFatal("deleted_branches_cleanup", &OlderThanConfig{
+		BaseConfig: BaseConfig{
+			Enabled:    true,
+			RunAtStart: true,
+			Schedule:   "@every 24h",
+		},
+		OlderThan: 24 * time.Hour,
+	}, func(ctx context.Context, _ *models.User, config Config) error {
+		realConfig := config.(*OlderThanConfig)
+		models.RemoveOldDeletedBranches(ctx, realConfig.OlderThan)
+		return nil
+	})
+}
+
+func registerUpdateMigrationPosterID() {
+	RegisterTaskFatal("update_migration_poster_id", &BaseConfig{
+		Enabled:    true,
+		RunAtStart: true,
+		Schedule:   "@every 24h",
+	}, func(ctx context.Context, _ *models.User, _ Config) error {
+		return migrations.UpdateMigrationPosterID(ctx)
+	})
+}
+
+func initBasicTasks() {
+	registerUpdateMirrorTask()
+	registerRepoHealthCheck()
+	registerCheckRepoStats()
+	registerArchiveCleanup()
+	registerSyncExternalUsers()
+	registerDeletedBranchesCleanup()
+	registerUpdateMigrationPosterID()
+}
diff --git a/modules/cron/tasks_extended.go b/modules/cron/tasks_extended.go
new file mode 100644
index 0000000000..fa2d6e0c38
--- /dev/null
+++ b/modules/cron/tasks_extended.go
@@ -0,0 +1,119 @@
+// Copyright 2020 The Gitea Authors. All rights reserved.
+// Use of this source code is governed by a MIT-style
+// license that can be found in the LICENSE file.
+
+package cron
+
+import (
+	"context"
+	"time"
+
+	"code.gitea.io/gitea/models"
+	repo_module "code.gitea.io/gitea/modules/repository"
+	"code.gitea.io/gitea/modules/setting"
+)
+
+func registerDeleteInactiveUsers() {
+	RegisterTaskFatal("delete_inactive_accounts", &OlderThanConfig{
+		BaseConfig: BaseConfig{
+			Enabled:    false,
+			RunAtStart: false,
+			Schedule:   "@annually",
+		},
+		OlderThan: 0 * time.Second,
+	}, func(ctx context.Context, _ *models.User, config Config) error {
+		olderThanConfig := config.(*OlderThanConfig)
+		return models.DeleteInactiveUsers(ctx, olderThanConfig.OlderThan)
+	})
+}
+
+func registerDeleteRepositoryArchives() {
+	RegisterTaskFatal("delete_repo_archives", &BaseConfig{
+		Enabled:    false,
+		RunAtStart: false,
+		Schedule:   "@annually",
+	}, func(ctx context.Context, _ *models.User, _ Config) error {
+		return models.DeleteRepositoryArchives(ctx)
+	})
+}
+
+func registerGarbageCollectRepositories() {
+	type RepoHealthCheckConfig struct {
+		BaseConfig
+		Timeout time.Duration
+		Args    []string `delim:" "`
+	}
+	RegisterTaskFatal("git_gc_repos", &RepoHealthCheckConfig{
+		BaseConfig: BaseConfig{
+			Enabled:    false,
+			RunAtStart: false,
+			Schedule:   "@every 72h",
+		},
+		Timeout: time.Duration(setting.Git.Timeout.GC) * time.Second,
+		Args:    setting.Git.GCArgs,
+	}, func(ctx context.Context, _ *models.User, config Config) error {
+		rhcConfig := config.(*RepoHealthCheckConfig)
+		return repo_module.GitGcRepos(ctx, rhcConfig.Timeout, rhcConfig.Args...)
+	})
+}
+
+func registerRewriteAllPublicKeys() {
+	RegisterTaskFatal("resync_all_sshkeys", &BaseConfig{
+		Enabled:    false,
+		RunAtStart: false,
+		Schedule:   "@every 72h",
+	}, func(_ context.Context, _ *models.User, _ Config) error {
+		return models.RewriteAllPublicKeys()
+	})
+}
+
+func registerRepositoryUpdateHook() {
+	RegisterTaskFatal("resync_all_hooks", &BaseConfig{
+		Enabled:    false,
+		RunAtStart: false,
+		Schedule:   "@every 72h",
+	}, func(ctx context.Context, _ *models.User, _ Config) error {
+		return repo_module.SyncRepositoryHooks(ctx)
+	})
+}
+
+func registerReinitMissingRepositories() {
+	RegisterTaskFatal("reinit_missing_repos", &BaseConfig{
+		Enabled:    false,
+		RunAtStart: false,
+		Schedule:   "@every 72h",
+	}, func(ctx context.Context, _ *models.User, _ Config) error {
+		return repo_module.ReinitMissingRepositories(ctx)
+	})
+}
+
+func registerDeleteMissingRepositories() {
+	RegisterTaskFatal("delete_missing_repos", &BaseConfig{
+		Enabled:    false,
+		RunAtStart: false,
+		Schedule:   "@every 72h",
+	}, func(ctx context.Context, user *models.User, _ Config) error {
+		return repo_module.DeleteMissingRepositories(ctx, user)
+	})
+}
+
+func registerRemoveRandomAvatars() {
+	RegisterTaskFatal("delete_generated_repository_avatars", &BaseConfig{
+		Enabled:    false,
+		RunAtStart: false,
+		Schedule:   "@every 72h",
+	}, func(ctx context.Context, _ *models.User, _ Config) error {
+		return models.RemoveRandomAvatars(ctx)
+	})
+}
+
+func initExtendedTasks() {
+	registerDeleteInactiveUsers()
+	registerDeleteRepositoryArchives()
+	registerGarbageCollectRepositories()
+	registerRewriteAllPublicKeys()
+	registerRepositoryUpdateHook()
+	registerReinitMissingRepositories()
+	registerDeleteMissingRepositories()
+	registerRemoveRandomAvatars()
+}
diff --git a/modules/git/command.go b/modules/git/command.go
index 6c931790c0..14fab4ef3c 100644
--- a/modules/git/command.go
+++ b/modules/git/command.go
@@ -45,22 +45,32 @@ func (c *Command) String() string {
 
 // NewCommand creates and returns a new Git Command based on given command and arguments.
 func NewCommand(args ...string) *Command {
+	return NewCommandContext(DefaultContext, args...)
+}
+
+// NewCommandContext creates and returns a new Git Command based on given command and arguments.
+func NewCommandContext(ctx context.Context, args ...string) *Command {
 	// Make an explicit copy of GlobalCommandArgs, otherwise append might overwrite it
 	cargs := make([]string, len(GlobalCommandArgs))
 	copy(cargs, GlobalCommandArgs)
 	return &Command{
 		name:          GitExecutable,
 		args:          append(cargs, args...),
-		parentContext: DefaultContext,
+		parentContext: ctx,
 	}
 }
 
 // NewCommandNoGlobals creates and returns a new Git Command based on given command and arguments only with the specify args and don't care global command args
 func NewCommandNoGlobals(args ...string) *Command {
+	return NewCommandContextNoGlobals(DefaultContext, args...)
+}
+
+// NewCommandContextNoGlobals creates and returns a new Git Command based on given command and arguments only with the specify args and don't care global command args
+func NewCommandContextNoGlobals(ctx context.Context, args ...string) *Command {
 	return &Command{
 		name:          GitExecutable,
 		args:          args,
-		parentContext: DefaultContext,
+		parentContext: ctx,
 	}
 }
 
diff --git a/modules/git/git.go b/modules/git/git.go
index d5caaa0912..7f718511f7 100644
--- a/modules/git/git.go
+++ b/modules/git/git.go
@@ -150,11 +150,11 @@ func Init(ctx context.Context) error {
 }
 
 // Fsck verifies the connectivity and validity of the objects in the database
-func Fsck(repoPath string, timeout time.Duration, args ...string) error {
+func Fsck(ctx context.Context, repoPath string, timeout time.Duration, args ...string) error {
 	// Make sure timeout makes sense.
 	if timeout <= 0 {
 		timeout = -1
 	}
-	_, err := NewCommand("fsck").AddArguments(args...).RunInDirTimeout(timeout, repoPath)
+	_, err := NewCommandContext(ctx, "fsck").AddArguments(args...).RunInDirTimeout(timeout, repoPath)
 	return err
 }
diff --git a/modules/migrations/update.go b/modules/migrations/update.go
index 3d0962657c..e7a57ceca8 100644
--- a/modules/migrations/update.go
+++ b/modules/migrations/update.go
@@ -13,17 +13,19 @@ import (
 )
 
 // UpdateMigrationPosterID updates all migrated repositories' issues and comments posterID
-func UpdateMigrationPosterID(ctx context.Context) {
+func UpdateMigrationPosterID(ctx context.Context) error {
 	for _, gitService := range structs.SupportedFullGitService {
 		select {
 		case <-ctx.Done():
-			log.Warn("UpdateMigrationPosterID aborted due to shutdown before %s", gitService.Name())
+			log.Warn("UpdateMigrationPosterID aborted before %s", gitService.Name())
+			return models.ErrCancelledf("during UpdateMigrationPosterID before %s", gitService.Name())
 		default:
 		}
 		if err := updateMigrationPosterIDByGitService(ctx, gitService); err != nil {
 			log.Error("updateMigrationPosterIDByGitService failed: %v", err)
 		}
 	}
+	return nil
 }
 
 func updateMigrationPosterIDByGitService(ctx context.Context, tp structs.GitServiceType) error {
@@ -37,7 +39,7 @@ func updateMigrationPosterIDByGitService(ctx context.Context, tp structs.GitServ
 	for {
 		select {
 		case <-ctx.Done():
-			log.Warn("UpdateMigrationPosterIDByGitService(%s) aborted due to shutdown", tp.Name())
+			log.Warn("UpdateMigrationPosterIDByGitService(%s) cancelled", tp.Name())
 			return nil
 		default:
 		}
@@ -54,7 +56,7 @@ func updateMigrationPosterIDByGitService(ctx context.Context, tp structs.GitServ
 		for _, user := range users {
 			select {
 			case <-ctx.Done():
-				log.Warn("UpdateMigrationPosterIDByGitService(%s) aborted due to shutdown", tp.Name())
+				log.Warn("UpdateMigrationPosterIDByGitService(%s) cancelled", tp.Name())
 				return nil
 			default:
 			}
diff --git a/modules/repository/check.go b/modules/repository/check.go
index fcaf76308f..90186d6a29 100644
--- a/modules/repository/check.go
+++ b/modules/repository/check.go
@@ -7,19 +7,19 @@ package repository
 import (
 	"context"
 	"fmt"
+	"strings"
 	"time"
 
 	"code.gitea.io/gitea/models"
 	"code.gitea.io/gitea/modules/git"
 	"code.gitea.io/gitea/modules/log"
-	"code.gitea.io/gitea/modules/setting"
 
 	"github.com/unknwon/com"
 	"xorm.io/builder"
 )
 
 // GitFsck calls 'git fsck' to check repository health.
-func GitFsck(ctx context.Context) error {
+func GitFsck(ctx context.Context, timeout time.Duration, args []string) error {
 	log.Trace("Doing: GitFsck")
 
 	if err := models.Iterate(
@@ -27,24 +27,24 @@ func GitFsck(ctx context.Context) error {
 		new(models.Repository),
 		builder.Expr("id>0 AND is_fsck_enabled=?", true),
 		func(idx int, bean interface{}) error {
+			repo := bean.(*models.Repository)
 			select {
 			case <-ctx.Done():
-				return fmt.Errorf("Aborted due to shutdown")
+				return models.ErrCancelledf("before fsck of %s", repo.FullName())
 			default:
 			}
-			repo := bean.(*models.Repository)
+			log.Trace("Running health check on repository %v", repo)
 			repoPath := repo.RepoPath()
-			log.Trace("Running health check on repository %s", repoPath)
-			if err := git.Fsck(repoPath, setting.Cron.RepoHealthCheck.Timeout, setting.Cron.RepoHealthCheck.Args...); err != nil {
-				desc := fmt.Sprintf("Failed to health check repository (%s): %v", repoPath, err)
-				log.Warn(desc)
-				if err = models.CreateRepositoryNotice(desc); err != nil {
+			if err := git.Fsck(ctx, repoPath, timeout, args...); err != nil {
+				log.Warn("Failed to health check repository (%v): %v", repo, err)
+				if err = models.CreateRepositoryNotice("Failed to health check repository (%s): %v", repo.FullName(), err); err != nil {
 					log.Error("CreateRepositoryNotice: %v", err)
 				}
 			}
 			return nil
 		},
 	); err != nil {
+		log.Trace("Error: GitFsck: %v", err)
 		return err
 	}
 
@@ -53,32 +53,43 @@ func GitFsck(ctx context.Context) error {
 }
 
 // GitGcRepos calls 'git gc' to remove unnecessary files and optimize the local repository
-func GitGcRepos(ctx context.Context) error {
+func GitGcRepos(ctx context.Context, timeout time.Duration, args ...string) error {
 	log.Trace("Doing: GitGcRepos")
-	args := append([]string{"gc"}, setting.Git.GCArgs...)
+	args = append([]string{"gc"}, args...)
 
 	if err := models.Iterate(
 		models.DefaultDBContext(),
 		new(models.Repository),
 		builder.Gt{"id": 0},
 		func(idx int, bean interface{}) error {
+			repo := bean.(*models.Repository)
 			select {
 			case <-ctx.Done():
-				return fmt.Errorf("Aborted due to shutdown")
+				return models.ErrCancelledf("before GC of %s", repo.FullName())
 			default:
 			}
-
-			repo := bean.(*models.Repository)
-			if err := repo.GetOwner(); err != nil {
-				return err
+			log.Trace("Running git gc on %v", repo)
+			command := git.NewCommandContext(ctx, args...).
+				SetDescription(fmt.Sprintf("Repository Garbage Collection: %s", repo.FullName()))
+			var stdout string
+			var err error
+			if timeout > 0 {
+				var stdoutBytes []byte
+				stdoutBytes, err = command.RunInDirTimeout(
+					timeout,
+					repo.RepoPath())
+				stdout = string(stdoutBytes)
+			} else {
+				stdout, err = command.RunInDir(repo.RepoPath())
 			}
-			if stdout, err := git.NewCommand(args...).
-				SetDescription(fmt.Sprintf("Repository Garbage Collection: %s", repo.FullName())).
-				RunInDirTimeout(
-					time.Duration(setting.Git.Timeout.GC)*time.Second,
-					repo.RepoPath()); err != nil {
+
+			if err != nil {
 				log.Error("Repository garbage collection failed for %v. Stdout: %s\nError: %v", repo, stdout, err)
-				return fmt.Errorf("Repository garbage collection failed: Error: %v", err)
+				desc := fmt.Sprintf("Repository garbage collection failed for %s. Stdout: %s\nError: %v", repo.RepoPath(), stdout, err)
+				if err = models.CreateRepositoryNotice(desc); err != nil {
+					log.Error("CreateRepositoryNotice: %v", err)
+				}
+				return fmt.Errorf("Repository garbage collection failed in repo: %s: Error: %v", repo.FullName(), err)
 			}
 			return nil
 		},
@@ -90,7 +101,7 @@ func GitGcRepos(ctx context.Context) error {
 	return nil
 }
 
-func gatherMissingRepoRecords() ([]*models.Repository, error) {
+func gatherMissingRepoRecords(ctx context.Context) ([]*models.Repository, error) {
 	repos := make([]*models.Repository, 0, 10)
 	if err := models.Iterate(
 		models.DefaultDBContext(),
@@ -98,24 +109,33 @@ func gatherMissingRepoRecords() ([]*models.Repository, error) {
 		builder.Gt{"id": 0},
 		func(idx int, bean interface{}) error {
 			repo := bean.(*models.Repository)
+			select {
+			case <-ctx.Done():
+				return models.ErrCancelledf("during gathering missing repo records before checking %s", repo.FullName())
+			default:
+			}
 			if !com.IsDir(repo.RepoPath()) {
 				repos = append(repos, repo)
 			}
 			return nil
 		},
 	); err != nil {
-		if err2 := models.CreateRepositoryNotice(fmt.Sprintf("gatherMissingRepoRecords: %v", err)); err2 != nil {
-			return nil, fmt.Errorf("CreateRepositoryNotice: %v", err)
+		if strings.HasPrefix("Aborted gathering missing repo", err.Error()) {
+			return nil, err
 		}
+		if err2 := models.CreateRepositoryNotice("gatherMissingRepoRecords: %v", err); err2 != nil {
+			log.Error("CreateRepositoryNotice: %v", err2)
+		}
+		return nil, err
 	}
 	return repos, nil
 }
 
 // DeleteMissingRepositories deletes all repository records that lost Git files.
-func DeleteMissingRepositories(doer *models.User) error {
-	repos, err := gatherMissingRepoRecords()
+func DeleteMissingRepositories(ctx context.Context, doer *models.User) error {
+	repos, err := gatherMissingRepoRecords(ctx)
 	if err != nil {
-		return fmt.Errorf("gatherMissingRepoRecords: %v", err)
+		return err
 	}
 
 	if len(repos) == 0 {
@@ -123,10 +143,16 @@ func DeleteMissingRepositories(doer *models.User) error {
 	}
 
 	for _, repo := range repos {
+		select {
+		case <-ctx.Done():
+			return models.ErrCancelledf("during DeleteMissingRepositories before %s", repo.FullName())
+		default:
+		}
 		log.Trace("Deleting %d/%d...", repo.OwnerID, repo.ID)
 		if err := models.DeleteRepository(doer, repo.OwnerID, repo.ID); err != nil {
-			if err2 := models.CreateRepositoryNotice(fmt.Sprintf("DeleteRepository [%d]: %v", repo.ID, err)); err2 != nil {
-				return fmt.Errorf("CreateRepositoryNotice: %v", err)
+			log.Error("Failed to DeleteRepository %s [%d]: Error: %v", repo.FullName(), repo.ID, err)
+			if err2 := models.CreateRepositoryNotice("Failed to DeleteRepository %s [%d]: Error: %v", repo.FullName(), repo.ID, err); err2 != nil {
+				log.Error("CreateRepositoryNotice: %v", err)
 			}
 		}
 	}
@@ -134,10 +160,10 @@ func DeleteMissingRepositories(doer *models.User) error {
 }
 
 // ReinitMissingRepositories reinitializes all repository records that lost Git files.
-func ReinitMissingRepositories() error {
-	repos, err := gatherMissingRepoRecords()
+func ReinitMissingRepositories(ctx context.Context) error {
+	repos, err := gatherMissingRepoRecords(ctx)
 	if err != nil {
-		return fmt.Errorf("gatherMissingRepoRecords: %v", err)
+		return err
 	}
 
 	if len(repos) == 0 {
@@ -145,10 +171,16 @@ func ReinitMissingRepositories() error {
 	}
 
 	for _, repo := range repos {
+		select {
+		case <-ctx.Done():
+			return models.ErrCancelledf("during ReinitMissingRepositories before %s", repo.FullName())
+		default:
+		}
 		log.Trace("Initializing %d/%d...", repo.OwnerID, repo.ID)
 		if err := git.InitRepository(repo.RepoPath(), true); err != nil {
-			if err2 := models.CreateRepositoryNotice(fmt.Sprintf("InitRepository [%d]: %v", repo.ID, err)); err2 != nil {
-				return fmt.Errorf("CreateRepositoryNotice: %v", err)
+			log.Error("Unable (re)initialize repository %d at %s. Error: %v", repo.ID, repo.RepoPath(), err)
+			if err2 := models.CreateRepositoryNotice("InitRepository [%d]: %v", repo.ID, err); err2 != nil {
+				log.Error("CreateRepositoryNotice: %v", err2)
 			}
 		}
 	}
diff --git a/modules/repository/hooks.go b/modules/repository/hooks.go
index 94f570bf3a..6050f21f7f 100644
--- a/modules/repository/hooks.go
+++ b/modules/repository/hooks.go
@@ -160,17 +160,18 @@ func SyncRepositoryHooks(ctx context.Context) error {
 		new(models.Repository),
 		builder.Gt{"id": 0},
 		func(idx int, bean interface{}) error {
+			repo := bean.(*models.Repository)
 			select {
 			case <-ctx.Done():
-				return fmt.Errorf("Aborted due to shutdown")
+				return models.ErrCancelledf("before sync repository hooks for %s", repo.FullName())
 			default:
 			}
 
-			if err := createDelegateHooks(bean.(*models.Repository).RepoPath()); err != nil {
+			if err := createDelegateHooks(repo.RepoPath()); err != nil {
 				return fmt.Errorf("SyncRepositoryHook: %v", err)
 			}
-			if bean.(*models.Repository).HasWiki() {
-				if err := createDelegateHooks(bean.(*models.Repository).WikiPath()); err != nil {
+			if repo.HasWiki() {
+				if err := createDelegateHooks(repo.WikiPath()); err != nil {
 					return fmt.Errorf("SyncRepositoryHook: %v", err)
 				}
 			}
diff --git a/modules/setting/cron.go b/modules/setting/cron.go
index 77f55168aa..c8228ddaa8 100644
--- a/modules/setting/cron.go
+++ b/modules/setting/cron.go
@@ -4,129 +4,8 @@
 
 package setting
 
-import (
-	"time"
-
-	"code.gitea.io/gitea/modules/log"
-)
-
-var (
-
-	// Cron tasks
-	Cron = struct {
-		UpdateMirror struct {
-			Enabled    bool
-			RunAtStart bool
-			Schedule   string
-		} `ini:"cron.update_mirrors"`
-		RepoHealthCheck struct {
-			Enabled    bool
-			RunAtStart bool
-			Schedule   string
-			Timeout    time.Duration
-			Args       []string `delim:" "`
-		} `ini:"cron.repo_health_check"`
-		CheckRepoStats struct {
-			Enabled    bool
-			RunAtStart bool
-			Schedule   string
-		} `ini:"cron.check_repo_stats"`
-		ArchiveCleanup struct {
-			Enabled    bool
-			RunAtStart bool
-			Schedule   string
-			OlderThan  time.Duration
-		} `ini:"cron.archive_cleanup"`
-		SyncExternalUsers struct {
-			Enabled        bool
-			RunAtStart     bool
-			Schedule       string
-			UpdateExisting bool
-		} `ini:"cron.sync_external_users"`
-		DeletedBranchesCleanup struct {
-			Enabled    bool
-			RunAtStart bool
-			Schedule   string
-			OlderThan  time.Duration
-		} `ini:"cron.deleted_branches_cleanup"`
-		UpdateMigrationPosterID struct {
-			Schedule string
-		} `ini:"cron.update_migration_poster_id"`
-	}{
-		UpdateMirror: struct {
-			Enabled    bool
-			RunAtStart bool
-			Schedule   string
-		}{
-			Enabled:    true,
-			RunAtStart: false,
-			Schedule:   "@every 10m",
-		},
-		RepoHealthCheck: struct {
-			Enabled    bool
-			RunAtStart bool
-			Schedule   string
-			Timeout    time.Duration
-			Args       []string `delim:" "`
-		}{
-			Enabled:    true,
-			RunAtStart: false,
-			Schedule:   "@every 24h",
-			Timeout:    60 * time.Second,
-			Args:       []string{},
-		},
-		CheckRepoStats: struct {
-			Enabled    bool
-			RunAtStart bool
-			Schedule   string
-		}{
-			Enabled:    true,
-			RunAtStart: true,
-			Schedule:   "@every 24h",
-		},
-		ArchiveCleanup: struct {
-			Enabled    bool
-			RunAtStart bool
-			Schedule   string
-			OlderThan  time.Duration
-		}{
-			Enabled:    true,
-			RunAtStart: true,
-			Schedule:   "@every 24h",
-			OlderThan:  24 * time.Hour,
-		},
-		SyncExternalUsers: struct {
-			Enabled        bool
-			RunAtStart     bool
-			Schedule       string
-			UpdateExisting bool
-		}{
-			Enabled:        true,
-			RunAtStart:     false,
-			Schedule:       "@every 24h",
-			UpdateExisting: true,
-		},
-		DeletedBranchesCleanup: struct {
-			Enabled    bool
-			RunAtStart bool
-			Schedule   string
-			OlderThan  time.Duration
-		}{
-			Enabled:    true,
-			RunAtStart: true,
-			Schedule:   "@every 24h",
-			OlderThan:  24 * time.Hour,
-		},
-		UpdateMigrationPosterID: struct {
-			Schedule string
-		}{
-			Schedule: "@every 24h",
-		},
-	}
-)
-
-func newCron() {
-	if err := Cfg.Section("cron").MapTo(&Cron); err != nil {
-		log.Fatal("Failed to map Cron settings: %v", err)
-	}
+// GetCronSettings maps the cron subsection to the provided config
+func GetCronSettings(name string, config interface{}) (interface{}, error) {
+	err := Cfg.Section("cron." + name).MapTo(config)
+	return config, err
 }
diff --git a/modules/setting/setting.go b/modules/setting/setting.go
index dd7dbd3fdf..ede4687c81 100644
--- a/modules/setting/setting.go
+++ b/modules/setting/setting.go
@@ -981,7 +981,6 @@ func NewContext() {
 	u.Path = path.Join(u.Path, "api", "swagger")
 	API.SwaggerURL = u.String()
 
-	newCron()
 	newGit()
 
 	sec = Cfg.Section("mirror")
diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini
index 232315122c..975027dcc9 100644
--- a/options/locale/locale_en-US.ini
+++ b/options/locale/locale_en-US.ini
@@ -1842,26 +1842,37 @@ dashboard.operation_switch = Switch
 dashboard.operation_run = Run
 dashboard.clean_unbind_oauth = Clean unbound OAuth connections
 dashboard.clean_unbind_oauth_success = All unbound OAuth connections have been deleted.
-dashboard.delete_inactivate_accounts = Delete all unactivated accounts
-dashboard.delete_inactivate_accounts_success = All unactivated accounts have been deleted.
+dashboard.task.started=Started Task: %s
+dashboard.task.process=Task: %s
+dashboard.task.cancelled=Task: %s cancelled: %[3]s 
+dashboard.task.error=Error in Task: %s: %[3]s
+dashboard.task.finished=Task: %s started by %s has finished
+dashboard.task.unknown=Unknown task: %s
+dashboard.cron.started=Started Cron: %[1]s
+dashboard.cron.process=Cron: %[1]s
+dashboard.cron.cancelled=Cron: %s cancelled: %[3]s 
+dashboard.cron.error=Error in Cron: %s: %[3]s
+dashboard.cron.finished=Cron: %[1]s has finished
+dashboard.delete_inactive_accounts = Delete all unactivated accounts
+dashboard.delete_inactive_accounts.started = Delete all unactivated accounts task started.
 dashboard.delete_repo_archives = Delete all repository archives
-dashboard.delete_repo_archives_success = All repository archives have been deleted.
+dashboard.delete_repo_archives.started = Delete all repository archives task started.
 dashboard.delete_missing_repos = Delete all repositories missing their Git files
-dashboard.delete_missing_repos_success = All repositories missing their Git files have been deleted.
+dashboard.delete_missing_repos.started = Delete all repositories missing their Git files task started.
 dashboard.delete_generated_repository_avatars = Delete generated repository avatars
-dashboard.delete_generated_repository_avatars_success = Generated repository avatars were deleted.
+dashboard.update_mirrors = Update Mirrors
+dashboard.repo_health_check = Health check all repositories
+dashboard.check_repo_stats = Check all repository statistics
+dashboard.archive_cleanup = Delete old repository archives
+dashboard.deleted_branches_cleanup = Clean-up deleted branches
+dashboard.update_migration_poster_id = Update migration poster IDs
 dashboard.git_gc_repos = Garbage collect all repositories
-dashboard.git_gc_repos_success = All repositories have finished garbage collection.
-dashboard.resync_all_sshkeys = Update the '.ssh/authorized_keys' file with Gitea SSH keys. (Not needed for the built-in SSH server.)
-dashboard.resync_all_sshkeys_success = The public SSH keys controlled by Gitea have been updated.
+dashboard.resync_all_sshkeys = Update the '.ssh/authorized_keys' file with Gitea SSH keys.
+dashboard.resync_all_sshkeys.desc = (Not needed for the built-in SSH server.)
 dashboard.resync_all_hooks = Resynchronize pre-receive, update and post-receive hooks of all repositories.
-dashboard.resync_all_hooks_success = All pre-receive, update and post-receive repository hooks have been resynchronized.
 dashboard.reinit_missing_repos = Reinitialize all missing Git repositories for which records exist
-dashboard.reinit_missing_repos_success = All missing Git repositories for which records existed have been reinitialized.
 dashboard.sync_external_users = Synchronize external user data
-dashboard.sync_external_users_started = External user data synchronization has started.
 dashboard.git_fsck = Execute health checks on all repositories
-dashboard.git_fsck_started = Repository health checks have started.
 dashboard.server_uptime = Server Uptime
 dashboard.current_goroutine = Current Goroutines
 dashboard.current_memory_usage = Current Memory Usage
@@ -2266,6 +2277,7 @@ notices.delete_selected = Delete Selected
 notices.delete_all = Delete All Notices
 notices.type = Type
 notices.type_1 = Repository
+notices.type_2 = Task
 notices.desc = Description
 notices.op = Op.
 notices.delete_success = The system notices have been deleted.
diff --git a/routers/admin/admin.go b/routers/admin/admin.go
index 1b4a8631cd..7595f4929d 100644
--- a/routers/admin/admin.go
+++ b/routers/admin/admin.go
@@ -21,11 +21,9 @@ import (
 	"code.gitea.io/gitea/modules/context"
 	"code.gitea.io/gitea/modules/cron"
 	"code.gitea.io/gitea/modules/git"
-	"code.gitea.io/gitea/modules/graceful"
 	"code.gitea.io/gitea/modules/log"
 	"code.gitea.io/gitea/modules/process"
 	"code.gitea.io/gitea/modules/queue"
-	repo_module "code.gitea.io/gitea/modules/repository"
 	"code.gitea.io/gitea/modules/setting"
 	"code.gitea.io/gitea/modules/timeutil"
 	"code.gitea.io/gitea/services/mailer"
@@ -124,22 +122,6 @@ func updateSystemStatus() {
 	sysStatus.NumGC = m.NumGC
 }
 
-// Operation Operation types.
-type Operation int
-
-const (
-	cleanInactivateUser Operation = iota + 1
-	cleanRepoArchives
-	cleanMissingRepos
-	gitGCRepos
-	syncSSHAuthorizedKey
-	syncRepositoryUpdateHook
-	reinitMissingRepository
-	syncExternalUsers
-	gitFsck
-	deleteGeneratedRepositoryAvatars
-)
-
 // Dashboard show admin panel dashboard
 func Dashboard(ctx *context.Context) {
 	ctx.Data["Title"] = ctx.Tr("admin.dashboard")
@@ -162,48 +144,13 @@ func DashboardPost(ctx *context.Context, form auth.AdminDashboardForm) {
 	ctx.Data["SysStatus"] = sysStatus
 
 	// Run operation.
-	if form.Op > 0 {
-		var err error
-		var success string
-		shutdownCtx := graceful.GetManager().ShutdownContext()
-
-		switch Operation(form.Op) {
-		case cleanInactivateUser:
-			success = ctx.Tr("admin.dashboard.delete_inactivate_accounts_success")
-			err = models.DeleteInactivateUsers()
-		case cleanRepoArchives:
-			success = ctx.Tr("admin.dashboard.delete_repo_archives_success")
-			err = models.DeleteRepositoryArchives()
-		case cleanMissingRepos:
-			success = ctx.Tr("admin.dashboard.delete_missing_repos_success")
-			err = repo_module.DeleteMissingRepositories(ctx.User)
-		case gitGCRepos:
-			success = ctx.Tr("admin.dashboard.git_gc_repos_success")
-			err = repo_module.GitGcRepos(shutdownCtx)
-		case syncSSHAuthorizedKey:
-			success = ctx.Tr("admin.dashboard.resync_all_sshkeys_success")
-			err = models.RewriteAllPublicKeys()
-		case syncRepositoryUpdateHook:
-			success = ctx.Tr("admin.dashboard.resync_all_hooks_success")
-			err = repo_module.SyncRepositoryHooks(shutdownCtx)
-		case reinitMissingRepository:
-			success = ctx.Tr("admin.dashboard.reinit_missing_repos_success")
-			err = repo_module.ReinitMissingRepositories()
-		case syncExternalUsers:
-			success = ctx.Tr("admin.dashboard.sync_external_users_started")
-			go graceful.GetManager().RunWithShutdownContext(models.SyncExternalUsers)
-		case gitFsck:
-			success = ctx.Tr("admin.dashboard.git_fsck_started")
-			err = repo_module.GitFsck(shutdownCtx)
-		case deleteGeneratedRepositoryAvatars:
-			success = ctx.Tr("admin.dashboard.delete_generated_repository_avatars_success")
-			err = models.RemoveRandomAvatars()
-		}
-
-		if err != nil {
-			ctx.Flash.Error(err.Error())
+	if form.Op != "" {
+		task := cron.GetTask(form.Op)
+		if task != nil {
+			go task.RunWithUser(ctx.User, nil)
+			ctx.Flash.Success(ctx.Tr("admin.dashboard.task.started", ctx.Tr("admin.dashboard."+form.Op)))
 		} else {
-			ctx.Flash.Success(success)
+			ctx.Flash.Error(ctx.Tr("admin.dashboard.task.unknown", form.Op))
 		}
 	}
 
diff --git a/routers/init.go b/routers/init.go
index 72f55c809c..c891ec3149 100644
--- a/routers/init.go
+++ b/routers/init.go
@@ -25,6 +25,7 @@ import (
 	"code.gitea.io/gitea/modules/markup"
 	"code.gitea.io/gitea/modules/markup/external"
 	"code.gitea.io/gitea/modules/notification"
+	"code.gitea.io/gitea/modules/options"
 	"code.gitea.io/gitea/modules/setting"
 	"code.gitea.io/gitea/modules/ssh"
 	"code.gitea.io/gitea/modules/task"
@@ -33,7 +34,9 @@ import (
 	mirror_service "code.gitea.io/gitea/services/mirror"
 	pull_service "code.gitea.io/gitea/services/pull"
 
+	"gitea.com/macaron/i18n"
 	"gitea.com/macaron/macaron"
+	unknwoni18n "github.com/unknwon/i18n"
 )
 
 func checkRunMode() {
@@ -79,6 +82,33 @@ func initDBEngine(ctx context.Context) (err error) {
 	return nil
 }
 
+// InitLocales loads the locales
+func InitLocales() {
+	localeNames, err := options.Dir("locale")
+
+	if err != nil {
+		log.Fatal("Failed to list locale files: %v", err)
+	}
+	localFiles := make(map[string][]byte)
+
+	for _, name := range localeNames {
+		localFiles[name], err = options.Locale(name)
+
+		if err != nil {
+			log.Fatal("Failed to load %s locale file. %v", name, err)
+		}
+	}
+	i18n.I18n(i18n.Options{
+		SubURL:       setting.AppSubURL,
+		Files:        localFiles,
+		Langs:        setting.Langs,
+		Names:        setting.Names,
+		DefaultLang:  "en-US",
+		Redirect:     false,
+		CookieDomain: setting.SessionConfig.Domain,
+	})
+}
+
 // GlobalInit is for global configuration reload-able.
 func GlobalInit(ctx context.Context) {
 	setting.NewContext()
@@ -91,6 +121,11 @@ func GlobalInit(ctx context.Context) {
 	log.Trace("Custom path: %s", setting.CustomPath)
 	log.Trace("Log path: %s", setting.LogRootPath)
 
+	// Setup i18n
+	InitLocales()
+
+	log.Info("%s", unknwoni18n.Tr("en-US", "admin.dashboard.delete_repo_archives"))
+
 	NewServices()
 
 	if setting.InstallLock {
diff --git a/services/mirror/mirror.go b/services/mirror/mirror.go
index 82d8fc59b2..165e7cd35b 100644
--- a/services/mirror/mirror.go
+++ b/services/mirror/mirror.go
@@ -296,7 +296,7 @@ func Password(m *models.Mirror) string {
 }
 
 // Update checks and updates mirror repositories.
-func Update(ctx context.Context) {
+func Update(ctx context.Context) error {
 	log.Trace("Doing: Update")
 	if err := models.MirrorsIterate(func(idx int, bean interface{}) error {
 		m := bean.(*models.Mirror)
@@ -306,14 +306,17 @@ func Update(ctx context.Context) {
 		}
 		select {
 		case <-ctx.Done():
-			return fmt.Errorf("Aborted due to shutdown")
+			return fmt.Errorf("Aborted")
 		default:
 			mirrorQueue.Add(m.RepoID)
 			return nil
 		}
 	}); err != nil {
-		log.Error("Update: %v", err)
+		log.Trace("Update: %v", err)
+		return err
 	}
+	log.Trace("Finished: Update")
+	return nil
 }
 
 // SyncMirrors checks and syncs mirrors.
diff --git a/templates/admin/dashboard.tmpl b/templates/admin/dashboard.tmpl
index 151b489710..affed1634f 100644
--- a/templates/admin/dashboard.tmpl
+++ b/templates/admin/dashboard.tmpl
@@ -15,49 +15,50 @@
 			{{.i18n.Tr "admin.dashboard.operations"}}
 		</h4>
 		<div class="ui attached table segment">
-			<form method="post" action="{{AppSubUrl}}/admin">
+			<form method="post" action="{{.AppSubUrl}}/admin">
 				{{.CsrfTokenHtml}}
 				<table class="ui very basic table">
 					<tbody>
 						<tr>
-							<td>{{.i18n.Tr "admin.dashboard.delete_inactivate_accounts"}}</td>
-							<td><button type="submit" class="ui green button" name="op" value="1">{{svg "octicon-triangle-right" 16}} {{.i18n.Tr "admin.dashboard.operation_run"}}</button></td>
+							<td>{{.i18n.Tr "admin.dashboard.delete_inactive_accounts"}}</td>
+							<td><button type="submit" class="ui green button" name="op" value="delete_inactive_accounts">{{svg "octicon-triangle-right" 16}} {{.i18n.Tr "admin.dashboard.operation_run"}}</button></td>
 						</tr>
 						<tr>
 							<td>{{.i18n.Tr "admin.dashboard.delete_repo_archives"}}</td>
-							<td><button type="submit" class="ui green button" name="op" value="2">{{svg "octicon-triangle-right" 16}} {{.i18n.Tr "admin.dashboard.operation_run"}}</button></td>
+							<td><button type="submit" class="ui green button" name="op" value="delete_repo_archives">{{svg "octicon-triangle-right" 16}} {{.i18n.Tr "admin.dashboard.operation_run"}}</button></td>
 						</tr>
 						<tr>
 							<td>{{.i18n.Tr "admin.dashboard.delete_missing_repos"}}</td>
-							<td><button type="submit" class="ui green button" name="op" value="3">{{svg "octicon-triangle-right" 16}} {{.i18n.Tr "admin.dashboard.operation_run"}}</button></td>
+							<td><button type="submit" class="ui green button" name="op" value="delete_missing_repos">{{svg "octicon-triangle-right" 16}} {{.i18n.Tr "admin.dashboard.operation_run"}}</button></td>
 						</tr>
 						<tr>
 							<td>{{.i18n.Tr "admin.dashboard.git_gc_repos"}}</td>
-							<td><button type="submit" class="ui green button" name="op" value="4">{{svg "octicon-triangle-right" 16}} {{.i18n.Tr "admin.dashboard.operation_run"}}</button></td>
+							<td><button type="submit" class="ui green button" name="op" value="git_gc_repos">{{svg "octicon-triangle-right" 16}} {{.i18n.Tr "admin.dashboard.operation_run"}}</button></td>
 						</tr>
 						<tr>
-							<td>{{.i18n.Tr "admin.dashboard.resync_all_sshkeys"}}</td>
-							<td><button type="submit" class="ui green button" name="op" value="5">{{svg "octicon-triangle-right" 16}} {{.i18n.Tr "admin.dashboard.operation_run"}}</button></td>
+							<td>{{.i18n.Tr "admin.dashboard.resync_all_sshkeys"}}<br/>
+							{{.i18n.Tr "admin.dashboard.resync_all_sshkeys.desc"}}</td>
+							<td><button type="submit" class="ui green button" name="op" value="resync_all_sshkeys">{{svg "octicon-triangle-right" 16}} {{.i18n.Tr "admin.dashboard.operation_run"}}</button></td>
 						</tr>
 						<tr>
 							<td>{{.i18n.Tr "admin.dashboard.resync_all_hooks"}}</td>
-							<td><button type="submit" class="ui green button" name="op" value="6">{{svg "octicon-triangle-right" 16}} {{.i18n.Tr "admin.dashboard.operation_run"}}</button></td>
+							<td><button type="submit" class="ui green button" name="op" value="resync_all_hooks">{{svg "octicon-triangle-right" 16}} {{.i18n.Tr "admin.dashboard.operation_run"}}</button></td>
 						</tr>
 						<tr>
 							<td>{{.i18n.Tr "admin.dashboard.reinit_missing_repos"}}</td>
-							<td><button type="submit" class="ui green button" name="op" value="7">{{svg "octicon-triangle-right" 16}} {{.i18n.Tr "admin.dashboard.operation_run"}}</button></td>
+							<td><button type="submit" class="ui green button" name="op" value="reinit_missing_repos">{{svg "octicon-triangle-right" 16}} {{.i18n.Tr "admin.dashboard.operation_run"}}</button></td>
 						</tr>
 						<tr>
 							<td>{{.i18n.Tr "admin.dashboard.sync_external_users"}}</td>
-							<td><button type="submit" class="ui green button" name="op" value="8">{{svg "octicon-triangle-right" 16}} {{.i18n.Tr "admin.dashboard.operation_run"}}</button></td>
+							<td><button type="submit" class="ui green button" name="op" value="sync_external_users">{{svg "octicon-triangle-right" 16}} {{.i18n.Tr "admin.dashboard.operation_run"}}</button></td>
 						</tr>
 						<tr>
 							<td>{{.i18n.Tr "admin.dashboard.git_fsck"}}</td>
-							<td><button type="submit" class="ui green button" name="op" value="9">{{svg "octicon-triangle-right" 16}} {{.i18n.Tr "admin.dashboard.operation_run"}}</button></td>
+							<td><button type="submit" class="ui green button" name="op" value="git_fsck">{{svg "octicon-triangle-right" 16}} {{.i18n.Tr "admin.dashboard.operation_run"}}</button></td>
 						</tr>
 						<tr>
 							<td>{{.i18n.Tr "admin.dashboard.delete_generated_repository_avatars"}}</td>
-							<td><button type="submit" class="ui green button" name="op" value="10">{{svg "octicon-triangle-right" 16}} {{.i18n.Tr "admin.dashboard.operation_run"}}</button></td>
+							<td><button type="submit" class="ui green button" name="op" value="delete_generated_repository_avatars">{{svg "octicon-triangle-right" 16}} {{.i18n.Tr "admin.dashboard.operation_run"}}</button></td>
 						</tr>
 					</tbody>
 				</table>
diff --git a/templates/admin/monitor.tmpl b/templates/admin/monitor.tmpl
index 0f9c2150b6..26fbcb60ac 100644
--- a/templates/admin/monitor.tmpl
+++ b/templates/admin/monitor.tmpl
@@ -7,28 +7,33 @@
 			{{.i18n.Tr "admin.monitor.cron"}}
 		</h4>
 		<div class="ui attached table segment">
-			<table class="ui very basic striped table">
-				<thead>
-					<tr>
-						<th>{{.i18n.Tr "admin.monitor.name"}}</th>
-						<th>{{.i18n.Tr "admin.monitor.schedule"}}</th>
-						<th>{{.i18n.Tr "admin.monitor.next"}}</th>
-						<th>{{.i18n.Tr "admin.monitor.previous"}}</th>
-						<th>{{.i18n.Tr "admin.monitor.execute_times"}}</th>
-					</tr>
-				</thead>
-				<tbody>
-					{{range .Entries}}
+			<form method="post" action="{{.AppSubUrl}}/admin">
+				{{.CsrfTokenHtml}}
+				<table class="ui very basic striped table">
+					<thead>
 						<tr>
-							<td>{{.Description}}</td>
-							<td>{{.Spec}}</td>
-							<td>{{DateFmtLong .Next}}</td>
-							<td>{{if gt .Prev.Year 1 }}{{DateFmtLong .Prev}}{{else}}N/A{{end}}</td>
-							<td>{{.ExecTimes}}</td>
+							<th></th>
+							<th>{{.i18n.Tr "admin.monitor.name"}}</th>
+							<th>{{.i18n.Tr "admin.monitor.schedule"}}</th>
+							<th>{{.i18n.Tr "admin.monitor.next"}}</th>
+							<th>{{.i18n.Tr "admin.monitor.previous"}}</th>
+							<th>{{.i18n.Tr "admin.monitor.execute_times"}}</th>
 						</tr>
-					{{end}}
-				</tbody>
-			</table>
+					</thead>
+					<tbody>
+						{{range .Entries}}
+							<tr>
+								<td><button type="submit" class="ui green button" name="op" value="{{.Name}}" title="{{$.i18n.Tr "admin.dashboard.operation_run"}}">{{svg "octicon-triangle-right" 16}}</button></td>
+								<td>{{$.i18n.Tr (printf "admin.dashboard.%s" .Name)}}</td>
+								<td>{{.Spec}}</td>
+								<td>{{DateFmtLong .Next}}</td>
+								<td>{{if gt .Prev.Year 1 }}{{DateFmtLong .Prev}}{{else}}N/A{{end}}</td>
+								<td>{{.ExecTimes}}</td>
+							</tr>
+						{{end}}
+					</tbody>
+				</table>
+			</form>
 		</div>
 
 		<h4 class="ui top attached header">