diff --git a/custom/conf/app.example.ini b/custom/conf/app.example.ini index dd4d5a4cbb3fe..b994fb940adea 100644 --- a/custom/conf/app.example.ini +++ b/custom/conf/app.example.ini @@ -1160,6 +1160,9 @@ ROUTER = console ;; ;; Number of organizations that are displayed on one page ;ORG_PAGING_NUM = 50 +;; +;; Cache summary statistics for this period of time. +;STATISTICS_TTL = 5m ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -2217,6 +2220,9 @@ ROUTER = console ;ENABLED_ISSUE_BY_LABEL = false ;; Enable issue by repository metrics; default is false ;ENABLED_ISSUE_BY_REPOSITORY = false +;; +;; Cache statistics for this period of time. +;STATISTICS_TTL = 5m ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; diff --git a/docs/content/doc/advanced/config-cheat-sheet.en-us.md b/docs/content/doc/advanced/config-cheat-sheet.en-us.md index 2cd4795e680d3..747d602435b85 100644 --- a/docs/content/doc/advanced/config-cheat-sheet.en-us.md +++ b/docs/content/doc/advanced/config-cheat-sheet.en-us.md @@ -201,6 +201,7 @@ The following configuration set `Content-Type: application/vnd.android.package-a - `REPO_PAGING_NUM`: **50**: Number of repos that are shown in one page. - `NOTICE_PAGING_NUM`: **25**: Number of notices that are shown in one page. - `ORG_PAGING_NUM`: **50**: Number of organizations that are shown in one page. +- `STATISTICS_TTL`: **5m**: Cache summary statistics for this period of time. ### UI - Metadata (`ui.meta`) @@ -997,6 +998,7 @@ Default templates for project boards: - `ENABLED_ISSUE_BY_LABEL`: **false**: Enable issue by label metrics with format `gitea_issues_by_label{label="bug"} 2`. - `ENABLED_ISSUE_BY_REPOSITORY`: **false**: Enable issue by repository metrics with format `gitea_issues_by_repository{repository="org/repo"} 5`. - `TOKEN`: **\**: You need to specify the token, if you want to include in the authorization the metrics . The same token need to be used in prometheus parameters `bearer_token` or `bearer_token_file`. +- `STATISTICS_TTL`: **5m**: Cache summary statistics for this period of time. ## API (`api`) diff --git a/models/statistic.go b/models/statistic.go index ec094b5f5b7bc..ea718bb50a7ba 100644 --- a/models/statistic.go +++ b/models/statistic.go @@ -5,6 +5,9 @@ package models import ( + "context" + "time" + asymkey_model "code.gitea.io/gitea/models/asymkey" "code.gitea.io/gitea/models/auth" "code.gitea.io/gitea/models/db" @@ -32,6 +35,7 @@ type Statistic struct { IssueByLabel []IssueByLabelCount IssueByRepository []IssueByRepositoryCount } + Time time.Time } // IssueByLabelCount contains the number of issue group by label @@ -48,23 +52,24 @@ type IssueByRepositoryCount struct { } // GetStatistic returns the database statistics -func GetStatistic() (stats Statistic) { - e := db.GetEngine(db.DefaultContext) +func GetStatistic(ctx context.Context, metrics bool) (stats Statistic) { + e := db.GetEngine(ctx) + stats.Counter.User = user_model.CountUsers(nil) stats.Counter.Org, _ = organization.CountOrgs(organization.FindOrgOptions{IncludePrivate: true}) stats.Counter.PublicKey, _ = e.Count(new(asymkey_model.PublicKey)) stats.Counter.Repo, _ = repo_model.CountRepositories(db.DefaultContext, repo_model.CountRepositoryOptions{}) stats.Counter.Watch, _ = e.Count(new(repo_model.Watch)) stats.Counter.Star, _ = e.Count(new(repo_model.Star)) - stats.Counter.Action, _ = db.EstimateCount(db.DefaultContext, new(Action)) - stats.Counter.Access, _ = e.Count(new(access_model.Access)) + stats.Counter.Action, _ = db.EstimateCount(ctx, new(Action)) + stats.Counter.Access, _ = db.EstimateCount(ctx, new(access_model.Access)) type IssueCount struct { Count int64 IsClosed bool } - if setting.Metrics.EnabledIssueByLabel { + if metrics && setting.Metrics.EnabledIssueByLabel { stats.Counter.IssueByLabel = []IssueByLabelCount{} _ = e.Select("COUNT(*) AS count, l.name AS label"). @@ -74,7 +79,7 @@ func GetStatistic() (stats Statistic) { Find(&stats.Counter.IssueByLabel) } - if setting.Metrics.EnabledIssueByRepository { + if metrics && setting.Metrics.EnabledIssueByRepository { stats.Counter.IssueByRepository = []IssueByRepositoryCount{} _ = e.Select("COUNT(*) AS count, r.owner_name, r.name AS repository"). @@ -97,19 +102,20 @@ func GetStatistic() (stats Statistic) { stats.Counter.Issue = stats.Counter.IssueClosed + stats.Counter.IssueOpen - stats.Counter.Comment, _ = e.Count(new(issues_model.Comment)) - stats.Counter.Oauth = 0 + stats.Counter.Comment, _ = db.EstimateCount(ctx, new(issues_model.Comment)) stats.Counter.Follow, _ = e.Count(new(user_model.Follow)) stats.Counter.Mirror, _ = e.Count(new(repo_model.Mirror)) stats.Counter.Release, _ = e.Count(new(Release)) - stats.Counter.AuthSource = auth.CountSources() stats.Counter.Webhook, _ = e.Count(new(webhook.Webhook)) stats.Counter.Milestone, _ = e.Count(new(issues_model.Milestone)) stats.Counter.Label, _ = e.Count(new(issues_model.Label)) - stats.Counter.HookTask, _ = e.Count(new(webhook.HookTask)) + stats.Counter.HookTask, _ = db.EstimateCount(ctx, new(webhook.HookTask)) stats.Counter.Team, _ = e.Count(new(organization.Team)) - stats.Counter.Attachment, _ = e.Count(new(repo_model.Attachment)) + stats.Counter.Attachment, _ = db.EstimateCount(ctx, new(repo_model.Attachment)) stats.Counter.Project, _ = e.Count(new(project_model.Project)) stats.Counter.ProjectBoard, _ = e.Count(new(project_model.Board)) + stats.Counter.Oauth = 0 + stats.Counter.AuthSource = auth.CountSources() + stats.Time = time.Now() return stats } diff --git a/modules/metrics/collector.go b/modules/metrics/collector.go index 069633a565a20..b5b249f59a63d 100755 --- a/modules/metrics/collector.go +++ b/modules/metrics/collector.go @@ -6,6 +6,7 @@ package metrics import ( "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/setting" "github.com/prometheus/client_golang/prometheus" ) @@ -43,6 +44,8 @@ type Collector struct { Users *prometheus.Desc Watches *prometheus.Desc Webhooks *prometheus.Desc + + StatisticsTime *prometheus.Desc } // NewCollector returns a new Collector with all prometheus.Desc initialized @@ -225,152 +228,156 @@ func (c Collector) Describe(ch chan<- *prometheus.Desc) { // Collect returns the metrics with values func (c Collector) Collect(ch chan<- prometheus.Metric) { - stats := models.GetStatistic() + stats := <-GetStatistic(setting.Metrics.StatisticTTL, true) + if stats == nil { + // This will happen if the statistics generation was cancelled midway through + stats = &models.Statistic{} + } - ch <- prometheus.MustNewConstMetric( + ch <- prometheus.NewMetricWithTimestamp(stats.Time, prometheus.MustNewConstMetric( c.Accesses, prometheus.GaugeValue, float64(stats.Counter.Access), - ) - ch <- prometheus.MustNewConstMetric( + )) + ch <- prometheus.NewMetricWithTimestamp(stats.Time, prometheus.MustNewConstMetric( c.Actions, prometheus.GaugeValue, float64(stats.Counter.Action), - ) - ch <- prometheus.MustNewConstMetric( + )) + ch <- prometheus.NewMetricWithTimestamp(stats.Time, prometheus.MustNewConstMetric( c.Attachments, prometheus.GaugeValue, float64(stats.Counter.Attachment), - ) - ch <- prometheus.MustNewConstMetric( + )) + ch <- prometheus.NewMetricWithTimestamp(stats.Time, prometheus.MustNewConstMetric( c.Comments, prometheus.GaugeValue, float64(stats.Counter.Comment), - ) - ch <- prometheus.MustNewConstMetric( + )) + ch <- prometheus.NewMetricWithTimestamp(stats.Time, prometheus.MustNewConstMetric( c.Follows, prometheus.GaugeValue, float64(stats.Counter.Follow), - ) - ch <- prometheus.MustNewConstMetric( + )) + ch <- prometheus.NewMetricWithTimestamp(stats.Time, prometheus.MustNewConstMetric( c.HookTasks, prometheus.GaugeValue, float64(stats.Counter.HookTask), - ) - ch <- prometheus.MustNewConstMetric( + )) + ch <- prometheus.NewMetricWithTimestamp(stats.Time, prometheus.MustNewConstMetric( c.Issues, prometheus.GaugeValue, float64(stats.Counter.Issue), - ) + )) for _, il := range stats.Counter.IssueByLabel { - ch <- prometheus.MustNewConstMetric( + ch <- prometheus.NewMetricWithTimestamp(stats.Time, prometheus.MustNewConstMetric( c.IssuesByLabel, prometheus.GaugeValue, float64(il.Count), il.Label, - ) + )) } for _, ir := range stats.Counter.IssueByRepository { - ch <- prometheus.MustNewConstMetric( + ch <- prometheus.NewMetricWithTimestamp(stats.Time, prometheus.MustNewConstMetric( c.IssuesByRepository, prometheus.GaugeValue, float64(ir.Count), ir.OwnerName+"/"+ir.Repository, - ) + )) } - ch <- prometheus.MustNewConstMetric( + ch <- prometheus.NewMetricWithTimestamp(stats.Time, prometheus.MustNewConstMetric( c.IssuesClosed, prometheus.GaugeValue, float64(stats.Counter.IssueClosed), - ) - ch <- prometheus.MustNewConstMetric( + )) + ch <- prometheus.NewMetricWithTimestamp(stats.Time, prometheus.MustNewConstMetric( c.IssuesOpen, prometheus.GaugeValue, float64(stats.Counter.IssueOpen), - ) - ch <- prometheus.MustNewConstMetric( + )) + ch <- prometheus.NewMetricWithTimestamp(stats.Time, prometheus.MustNewConstMetric( c.Labels, prometheus.GaugeValue, float64(stats.Counter.Label), - ) - ch <- prometheus.MustNewConstMetric( + )) + ch <- prometheus.NewMetricWithTimestamp(stats.Time, prometheus.MustNewConstMetric( c.LoginSources, prometheus.GaugeValue, float64(stats.Counter.AuthSource), - ) - ch <- prometheus.MustNewConstMetric( + )) + ch <- prometheus.NewMetricWithTimestamp(stats.Time, prometheus.MustNewConstMetric( c.Milestones, prometheus.GaugeValue, float64(stats.Counter.Milestone), - ) - ch <- prometheus.MustNewConstMetric( + )) + ch <- prometheus.NewMetricWithTimestamp(stats.Time, prometheus.MustNewConstMetric( c.Mirrors, prometheus.GaugeValue, float64(stats.Counter.Mirror), - ) - ch <- prometheus.MustNewConstMetric( + )) + ch <- prometheus.NewMetricWithTimestamp(stats.Time, prometheus.MustNewConstMetric( c.Oauths, prometheus.GaugeValue, float64(stats.Counter.Oauth), - ) - ch <- prometheus.MustNewConstMetric( + )) + ch <- prometheus.NewMetricWithTimestamp(stats.Time, prometheus.MustNewConstMetric( c.Organizations, prometheus.GaugeValue, float64(stats.Counter.Org), - ) - ch <- prometheus.MustNewConstMetric( + )) + ch <- prometheus.NewMetricWithTimestamp(stats.Time, prometheus.MustNewConstMetric( c.Projects, prometheus.GaugeValue, float64(stats.Counter.Project), - ) - ch <- prometheus.MustNewConstMetric( + )) + ch <- prometheus.NewMetricWithTimestamp(stats.Time, prometheus.MustNewConstMetric( c.ProjectBoards, prometheus.GaugeValue, float64(stats.Counter.ProjectBoard), - ) - ch <- prometheus.MustNewConstMetric( + )) + ch <- prometheus.NewMetricWithTimestamp(stats.Time, prometheus.MustNewConstMetric( c.PublicKeys, prometheus.GaugeValue, float64(stats.Counter.PublicKey), - ) - ch <- prometheus.MustNewConstMetric( + )) + ch <- prometheus.NewMetricWithTimestamp(stats.Time, prometheus.MustNewConstMetric( c.Releases, prometheus.GaugeValue, float64(stats.Counter.Release), - ) - ch <- prometheus.MustNewConstMetric( + )) + ch <- prometheus.NewMetricWithTimestamp(stats.Time, prometheus.MustNewConstMetric( c.Repositories, prometheus.GaugeValue, float64(stats.Counter.Repo), - ) - ch <- prometheus.MustNewConstMetric( + )) + ch <- prometheus.NewMetricWithTimestamp(stats.Time, prometheus.MustNewConstMetric( c.Stars, prometheus.GaugeValue, float64(stats.Counter.Star), - ) - ch <- prometheus.MustNewConstMetric( + )) + ch <- prometheus.NewMetricWithTimestamp(stats.Time, prometheus.MustNewConstMetric( c.Teams, prometheus.GaugeValue, float64(stats.Counter.Team), - ) - ch <- prometheus.MustNewConstMetric( + )) + ch <- prometheus.NewMetricWithTimestamp(stats.Time, prometheus.MustNewConstMetric( c.UpdateTasks, prometheus.GaugeValue, float64(stats.Counter.UpdateTask), - ) - ch <- prometheus.MustNewConstMetric( + )) + ch <- prometheus.NewMetricWithTimestamp(stats.Time, prometheus.MustNewConstMetric( c.Users, prometheus.GaugeValue, float64(stats.Counter.User), - ) - ch <- prometheus.MustNewConstMetric( + )) + ch <- prometheus.NewMetricWithTimestamp(stats.Time, prometheus.MustNewConstMetric( c.Watches, prometheus.GaugeValue, float64(stats.Counter.Watch), - ) - ch <- prometheus.MustNewConstMetric( + )) + ch <- prometheus.NewMetricWithTimestamp(stats.Time, prometheus.MustNewConstMetric( c.Webhooks, prometheus.GaugeValue, float64(stats.Counter.Webhook), - ) + )) } diff --git a/modules/metrics/statistics.go b/modules/metrics/statistics.go new file mode 100644 index 0000000000000..94a381cba4350 --- /dev/null +++ b/modules/metrics/statistics.go @@ -0,0 +1,119 @@ +// Copyright 2022 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 metrics + +import ( + "fmt" + "sync" + "time" + + "code.gitea.io/gitea/models" + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/modules/process" +) + +var ( + statisticsLock sync.Mutex + + shortStatistic *models.Statistic + fullStatistic *models.Statistic + + shortStatisticWorkingChan chan struct{} + fullStatisticWorkingChan chan struct{} +) + +func GetStatistic(statisticsTTL time.Duration, full bool) <-chan *models.Statistic { + statisticsLock.Lock() // CAREFUL: no defer! + ourChan := make(chan *models.Statistic, 1) + + // Check for a cached statistic + if statisticsTTL > 0 { + stats := fullStatistic + if !full && shortStatistic != nil { + stats = shortStatistic + } + if stats != nil && stats.Time.Add(statisticsTTL).After(time.Now()) { + // Found a valid cached statistic for these params, so unlock and send this down the channel + statisticsLock.Unlock() // Unlock from above + + ourChan <- stats + close(ourChan) + return ourChan + } + } + + // We need to calculate a statistic - however, we should only do this one at a time (NOTE: we are still within the lock) + // + // So check if we have a worker already and get a marker channel + var workingChan chan struct{} + if full { + workingChan = fullStatisticWorkingChan + } else { + workingChan = shortStatisticWorkingChan + } + + if workingChan == nil { + // we need to make our own worker... (NOTE: we are still within the lock) + + // create a marker channel which will be closed when our worker is finished + // and assign it to the working map. + workingChan = make(chan struct{}) + if full { + fullStatisticWorkingChan = workingChan + } else { + shortStatisticWorkingChan = workingChan + } + + // Create the working go-routine + go func() { + ctx, _, finished := process.GetManager().AddContext(db.DefaultContext, fmt.Sprintf("Statistics: Full: %t", full)) + defer finished() + stats := models.GetStatistic(ctx, full) + statsPtr := &stats + select { + case <-ctx.Done(): + // The above stats likely have been cancelled part way through generation and should be ignored + statsPtr = nil + default: + } + + // cache the result, remove this worker and inform anyone waiting we are done + statisticsLock.Lock() // Lock within goroutine + if statsPtr != nil { + shortStatistic = statsPtr + if full { + fullStatistic = statsPtr + } + } + if full { + fullStatisticWorkingChan = nil + } else { + shortStatisticWorkingChan = nil + } + close(workingChan) + statisticsLock.Unlock() // Unlock within goroutine + }() + } + + statisticsLock.Unlock() // Unlock from above + + // Create our goroutine for the channel waiting for the statistics to be generated + go func() { + <-workingChan // Wait for the worker to finish + + // Now lock and get the last stats completed + statisticsLock.Lock() + stats := fullStatistic + if !full { + stats = shortStatistic + } + statisticsLock.Unlock() + + ourChan <- stats + close(ourChan) + }() + + return ourChan +} diff --git a/modules/setting/cache.go b/modules/setting/cache.go index 9a44965124446..3e96200f58f78 100644 --- a/modules/setting/cache.go +++ b/modules/setting/cache.go @@ -94,16 +94,18 @@ func newCacheService() { // TTLSeconds returns the TTLSeconds or unix timestamp for memcache func (c Cache) TTLSeconds() int64 { - if c.Adapter == "memcache" && c.TTL > MemcacheMaxTTL { - return time.Now().Add(c.TTL).Unix() - } - return int64(c.TTL.Seconds()) + return DurationToCacheTTL(c.TTL) } // LastCommitCacheTTLSeconds returns the TTLSeconds or unix timestamp for memcache func LastCommitCacheTTLSeconds() int64 { - if CacheService.Adapter == "memcache" && CacheService.LastCommit.TTL > MemcacheMaxTTL { - return time.Now().Add(CacheService.LastCommit.TTL).Unix() + return DurationToCacheTTL(CacheService.LastCommit.TTL) +} + +// DurationToCacheTTL converts a time.Duration to a TTL +func DurationToCacheTTL(duration time.Duration) int64 { + if CacheService.Adapter == "memcache" && duration > MemcacheMaxTTL { + return time.Now().Add(duration).Unix() } - return int64(CacheService.LastCommit.TTL.Seconds()) + return int64(duration.Seconds()) } diff --git a/modules/setting/setting.go b/modules/setting/setting.go index 0af743dd97c27..074a7c41a4cc7 100644 --- a/modules/setting/setting.go +++ b/modules/setting/setting.go @@ -251,6 +251,7 @@ var ( RepoPagingNum int NoticePagingNum int OrgPagingNum int + StatisticTTL time.Duration `ini:"STATISTICS_TTL"` } `ini:"ui.admin"` User struct { RepoPagingNum int @@ -305,11 +306,13 @@ var ( RepoPagingNum int NoticePagingNum int OrgPagingNum int + StatisticTTL time.Duration `ini:"STATISTICS_TTL"` }{ UserPagingNum: 50, RepoPagingNum: 50, NoticePagingNum: 25, OrgPagingNum: 50, + StatisticTTL: 5 * time.Minute, }, User: struct { RepoPagingNum int @@ -409,11 +412,13 @@ var ( Token string EnabledIssueByLabel bool EnabledIssueByRepository bool + StatisticTTL time.Duration `ini:"STATISTICS_TTL"` }{ Enabled: false, Token: "", EnabledIssueByLabel: false, EnabledIssueByRepository: false, + StatisticTTL: 5 * time.Minute, } // I18n settings diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index b534c43a8d7d2..85e9e385ca45a 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -2430,7 +2430,9 @@ dashboard.new_version_hint = Gitea %s is now available, you are running %s. Chec dashboard.statistic = Summary dashboard.operations = Maintenance Operations dashboard.system_status = System Status -dashboard.statistic_info = The Gitea database holds %d users, %d organizations, %d public keys, %d repositories, %d watches, %d stars, ~%d actions, %d accesses, %d issues, %d comments, %d social accounts, %d follows, %d mirrors, %d releases, %d authentication sources, %d webhooks, %d milestones, %d labels, %d hook tasks, %d teams, %d update tasks, %d attachments. +dashboard.statistic_info = The Gitea database holds %d users, %d organizations, %d public keys, %d repositories, %d watches, %d stars, ~%d actions, ~%d accesses, %d issues, ~%d comments, %d social accounts, %d follows, %d mirrors, %d releases, %d authentication sources, %d webhooks, %d milestones, %d labels, ~%d hook tasks, %d teams, %d update tasks, ~%d attachments. +dashboard.statistic_info_last_updated = Last updated %s +dashboard.statistic_info_in_progress = Statistics are being calculated dashboard.operation_name = Operation Name dashboard.operation_switch = Switch dashboard.operation_run = Run diff --git a/routers/web/admin/admin.go b/routers/web/admin/admin.go index ebe5066d2cf6b..11f1f0cb449f7 100644 --- a/routers/web/admin/admin.go +++ b/routers/web/admin/admin.go @@ -21,6 +21,7 @@ import ( "code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/json" "code.gitea.io/gitea/modules/log" + "code.gitea.io/gitea/modules/metrics" "code.gitea.io/gitea/modules/process" "code.gitea.io/gitea/modules/queue" "code.gitea.io/gitea/modules/setting" @@ -123,12 +124,25 @@ func updateSystemStatus() { sysStatus.NumGC = m.NumGC } +func getStatistics() *models.Statistic { + if setting.UI.Admin.StatisticTTL > 0 { + select { + case stats := <-metrics.GetStatistic(setting.UI.Admin.StatisticTTL, false): + return stats + case <-time.After(1 * time.Second): + return nil + } + } + + return <-metrics.GetStatistic(setting.UI.Admin.StatisticTTL, false) +} + // Dashboard show admin panel dashboard func Dashboard(ctx *context.Context) { ctx.Data["Title"] = ctx.Tr("admin.dashboard") ctx.Data["PageIsAdmin"] = true ctx.Data["PageIsAdminDashboard"] = true - ctx.Data["Stats"] = models.GetStatistic() + ctx.Data["Stats"] = getStatistics() ctx.Data["NeedUpdate"] = updatechecker.GetNeedUpdate() ctx.Data["RemoteVersion"] = updatechecker.GetRemoteVersion() // FIXME: update periodically @@ -144,7 +158,7 @@ func DashboardPost(ctx *context.Context) { ctx.Data["Title"] = ctx.Tr("admin.dashboard") ctx.Data["PageIsAdmin"] = true ctx.Data["PageIsAdminDashboard"] = true - ctx.Data["Stats"] = models.GetStatistic() + ctx.Data["Stats"] = getStatistics() updateSystemStatus() ctx.Data["SysStatus"] = sysStatus diff --git a/templates/admin/dashboard.tmpl b/templates/admin/dashboard.tmpl index 80eea91210cc2..47d0f5e6f25a7 100644 --- a/templates/admin/dashboard.tmpl +++ b/templates/admin/dashboard.tmpl @@ -12,9 +12,14 @@ {{.locale.Tr "admin.dashboard.statistic"}}
-

- {{.locale.Tr "admin.dashboard.statistic_info" .Stats.Counter.User .Stats.Counter.Org .Stats.Counter.PublicKey .Stats.Counter.Repo .Stats.Counter.Watch .Stats.Counter.Star .Stats.Counter.Action .Stats.Counter.Access .Stats.Counter.Issue .Stats.Counter.Comment .Stats.Counter.Oauth .Stats.Counter.Follow .Stats.Counter.Mirror .Stats.Counter.Release .Stats.Counter.AuthSource .Stats.Counter.Webhook .Stats.Counter.Milestone .Stats.Counter.Label .Stats.Counter.HookTask .Stats.Counter.Team .Stats.Counter.UpdateTask .Stats.Counter.Attachment | Str2html}} -

+ {{if .Stats}} +

+ {{.locale.Tr "admin.dashboard.statistic_info" .Stats.Counter.User .Stats.Counter.Org .Stats.Counter.PublicKey .Stats.Counter.Repo .Stats.Counter.Watch .Stats.Counter.Star .Stats.Counter.Action .Stats.Counter.Access .Stats.Counter.Issue .Stats.Counter.Comment .Stats.Counter.Oauth .Stats.Counter.Follow .Stats.Counter.Mirror .Stats.Counter.Release .Stats.Counter.AuthSource .Stats.Counter.Webhook .Stats.Counter.Milestone .Stats.Counter.Label .Stats.Counter.HookTask .Stats.Counter.Team .Stats.Counter.UpdateTask .Stats.Counter.Attachment | Str2html}} + {{.locale.Tr "admin.dashboard.statistic_info_last_updated" (TimeSince .Stats.Time $.locale.Lang) | Str2html}} +

+ {{else}} +

{{.locale.Tr "admin.dashboard.statistic_info_in_progress"}}

+ {{end}}

{{.locale.Tr "admin.dashboard.operations"}}