From f7e2568a041118007cc5b746666a79a4ee9974c5 Mon Sep 17 00:00:00 2001 From: Alexander Medvednikov Date: Sat, 16 May 2026 14:48:08 +0300 Subject: [PATCH] admin: statistics dashboard with user, repo, commit, issue charts --- admin/admin_routes.v | 1 + admin/admin_stats.v | 203 ++++++++++++++++++++++++++++++++ static/css/admin.scss | 104 ++++++++++++++++ templates/admin/statistics.html | 57 ++++++++- translations/cn.tr | 30 +++++ translations/en.tr | 30 +++++ translations/es.tr | 30 +++++ translations/jp.tr | 30 +++++ translations/pt.tr | 30 +++++ translations/ru.tr | 30 +++++ 10 files changed, 543 insertions(+), 2 deletions(-) create mode 100644 admin/admin_stats.v diff --git a/admin/admin_routes.v b/admin/admin_routes.v index 8ed2e3f..0f55097 100644 --- a/admin/admin_routes.v +++ b/admin/admin_routes.v @@ -72,5 +72,6 @@ pub fn (mut app App) admin_statistics() veb.Result { if !ctx.is_admin() { return ctx.redirect_to_index() } + stats := app.get_admin_stats(admin_stats_days) return $veb.html('templates/admin/statistics.html') } diff --git a/admin/admin_stats.v b/admin/admin_stats.v new file mode 100644 index 0000000..bcac3e6 --- /dev/null +++ b/admin/admin_stats.v @@ -0,0 +1,203 @@ +module main + +import time +import veb + +const admin_stats_days = 30 +const stats_month_short = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', + 'Nov', 'Dec'] + +struct DayBucket { + label string + count int +} + +struct AdminStats { +mut: + days int + users []DayBucket + repos []DayBucket + commits []DayBucket + issues []DayBucket + total_users int + total_repos int + total_commits int + total_issues int + max_users int + max_repos int + max_commits int + max_issues int +} + +fn stats_day_label(ts i64) string { + t := time.unix(ts) + return '${stats_month_short[t.month - 1]} ${t.day}' +} + +fn stats_bucket_index(ts i64, range_start i64, one_day i64, days int) int { + if ts < range_start { + return -1 + } + idx := int((ts - range_start) / one_day) + if idx < 0 || idx >= days { + return -1 + } + return idx +} + +pub fn (mut app App) get_admin_stats(days int) AdminStats { + one_day := i64(86400) + today_start := time.now().unix() / one_day * one_day + range_start := today_start - i64(days - 1) * one_day + + mut user_counts := []int{len: days, init: 0} + mut repo_counts := []int{len: days, init: 0} + mut commit_counts := []int{len: days, init: 0} + mut issue_counts := []int{len: days, init: 0} + + registered_users := sql app.db { + select from User where is_registered == true + } or { []User{} } + for u in registered_users { + idx := stats_bucket_index(u.created_at.unix(), range_start, one_day, days) + if idx >= 0 { + user_counts[idx]++ + } + } + + repo_rows := db_exec_values(app.db, + 'select created_at from ${sql_table('Repo')} where created_at >= ${range_start}') or { + [][]string{} + } + for row in repo_rows { + if row.len == 0 { + continue + } + idx := stats_bucket_index(row[0].i64(), range_start, one_day, days) + if idx >= 0 { + repo_counts[idx]++ + } + } + + commit_rows := db_exec_values(app.db, + 'select created_at from ${sql_table('Commit')} where created_at >= ${range_start}') or { + [][]string{} + } + for row in commit_rows { + if row.len == 0 { + continue + } + idx := stats_bucket_index(row[0].i64(), range_start, one_day, days) + if idx >= 0 { + commit_counts[idx]++ + } + } + + issue_rows := db_exec_values(app.db, + 'select created_at from ${sql_table('Issue')} where is_pr is false and created_at >= ${range_start}') or { + [][]string{} + } + for row in issue_rows { + if row.len == 0 { + continue + } + idx := stats_bucket_index(row[0].i64(), range_start, one_day, days) + if idx >= 0 { + issue_counts[idx]++ + } + } + + mut user_series := []DayBucket{cap: days} + mut repo_series := []DayBucket{cap: days} + mut commit_series := []DayBucket{cap: days} + mut issue_series := []DayBucket{cap: days} + mut max_u := 0 + mut max_r := 0 + mut max_c := 0 + mut max_i := 0 + for i in 0 .. days { + ts := range_start + i64(i) * one_day + lbl := stats_day_label(ts) + user_series << DayBucket{lbl, user_counts[i]} + repo_series << DayBucket{lbl, repo_counts[i]} + commit_series << DayBucket{lbl, commit_counts[i]} + issue_series << DayBucket{lbl, issue_counts[i]} + if user_counts[i] > max_u { + max_u = user_counts[i] + } + if repo_counts[i] > max_r { + max_r = repo_counts[i] + } + if commit_counts[i] > max_c { + max_c = commit_counts[i] + } + if issue_counts[i] > max_i { + max_i = issue_counts[i] + } + } + + total_users := sql app.db { + select count from User where is_registered == true + } or { 0 } + total_repos := sql app.db { + select count from Repo + } or { 0 } + total_commits := sql app.db { + select count from Commit + } or { 0 } + total_issues := sql app.db { + select count from Issue where is_pr == false + } or { 0 } + + return AdminStats{ + days: days + users: user_series + repos: repo_series + commits: commit_series + issues: issue_series + total_users: total_users + total_repos: total_repos + total_commits: total_commits + total_issues: total_issues + max_users: max_u + max_repos: max_r + max_commits: max_c + max_issues: max_i + } +} + +fn render_stat_chart(buckets []DayBucket, max int, color string) veb.RawHtml { + chart_w := 720 + chart_h := 200 + bar_area_h := 160 + bar_top := 10 + bar_count := buckets.len + if bar_count == 0 { + return veb.RawHtml('') + } + slot := (chart_w - 20) / bar_count + bar_w := if slot > 4 { slot - 2 } else { slot } + mut s := '' + s += '' + for i in 1 .. 5 { + y := bar_top + bar_area_h - bar_area_h * i / 4 + s += '' + } + s += '' + for i, b in buckets { + h := if max == 0 { 0 } else { b.count * bar_area_h / max } + x := 10 + i * slot + y := bar_top + bar_area_h - h + s += '' + s += '${b.label}: ${b.count}' + } + label_y := chart_h - 6 + for i, b in buckets { + if i % 5 == 0 || i == buckets.len - 1 { + x := 10 + i * slot + bar_w / 2 + s += '${b.label}' + } + } + s += '' + return veb.RawHtml(s) +} diff --git a/static/css/admin.scss b/static/css/admin.scss index 228fa32..8069103 100644 --- a/static/css/admin.scss +++ b/static/css/admin.scss @@ -93,3 +93,107 @@ .admin-menu { margin-bottom: 10px; } + +.stat-uptime { + color: #4a5568; + margin-bottom: 18px; +} + +.stat-summary { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; + margin-bottom: 24px; +} + +.stat-summary-item { + background: #f7fafc; + border: 1px solid #e2e8f0; + border-radius: $small-radius; + padding: 14px 16px; + display: flex; + flex-direction: column; +} + +.stat-summary-value { + font-size: 28px; + font-weight: 600; + color: #1a202c; + line-height: 1; +} + +.stat-summary-label { + margin-top: 6px; + font-size: 13px; + color: #4a5568; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.stat-cards { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 18px; + margin-bottom: 30px; +} + +.stat-card { + background: #ffffff; + border: 1px solid #e2e8f0; + border-radius: $small-radius; + padding: 16px 18px; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.03); +} + +.stat-card-head { + display: flex; + justify-content: space-between; + align-items: baseline; + margin-bottom: 10px; +} + +.stat-card-head h3 { + margin: 0; + font-size: 15px; + font-weight: 600; + color: #1a202c; +} + +.stat-card-range { + font-size: 12px; + color: #718096; +} + +.stat-chart { + width: 100%; + height: 180px; + display: block; +} + +.stat-chart-bar { + transition: opacity 0.1s ease; +} + +.stat-chart-bar:hover { + opacity: 0.8; +} + +.stat-chart-grid line { + stroke: #edf2f7; + stroke-width: 1; +} + +.stat-chart-label { + font-size: 9px; + fill: #718096; +} + +@media (max-width: 800px) { + .stat-summary { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .stat-cards { + grid-template-columns: 1fr; + } +} diff --git a/templates/admin/statistics.html b/templates/admin/statistics.html index 162e6bc..d22deeb 100644 --- a/templates/admin/statistics.html +++ b/templates/admin/statistics.html @@ -17,8 +17,61 @@ Statistics -
-

