Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d300cde639 |
@@ -6,6 +6,7 @@ package activities
|
||||
import (
|
||||
"context"
|
||||
|
||||
actions_model "code.gitea.io/gitea/models/actions"
|
||||
asymkey_model "code.gitea.io/gitea/models/asymkey"
|
||||
"code.gitea.io/gitea/models/auth"
|
||||
"code.gitea.io/gitea/models/db"
|
||||
@@ -18,6 +19,7 @@ import (
|
||||
user_model "code.gitea.io/gitea/models/user"
|
||||
"code.gitea.io/gitea/models/webhook"
|
||||
"code.gitea.io/gitea/modules/optional"
|
||||
"code.gitea.io/gitea/modules/timeutil"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/modules/structs"
|
||||
)
|
||||
@@ -37,6 +39,11 @@ type Statistic struct {
|
||||
Branches, Tags, CommitStatus int64
|
||||
IssueByLabel []IssueByLabelCount
|
||||
IssueByRepository []IssueByRepositoryCount
|
||||
|
||||
// MokoGitea extended metrics
|
||||
ActiveUsers30d int64
|
||||
ActionsQueueLength int64
|
||||
ActionsRunningJobs int64
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,5 +138,19 @@ func GetStatistic(ctx context.Context) (stats Statistic) {
|
||||
stats.Counter.Attachment, _ = e.Count(new(repo_model.Attachment))
|
||||
stats.Counter.Project, _ = e.Count(new(project_model.Project))
|
||||
stats.Counter.ProjectColumn, _ = e.Count(new(project_model.Column))
|
||||
|
||||
// MokoGitea extended metrics
|
||||
// Active users in last 30 days (users who performed any action)
|
||||
stats.Counter.ActiveUsers30d, _ = e.Where("last_login_unix > ?",
|
||||
timeutil.TimeStampNow()-30*24*60*60).Count(new(user_model.User))
|
||||
|
||||
// Actions queue and running jobs (if actions enabled)
|
||||
if setting.Actions.Enabled {
|
||||
stats.Counter.ActionsQueueLength, _ = e.Where("status = ?", 1). // StatusWaiting
|
||||
Count(new(actions_model.ActionRunJob))
|
||||
stats.Counter.ActionsRunningJobs, _ = e.Where("status = ?", 2). // StatusRunning
|
||||
Count(new(actions_model.ActionRunJob))
|
||||
}
|
||||
|
||||
return stats
|
||||
}
|
||||
|
||||
+111
-61
@@ -1,79 +1,129 @@
|
||||
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Copyright 2024 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package badge
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"strings"
|
||||
"sync"
|
||||
"unicode"
|
||||
|
||||
actions_model "code.gitea.io/gitea/models/actions"
|
||||
)
|
||||
|
||||
// Badge holds the data for rendering an SVG badge.
|
||||
// The Badge layout: |offset|label|message|
|
||||
// We use 10x scale to calculate more precisely
|
||||
// Then scale down to normal size in tmpl file
|
||||
|
||||
type Text struct {
|
||||
text string
|
||||
width int
|
||||
x int
|
||||
}
|
||||
|
||||
func (t Text) Text() string {
|
||||
return t.text
|
||||
}
|
||||
|
||||
func (t Text) Width() int {
|
||||
return t.width
|
||||
}
|
||||
|
||||
func (t Text) X() int {
|
||||
return t.x
|
||||
}
|
||||
|
||||
func (t Text) TextLength() int {
|
||||
return int(float64(t.width-defaultOffset) * 10)
|
||||
}
|
||||
|
||||
type Badge struct {
|
||||
Label string
|
||||
Message string
|
||||
Color string
|
||||
IDPrefix string
|
||||
FontFamily string
|
||||
Color string
|
||||
FontSize int
|
||||
Label Text
|
||||
Message Text
|
||||
}
|
||||
|
||||
// Color presets
|
||||
func (b Badge) Width() int {
|
||||
return b.Label.width + b.Message.width
|
||||
}
|
||||
|
||||
// Style follows https://shields.io/badges
|
||||
const (
|
||||
ColorGreen = "#4c1"
|
||||
ColorYellow = "#dfb317"
|
||||
ColorRed = "#e05d44"
|
||||
ColorGrey = "#9f9f9f"
|
||||
ColorBlue = "#007ec6"
|
||||
ColorOrange = "#fe7d37"
|
||||
StyleFlat = "flat"
|
||||
StyleFlatSquare = "flat-square"
|
||||
)
|
||||
|
||||
const charWidth = 6.8
|
||||
const (
|
||||
defaultOffset = 10
|
||||
defaultFontSize = 11
|
||||
DefaultColor = "#9f9f9f" // Grey
|
||||
DefaultFontFamily = "DejaVu Sans,Verdana,Geneva,sans-serif"
|
||||
DefaultStyle = StyleFlat
|
||||
)
|
||||
|
||||
func textWidth(s string) float64 {
|
||||
return float64(len(s)) * charWidth
|
||||
var GlobalVars = sync.OnceValue(func() (ret struct {
|
||||
StatusColorMap map[actions_model.Status]string
|
||||
DejaVuGlyphWidthData map[rune]uint8
|
||||
AllStyles []string
|
||||
},
|
||||
) {
|
||||
ret.StatusColorMap = map[actions_model.Status]string{
|
||||
actions_model.StatusSuccess: "#4c1", // Green
|
||||
actions_model.StatusSkipped: "#dfb317", // Yellow
|
||||
actions_model.StatusUnknown: "#97ca00", // Light Green
|
||||
actions_model.StatusFailure: "#e05d44", // Red
|
||||
actions_model.StatusCancelled: "#fe7d37", // Orange
|
||||
actions_model.StatusWaiting: "#dfb317", // Yellow
|
||||
actions_model.StatusRunning: "#dfb317", // Yellow
|
||||
actions_model.StatusBlocked: "#dfb317", // Yellow
|
||||
}
|
||||
ret.DejaVuGlyphWidthData = dejaVuGlyphWidthDataFunc()
|
||||
ret.AllStyles = []string{StyleFlat, StyleFlatSquare}
|
||||
return ret
|
||||
})
|
||||
|
||||
// GenerateBadge generates badge with given template
|
||||
func GenerateBadge(label, message, color string) Badge {
|
||||
lw := calculateTextWidth(label) + defaultOffset
|
||||
mw := calculateTextWidth(message) + defaultOffset
|
||||
|
||||
lx := lw * 5
|
||||
mx := lw*10 + mw*5 - 10
|
||||
return Badge{
|
||||
FontFamily: DefaultFontFamily,
|
||||
Label: Text{
|
||||
text: label,
|
||||
width: lw,
|
||||
x: lx,
|
||||
},
|
||||
Message: Text{
|
||||
text: message,
|
||||
width: mw,
|
||||
x: mx,
|
||||
},
|
||||
FontSize: defaultFontSize * 10,
|
||||
Color: color,
|
||||
}
|
||||
}
|
||||
|
||||
var svgTemplate = template.Must(template.New("badge").Parse(`<svg xmlns="http://www.w3.org/2000/svg" width="{{.TotalWidth}}" height="20" role="img" aria-label="{{.Label}}: {{.Message}}">
|
||||
<title>{{.Label}}: {{.Message}}</title>
|
||||
<linearGradient id="s" x2="0" y2="100%">
|
||||
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
||||
<stop offset="1" stop-opacity=".1"/>
|
||||
</linearGradient>
|
||||
<clipPath id="r"><rect width="{{.TotalWidth}}" height="20" rx="3" fill="#fff"/></clipPath>
|
||||
<g clip-path="url(#r)">
|
||||
<rect width="{{.LabelWidth}}" height="20" fill="#555"/>
|
||||
<rect x="{{.LabelWidth}}" width="{{.MessageWidth}}" height="20" fill="{{.Color}}"/>
|
||||
<rect width="{{.TotalWidth}}" height="20" fill="url(#s)"/>
|
||||
</g>
|
||||
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
|
||||
<text aria-hidden="true" x="{{.LabelX}}" y="15" fill="#010101" fill-opacity=".3">{{.Label}}</text>
|
||||
<text x="{{.LabelX}}" y="14">{{.Label}}</text>
|
||||
<text aria-hidden="true" x="{{.MessageX}}" y="15" fill="#010101" fill-opacity=".3">{{.Message}}</text>
|
||||
<text x="{{.MessageX}}" y="14">{{.Message}}</text>
|
||||
</g>
|
||||
</svg>`))
|
||||
|
||||
type templateData struct {
|
||||
Label, Message, Color string
|
||||
LabelWidth, MessageWidth int
|
||||
TotalWidth int
|
||||
LabelX, MessageX float64
|
||||
}
|
||||
|
||||
// Render generates an SVG badge as a byte slice.
|
||||
func (b Badge) Render() ([]byte, error) {
|
||||
padding := 12.0
|
||||
lw := int(textWidth(b.Label) + padding)
|
||||
mw := int(textWidth(b.Message) + padding)
|
||||
|
||||
data := templateData{
|
||||
Label: b.Label, Message: b.Message, Color: b.Color,
|
||||
LabelWidth: lw, MessageWidth: mw, TotalWidth: lw + mw,
|
||||
LabelX: float64(lw) / 2, MessageX: float64(lw) + float64(mw)/2,
|
||||
func calculateTextWidth(text string) int {
|
||||
width := 0
|
||||
widthData := GlobalVars().DejaVuGlyphWidthData
|
||||
for _, char := range strings.TrimSpace(text) {
|
||||
charWidth, ok := widthData[char]
|
||||
if !ok {
|
||||
// use the width of 'm' in case of missing glyph width data for a printable character
|
||||
if unicode.IsPrint(char) {
|
||||
charWidth = widthData['m']
|
||||
} else {
|
||||
charWidth = 0
|
||||
}
|
||||
}
|
||||
width += int(charWidth)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := svgTemplate.Execute(&buf, data); err != nil {
|
||||
return nil, fmt.Errorf("rendering badge: %w", err)
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
return width
|
||||
}
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package badge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
git_model "code.gitea.io/gitea/models/git"
|
||||
repo_model "code.gitea.io/gitea/models/repo"
|
||||
)
|
||||
|
||||
// Generate creates a badge for the given repo and badge type.
|
||||
func Generate(ctx context.Context, repo *repo_model.Repository, badgeType string) (Badge, error) {
|
||||
switch strings.ToLower(badgeType) {
|
||||
case "version":
|
||||
return versionBadge(ctx, repo)
|
||||
case "build":
|
||||
return buildBadge(ctx, repo)
|
||||
case "license":
|
||||
return licenseBadge(repo)
|
||||
case "health":
|
||||
return healthBadge(ctx, repo)
|
||||
default:
|
||||
return Badge{Label: "badge", Message: "unknown", Color: ColorGrey},
|
||||
fmt.Errorf("unknown badge type: %s", badgeType)
|
||||
}
|
||||
}
|
||||
|
||||
func versionBadge(ctx context.Context, repo *repo_model.Repository) (Badge, error) {
|
||||
release, err := repo_model.GetLatestReleaseByRepoID(ctx, repo.ID)
|
||||
if err != nil || release == nil {
|
||||
return Badge{Label: "version", Message: "none", Color: ColorGrey}, nil
|
||||
}
|
||||
return Badge{Label: "version", Message: release.TagName, Color: ColorBlue}, nil
|
||||
}
|
||||
|
||||
func buildBadge(ctx context.Context, repo *repo_model.Repository) (Badge, error) {
|
||||
status, err := git_model.GetLatestCommitStatus(ctx, repo.ID, repo.DefaultBranch, 1)
|
||||
if err != nil || len(status) == 0 {
|
||||
return Badge{Label: "build", Message: "unknown", Color: ColorGrey}, nil
|
||||
}
|
||||
|
||||
switch status[0].State.String() {
|
||||
case "success":
|
||||
return Badge{Label: "build", Message: "passing", Color: ColorGreen}, nil
|
||||
case "failure", "error":
|
||||
return Badge{Label: "build", Message: "failing", Color: ColorRed}, nil
|
||||
case "pending":
|
||||
return Badge{Label: "build", Message: "pending", Color: ColorYellow}, nil
|
||||
default:
|
||||
return Badge{Label: "build", Message: status[0].State.String(), Color: ColorGrey}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func licenseBadge(repo *repo_model.Repository) (Badge, error) {
|
||||
if len(repo.License) > 0 {
|
||||
return Badge{Label: "license", Message: repo.License, Color: ColorBlue}, nil
|
||||
}
|
||||
return Badge{Label: "license", Message: "none", Color: ColorGrey}, nil
|
||||
}
|
||||
|
||||
func healthBadge(ctx context.Context, repo *repo_model.Repository) (Badge, error) {
|
||||
score := 0
|
||||
if repo.HasWiki() {
|
||||
score++
|
||||
}
|
||||
if len(repo.License) > 0 {
|
||||
score++
|
||||
}
|
||||
if repo.Description != "" {
|
||||
score++
|
||||
}
|
||||
|
||||
var color string
|
||||
var msg string
|
||||
switch {
|
||||
case score >= 3:
|
||||
color, msg = ColorGreen, "healthy"
|
||||
case score >= 2:
|
||||
color, msg = ColorYellow, "fair"
|
||||
default:
|
||||
color, msg = ColorRed, "needs work"
|
||||
}
|
||||
return Badge{Label: "health", Message: msg, Color: color}, nil
|
||||
}
|
||||
@@ -46,6 +46,11 @@ type Collector struct {
|
||||
Users *prometheus.Desc
|
||||
Watches *prometheus.Desc
|
||||
Webhooks *prometheus.Desc
|
||||
|
||||
// MokoGitea extended metrics
|
||||
ActiveUsers30d *prometheus.Desc
|
||||
ActionsQueueLength *prometheus.Desc
|
||||
ActionsRunningJobs *prometheus.Desc
|
||||
}
|
||||
|
||||
// NewCollector returns a new Collector with all prometheus.Desc initialized
|
||||
@@ -196,6 +201,21 @@ func NewCollector() Collector {
|
||||
"Number of Webhooks",
|
||||
nil, nil,
|
||||
),
|
||||
ActiveUsers30d: prometheus.NewDesc(
|
||||
namespace+"active_users_30d",
|
||||
"Number of active users in the last 30 days",
|
||||
nil, nil,
|
||||
),
|
||||
ActionsQueueLength: prometheus.NewDesc(
|
||||
namespace+"actions_queue_length",
|
||||
"Number of actions jobs waiting to run",
|
||||
nil, nil,
|
||||
),
|
||||
ActionsRunningJobs: prometheus.NewDesc(
|
||||
namespace+"actions_running_jobs",
|
||||
"Number of actions jobs currently running",
|
||||
nil, nil,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -229,6 +249,9 @@ func (c Collector) Describe(ch chan<- *prometheus.Desc) {
|
||||
ch <- c.Users
|
||||
ch <- c.Watches
|
||||
ch <- c.Webhooks
|
||||
ch <- c.ActiveUsers30d
|
||||
ch <- c.ActionsQueueLength
|
||||
ch <- c.ActionsRunningJobs
|
||||
}
|
||||
|
||||
// Collect returns the metrics with values
|
||||
@@ -392,4 +415,21 @@ func (c Collector) Collect(ch chan<- prometheus.Metric) {
|
||||
prometheus.GaugeValue,
|
||||
float64(stats.Counter.Webhook),
|
||||
)
|
||||
|
||||
// MokoGitea extended metrics
|
||||
ch <- prometheus.MustNewConstMetric(
|
||||
c.ActiveUsers30d,
|
||||
prometheus.GaugeValue,
|
||||
float64(stats.Counter.ActiveUsers30d),
|
||||
)
|
||||
ch <- prometheus.MustNewConstMetric(
|
||||
c.ActionsQueueLength,
|
||||
prometheus.GaugeValue,
|
||||
float64(stats.Counter.ActionsQueueLength),
|
||||
)
|
||||
ch <- prometheus.MustNewConstMetric(
|
||||
c.ActionsRunningJobs,
|
||||
prometheus.GaugeValue,
|
||||
float64(stats.Counter.ActionsRunningJobs),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1430,9 +1430,7 @@ func Routes() *web.Router {
|
||||
Delete(reqToken(), repo.DeleteTopic)
|
||||
}, reqAdmin())
|
||||
}, reqAnyRepoReader())
|
||||
// MokoGitea badge engine
|
||||
m.Get("/badge/{type}.svg", repo.GetRepoBadge)
|
||||
m.Get("/issue_templates", context.ReferencesGitRepo(), repo.GetIssueTemplates)
|
||||
m.Get("/issue_templates", context.ReferencesGitRepo(), repo.GetIssueTemplates)
|
||||
m.Get("/issue_config", context.ReferencesGitRepo(), repo.GetIssueConfig)
|
||||
m.Get("/issue_config/validate", context.ReferencesGitRepo(), repo.ValidateIssueConfig)
|
||||
m.Get("/languages", reqRepoReader(unit.TypeCode), repo.GetLanguages)
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package repo
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"code.gitea.io/gitea/modules/badge"
|
||||
"code.gitea.io/gitea/services/context"
|
||||
)
|
||||
|
||||
// GetRepoBadge returns an SVG badge for the repository.
|
||||
func GetRepoBadge(ctx *context.APIContext) {
|
||||
badgeType := ctx.PathParam("type")
|
||||
|
||||
b, err := badge.Generate(ctx, ctx.Repo.Repository, badgeType)
|
||||
if err != nil {
|
||||
b = badge.Badge{Label: "badge", Message: "error", Color: badge.ColorGrey}
|
||||
}
|
||||
|
||||
svg, err := b.Render()
|
||||
if err != nil {
|
||||
ctx.APIErrorInternal(fmt.Errorf("rendering badge: %w", err))
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Resp.Header().Set("Content-Type", "image/svg+xml")
|
||||
ctx.Resp.Header().Set("Cache-Control", "public, max-age=300")
|
||||
ctx.Resp.Header().Set("ETag", fmt.Sprintf(`"%s-%s"`, b.Label, b.Message))
|
||||
ctx.Resp.WriteHeader(http.StatusOK)
|
||||
_, _ = ctx.Resp.Write(svg)
|
||||
}
|
||||
Reference in New Issue
Block a user