Gitly runs since @app.running_since()

+
+

%admin_stats_running_since @app.running_since()

+
+ +
+
+ @stats.total_users + %admin_stats_total_users +
+
+ @stats.total_repos + %admin_stats_total_repos +
+
+ @stats.total_commits + %admin_stats_total_commits +
+
+ @stats.total_issues + %admin_stats_total_issues +
+
+ +
+
+
+

%admin_stats_new_users

+ %admin_stats_last_n_days +
+ @{render_stat_chart(stats.users, stats.max_users, '#3b82f6')} +
+ +
+
+

%admin_stats_commits

+ %admin_stats_last_n_days +
+ @{render_stat_chart(stats.commits, stats.max_commits, '#22c55e')} +
+ +
+
+

%admin_stats_new_repos

+ %admin_stats_last_n_days +
+ @{render_stat_chart(stats.repos, stats.max_repos, '#a855f7')} +
+ +
+
+

%admin_stats_new_issues

+ %admin_stats_last_n_days +
+ @{render_stat_chart(stats.issues, stats.max_issues, '#f97316')} +
diff --git a/translations/cn.tr b/translations/cn.tr index 7283215..ec4c613 100644 --- a/translations/cn.tr +++ b/translations/cn.tr @@ -767,3 +767,33 @@ header_signed_in_as header_sign_out 退出登录 ----- +admin_stats_running_since +Gitly 运行时间 +----- +admin_stats_total_users +注册用户 +----- +admin_stats_total_repos +仓库 +----- +admin_stats_total_commits +提交 +----- +admin_stats_total_issues +问题 +----- +admin_stats_new_users +新用户 +----- +admin_stats_new_repos +新仓库 +----- +admin_stats_commits +提交 +----- +admin_stats_new_issues +新问题 +----- +admin_stats_last_n_days +最近 30 天 +----- diff --git a/translations/en.tr b/translations/en.tr index 4cb9826..1905c81 100644 --- a/translations/en.tr +++ b/translations/en.tr @@ -767,3 +767,33 @@ Signed in as header_sign_out Sign out ----- +admin_stats_running_since +Gitly runs since +----- +admin_stats_total_users +Registered users +----- +admin_stats_total_repos +Repositories +----- +admin_stats_total_commits +Commits +----- +admin_stats_total_issues +Issues +----- +admin_stats_new_users +New users +----- +admin_stats_new_repos +New repositories +----- +admin_stats_commits +Commits +----- +admin_stats_new_issues +New issues +----- +admin_stats_last_n_days +Last 30 days +----- diff --git a/translations/es.tr b/translations/es.tr index c682dbb..8bf9dcd 100644 --- a/translations/es.tr +++ b/translations/es.tr @@ -767,3 +767,33 @@ Sesión iniciada como header_sign_out Cerrar sesión ----- +admin_stats_running_since +Gitly funciona desde hace +----- +admin_stats_total_users +Usuarios registrados +----- +admin_stats_total_repos +Repositorios +----- +admin_stats_total_commits +Commits +----- +admin_stats_total_issues +Incidencias +----- +admin_stats_new_users +Nuevos usuarios +----- +admin_stats_new_repos +Nuevos repositorios +----- +admin_stats_commits +Commits +----- +admin_stats_new_issues +Nuevas incidencias +----- +admin_stats_last_n_days +Últimos 30 días +----- diff --git a/translations/jp.tr b/translations/jp.tr index 86b93e4..94c072c 100644 --- a/translations/jp.tr +++ b/translations/jp.tr @@ -767,3 +767,33 @@ header_signed_in_as header_sign_out ログアウト ----- +admin_stats_running_since +Gitly の稼働時間 +----- +admin_stats_total_users +登録ユーザー +----- +admin_stats_total_repos +リポジトリ +----- +admin_stats_total_commits +コミット +----- +admin_stats_total_issues +イシュー +----- +admin_stats_new_users +新規ユーザー +----- +admin_stats_new_repos +新規リポジトリ +----- +admin_stats_commits +コミット +----- +admin_stats_new_issues +新規イシュー +----- +admin_stats_last_n_days +直近30日 +----- diff --git a/translations/pt.tr b/translations/pt.tr index b595b03..9240a9d 100644 --- a/translations/pt.tr +++ b/translations/pt.tr @@ -767,3 +767,33 @@ Conectado como header_sign_out Sair ----- +admin_stats_running_since +Gitly em execução há +----- +admin_stats_total_users +Usuários cadastrados +----- +admin_stats_total_repos +Repositórios +----- +admin_stats_total_commits +Commits +----- +admin_stats_total_issues +Issues +----- +admin_stats_new_users +Novos usuários +----- +admin_stats_new_repos +Novos repositórios +----- +admin_stats_commits +Commits +----- +admin_stats_new_issues +Novas issues +----- +admin_stats_last_n_days +Últimos 30 dias +----- diff --git a/translations/ru.tr b/translations/ru.tr index 49ab51c..1f7ceca 100644 --- a/translations/ru.tr +++ b/translations/ru.tr @@ -767,3 +767,33 @@ header_signed_in_as header_sign_out Выйти ----- +admin_stats_running_since +Gitly работает с +----- +admin_stats_total_users +Зарегистрированные пользователи +----- +admin_stats_total_repos +Репозитории +----- +admin_stats_total_commits +Коммиты +----- +admin_stats_total_issues +Задачи +----- +admin_stats_new_users +Новые пользователи +----- +admin_stats_new_repos +Новые репозитории +----- +admin_stats_commits +Коммиты +----- +admin_stats_new_issues +Новые задачи +----- +admin_stats_last_n_days +За 30 дней +----- -- 2.39.5