2
0

v2.1.3: Add AI Learning admin UI and server status dashboard tiles
Some checks are pending
Build and Release / Lint (push) Waiting to run
Build and Release / Unit Tests (push) Waiting to run
Build and Release / Integration Tests (PostgreSQL) (push) Waiting to run
Build and Release / Create Release (push) Waiting to run
Build and Release / Build Binaries (amd64, darwin) (push) Blocked by required conditions
Build and Release / Build Binaries (amd64, linux) (push) Blocked by required conditions
Build and Release / Build Binaries (amd64, windows) (push) Blocked by required conditions
Build and Release / Build Binaries (arm64, darwin) (push) Blocked by required conditions
Build and Release / Build Binaries (arm64, linux) (push) Blocked by required conditions

- Add AI Learning admin section for viewing/editing error patterns
- Add server status tiles to admin dashboard (CPU load, memory, disk)
- Auto-refresh dashboard tiles using HTMX
- Fix error template text (GitCaddy Server)
- Dark mode compatibility for all new UI elements

🤖 Generated with Claude Code
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
GitCaddy
2026-01-15 14:55:06 +00:00
parent c7b10e1172
commit 70452a9477
14 changed files with 757 additions and 6 deletions

View File

@@ -251,7 +251,7 @@ jobs:
TAGS: bindata sqlite sqlite_unlock_notify
run: |
VERSION=$(git describe --tags --always --dirty 2>/dev/null | sed "s/-gitcaddy//" || echo "dev")
LDFLAGS="-X code.gitea.io/gitea/modules/setting.AppVer=${VERSION}"
LDFLAGS="-X main.Version=${VERSION}"
EXT=""
if [ "$GOOS" = "windows" ]; then

View File

@@ -112,7 +112,7 @@ func loadActionsFrom(rootCfg ConfigProvider) error {
Actions.RunnerHealthCheck.MinDiskPercent = 5.0 // Need at least 5% free
}
if Actions.RunnerHealthCheck.MaxDiskUsagePercent <= 0 {
Actions.RunnerHealthCheck.MaxDiskUsagePercent = 95.0 // Alert at 95% used
Actions.RunnerHealthCheck.MaxDiskUsagePercent = 85.0 // Alert at 85% used
}
if Actions.RunnerHealthCheck.MaxLatencyMs <= 0 {
Actions.RunnerHealthCheck.MaxLatencyMs = 500.0 // 500ms max latency

View File

@@ -207,7 +207,7 @@
"filter.string.asc": "AZ",
"filter.string.desc": "ZA",
"error.occurred": "An error occurred",
"error.report_message": "If you believe that this is a GitCaddy bug, please search for issues on <a href=\"%s\" target=\"_blank\">GitCaddy GitCaddy</a> or open a new issue if necessary.",
"error.report_message": "If you believe that this is a GitCaddy bug, please search for issues on <a href=\"%s\" target=\"_blank\">GitCaddy Server</a> or open a new issue if necessary.",
"error.not_found": "The target couldn't be found.",
"error.network_error": "Network error",
"startpage.app_desc": "Steeped in your workflow",
@@ -3961,5 +3961,37 @@
"actions.runners.queue_depth": "Job Queue",
"actions.runners.current_task": "Current Task",
"actions.runners.cleanup_requested": "Cleanup request sent to runner",
"actions.runners.cleanup_request_failed": "Failed to send cleanup request"
"actions.runners.cleanup_request_failed": "Failed to send cleanup request",
"admin.ai_learning": "AI Learning",
"admin.ai_learning.edit": "Edit Pattern",
"admin.ai_learning.total_patterns": "Total Patterns",
"admin.ai_learning.total_occurrences": "Total Occurrences",
"admin.ai_learning.total_successes": "Solutions Used",
"admin.ai_learning.success_rate": "Success Rate",
"admin.ai_learning.filter": "Filter",
"admin.ai_learning.clear_filters": "Clear",
"admin.ai_learning.all": "All",
"admin.ai_learning.filter_pattern": "Pattern Code",
"admin.ai_learning.filter_runner_type": "Runner Type",
"admin.ai_learning.filter_project_type": "Project Type",
"admin.ai_learning.pattern": "Pattern",
"admin.ai_learning.pattern_help": "Error code or pattern identifier (e.g., NETSDK1147)",
"admin.ai_learning.pattern_regex": "Regex Pattern",
"admin.ai_learning.pattern_regex_help": "Optional regex for matching error messages",
"admin.ai_learning.runner_type": "Runner Type",
"admin.ai_learning.project_type": "Project Type",
"admin.ai_learning.framework": "Framework",
"admin.ai_learning.error_message": "Error Message",
"admin.ai_learning.diagnosis": "Diagnosis",
"admin.ai_learning.solution": "Solution",
"admin.ai_learning.solution_diff": "Solution Diff",
"admin.ai_learning.occurrences": "Occurrences",
"admin.ai_learning.successes": "Successes",
"admin.ai_learning.actions": "Actions",
"admin.ai_learning.created": "Created",
"admin.ai_learning.updated": "Pattern updated successfully",
"admin.ai_learning.no_patterns": "No error patterns found",
"admin.ai_learning.delete_confirm": "Are you sure you want to delete this pattern?",
"admin.ai_learning.delete_selected": "Delete Selected",
"admin.ai_learning.deleted": "Pattern(s) deleted successfully"
}

View File

@@ -1104,7 +1104,7 @@ THE SOFTWARE.
--------------------------------------------------------------------------------
--------------------------------------------------------------------------------
@citation-js/name@0.4.2 - MIT
@citation-js/date@0.5.1 - MIT
--------------------------------------------------------------------------------
--------------------------------------------------------------------------------

View File

@@ -34,6 +34,7 @@ import (
const (
tplDashboard templates.TplName = "admin/dashboard"
tplSystemStatus templates.TplName = "admin/system_status"
tplServerStats templates.TplName = "admin/server_stats"
tplSelfCheck templates.TplName = "admin/self_check"
tplCron templates.TplName = "admin/cron"
tplQueue templates.TplName = "admin/queue"
@@ -140,6 +141,7 @@ func Dashboard(ctx *context.Context) {
ctx.Data["RemoteVersion"] = updatechecker.GetRemoteVersion(ctx)
updateSystemStatus()
ctx.Data["SysStatus"] = sysStatus
ctx.Data["ServerStats"] = GetServerStats()
ctx.Data["SSH"] = setting.SSH
prepareStartupProblemsAlert(ctx)
ctx.HTML(http.StatusOK, tplDashboard)
@@ -148,9 +150,16 @@ func Dashboard(ctx *context.Context) {
func SystemStatus(ctx *context.Context) {
updateSystemStatus()
ctx.Data["SysStatus"] = sysStatus
ctx.Data["ServerStats"] = GetServerStats()
ctx.HTML(http.StatusOK, tplSystemStatus)
}
// ServerStats returns the server stats partial for HTMX refresh
func DashboardServerStats(ctx *context.Context) {
ctx.Data["ServerStats"] = GetServerStats()
ctx.HTML(http.StatusOK, tplServerStats)
}
// DashboardPost run an admin operation
func DashboardPost(ctx *context.Context) {
form := web.GetForm(ctx).(*forms.AdminDashboardForm)
@@ -158,6 +167,7 @@ func DashboardPost(ctx *context.Context) {
ctx.Data["PageIsAdminDashboard"] = true
updateSystemStatus()
ctx.Data["SysStatus"] = sysStatus
ctx.Data["ServerStats"] = GetServerStats()
// Run operation.
if form.Op != "" {

View File

@@ -0,0 +1,217 @@
// Copyright 2026 MarketAlly. All rights reserved.
// SPDX-License-Identifier: MIT
package admin
import (
"net/http"
"strconv"
ai_model "code.gitea.io/gitea/models/ai"
"code.gitea.io/gitea/models/db"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/templates"
"code.gitea.io/gitea/modules/web"
"code.gitea.io/gitea/services/context"
"code.gitea.io/gitea/services/forms"
)
const (
tplAILearning templates.TplName = "admin/ai_learning"
tplAILearningEdit templates.TplName = "admin/ai_learning_edit"
)
// AILearningStats holds statistics for the AI Learning dashboard
type AILearningStats struct {
TotalPatterns int64
TotalOccurrences int64
TotalSuccesses int64
SuccessRate float64
TopRunnerTypes []string
TopProjectTypes []string
}
// AILearning shows the AI Learning patterns admin page
func AILearning(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("admin.ai_learning")
ctx.Data["PageIsAdminAILearning"] = true
page := max(ctx.FormInt("page"), 1)
pageSize := setting.UI.Admin.NoticePagingNum
if pageSize == 0 {
pageSize = 20
}
// Get filter params
patternFilter := ctx.FormString("pattern")
runnerType := ctx.FormString("runner_type")
projectType := ctx.FormString("project_type")
// Get patterns
patterns, err := ai_model.GetErrorPatterns(ctx, patternFilter, runnerType, projectType)
if err != nil {
ctx.ServerError("GetErrorPatterns", err)
return
}
// Calculate stats
stats := &AILearningStats{}
var totalOccurrences, totalSuccesses int64
runnerTypes := make(map[string]bool)
projectTypes := make(map[string]bool)
for _, p := range patterns {
totalOccurrences += int64(p.OccurrenceCount)
totalSuccesses += int64(p.SuccessCount)
if p.RunnerType != "" {
runnerTypes[p.RunnerType] = true
}
if p.ProjectType != "" {
projectTypes[p.ProjectType] = true
}
}
stats.TotalPatterns = int64(len(patterns))
stats.TotalOccurrences = totalOccurrences
stats.TotalSuccesses = totalSuccesses
if totalOccurrences > 0 {
stats.SuccessRate = float64(totalSuccesses) / float64(totalOccurrences) * 100
}
for rt := range runnerTypes {
stats.TopRunnerTypes = append(stats.TopRunnerTypes, rt)
}
for pt := range projectTypes {
stats.TopProjectTypes = append(stats.TopProjectTypes, pt)
}
// Paginate patterns
total := len(patterns)
start := (page - 1) * pageSize
end := start + pageSize
if start > total {
start = total
}
if end > total {
end = total
}
if start < total {
patterns = patterns[start:end]
} else {
patterns = []*ai_model.ErrorPattern{}
}
ctx.Data["Stats"] = stats
ctx.Data["Patterns"] = patterns
ctx.Data["Total"] = total
ctx.Data["PatternFilter"] = patternFilter
ctx.Data["RunnerTypeFilter"] = runnerType
ctx.Data["ProjectTypeFilter"] = projectType
ctx.Data["Page"] = context.NewPagination(total, pageSize, page, 5)
ctx.HTML(http.StatusOK, tplAILearning)
}
// AILearningEdit shows the edit form for an error pattern
func AILearningEdit(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("admin.ai_learning.edit")
ctx.Data["PageIsAdminAILearning"] = true
id := ctx.PathParamInt64("id")
if id == 0 {
ctx.NotFound(nil)
return
}
pattern, err := ai_model.GetErrorPatternByID(ctx, id)
if err != nil {
ctx.ServerError("GetErrorPatternByID", err)
return
}
if pattern == nil {
ctx.NotFound(nil)
return
}
ctx.Data["Pattern"] = pattern
ctx.HTML(http.StatusOK, tplAILearningEdit)
}
// AILearningEditPost handles the edit form submission
func AILearningEditPost(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("admin.ai_learning.edit")
ctx.Data["PageIsAdminAILearning"] = true
id := ctx.PathParamInt64("id")
if id == 0 {
ctx.NotFound(nil)
return
}
pattern, err := ai_model.GetErrorPatternByID(ctx, id)
if err != nil {
ctx.ServerError("GetErrorPatternByID", err)
return
}
if pattern == nil {
ctx.NotFound(nil)
return
}
form := web.GetForm(ctx).(*forms.AILearningForm)
pattern.Pattern = form.Pattern
pattern.PatternRegex = form.PatternRegex
pattern.RunnerType = form.RunnerType
pattern.ProjectType = form.ProjectType
pattern.Framework = form.Framework
pattern.ErrorMessage = form.ErrorMessage
pattern.Diagnosis = form.Diagnosis
pattern.Solution = form.Solution
pattern.SolutionDiff = form.SolutionDiff
if _, err := db.GetEngine(ctx).ID(id).AllCols().Update(pattern); err != nil {
ctx.ServerError("UpdateErrorPattern", err)
return
}
ctx.Flash.Success(ctx.Tr("admin.ai_learning.updated"))
ctx.Redirect(setting.AppSubURL + "/-/admin/ai-learning")
}
// AILearningDelete handles deletion of error patterns
func AILearningDelete(ctx *context.Context) {
id := ctx.PathParamInt64("id")
if id == 0 {
ctx.NotFound(nil)
return
}
if _, err := db.GetEngine(ctx).ID(id).Delete(&ai_model.ErrorPattern{}); err != nil {
ctx.Flash.Error("Delete error pattern: " + err.Error())
ctx.Status(http.StatusInternalServerError)
return
}
ctx.Flash.Success(ctx.Tr("admin.ai_learning.deleted"))
ctx.JSONRedirect(setting.AppSubURL + "/-/admin/ai-learning")
}
// AILearningDeleteMultiple handles bulk deletion of error patterns
func AILearningDeleteMultiple(ctx *context.Context) {
strs := ctx.FormStrings("ids[]")
ids := make([]int64, 0, len(strs))
for i := range strs {
id, _ := strconv.ParseInt(strs[i], 10, 64)
if id > 0 {
ids = append(ids, id)
}
}
if err := db.DeleteByIDs[ai_model.ErrorPattern](ctx, ids...); err != nil {
ctx.Flash.Error("DeleteErrorPatterns: " + err.Error())
ctx.Status(http.StatusInternalServerError)
} else {
ctx.Flash.Success(ctx.Tr("admin.ai_learning.deleted"))
ctx.Status(http.StatusOK)
}
}

View File

@@ -0,0 +1,141 @@
// Copyright 2026 MarketAlly. All rights reserved.
// SPDX-License-Identifier: MIT
package admin
import (
"os"
"runtime"
"strconv"
"strings"
"syscall"
"time"
)
// ServerStats holds server system statistics
type ServerStats struct {
// CPU
NumCPU int
NumGoroutine int
CPULoad float64 // 1-minute load average
// Memory (from runtime)
MemTotal int64
MemUsed int64
MemPercent float64
// Disk
DiskTotal int64
DiskUsed int64
DiskFree int64
DiskPercent float64
// Status
Uptime time.Duration
StartTime time.Time
Status string
}
var serverStartTime = time.Now()
// GetServerStats collects current server statistics
func GetServerStats() *ServerStats {
stats := &ServerStats{
NumCPU: runtime.NumCPU(),
NumGoroutine: runtime.NumGoroutine(),
StartTime: serverStartTime,
Uptime: time.Since(serverStartTime),
Status: "healthy",
}
// Get CPU load average
stats.CPULoad = getCPULoad()
// Memory stats from runtime
var m runtime.MemStats
runtime.ReadMemStats(&m)
stats.MemUsed = int64(m.Alloc)
stats.MemTotal = int64(m.Sys)
if stats.MemTotal > 0 {
stats.MemPercent = float64(stats.MemUsed) / float64(stats.MemTotal) * 100
}
// Disk stats
diskPath := "/"
if runtime.GOOS == "windows" {
diskPath = "C:\\"
}
if stat, err := getDiskUsage(diskPath); err == nil {
stats.DiskTotal = stat.Total
stats.DiskUsed = stat.Used
stats.DiskFree = stat.Free
if stats.DiskTotal > 0 {
stats.DiskPercent = float64(stats.DiskUsed) / float64(stats.DiskTotal) * 100
}
}
return stats
}
// getCPULoad returns the 1-minute load average on Linux
func getCPULoad() float64 {
if runtime.GOOS != "linux" {
return 0
}
data, err := os.ReadFile("/proc/loadavg")
if err != nil {
return 0
}
fields := strings.Fields(string(data))
if len(fields) < 1 {
return 0
}
load, err := strconv.ParseFloat(fields[0], 64)
if err != nil {
return 0
}
return load
}
type diskUsage struct {
Total int64
Free int64
Used int64
}
func getDiskUsage(path string) (*diskUsage, error) {
if runtime.GOOS == "windows" {
return getDiskUsageWindows(path)
}
return getDiskUsageUnix(path)
}
func getDiskUsageUnix(path string) (*diskUsage, error) {
var stat syscall.Statfs_t
if err := syscall.Statfs(path, &stat); err != nil {
return nil, err
}
total := int64(stat.Blocks) * stat.Bsize
free := int64(stat.Bavail) * stat.Bsize
used := total - free
return &diskUsage{
Total: total,
Free: free,
Used: used,
}, nil
}
func getDiskUsageWindows(_ string) (*diskUsage, error) {
return &diskUsage{
Total: 0,
Free: 0,
Used: 0,
}, nil
}

View File

@@ -769,6 +769,7 @@ func registerWebRoutes(m *web.Router) {
m.Group("/-/admin", func() {
m.Get("", admin.Dashboard)
m.Get("/system_status", admin.SystemStatus)
m.Get("/server_stats", admin.DashboardServerStats)
m.Post("", web.Bind(forms.AdminDashboardForm{}), admin.DashboardPost)
m.Get("/self_check", admin.SelfCheck)
@@ -859,6 +860,14 @@ func registerWebRoutes(m *web.Router) {
m.Post("/empty", admin.EmptyNotices)
})
m.Group("/ai-learning", func() {
m.Get("", admin.AILearning)
m.Get("/{id}", admin.AILearningEdit)
m.Post("/{id}", web.Bind(forms.AILearningForm{}), admin.AILearningEditPost)
m.Post("/{id}/delete", admin.AILearningDelete)
m.Post("/delete", admin.AILearningDeleteMultiple)
})
m.Group("/applications", func() {
m.Get("", admin.Applications)
m.Post("/oauth2", web.Bind(forms.EditOAuth2ApplicationForm{}), admin.ApplicationsPost)

View File

@@ -71,3 +71,22 @@ func (f *AdminDashboardForm) Validate(req *http.Request, errs binding.Errors) bi
ctx := context.GetValidateContext(req)
return middleware.Validate(errs, ctx.Data, f, ctx.Locale)
}
// AILearningForm form for editing AI learning patterns
type AILearningForm struct {
Pattern string `binding:"Required"`
PatternRegex string
RunnerType string
ProjectType string
Framework string
ErrorMessage string
Diagnosis string
Solution string
SolutionDiff string
}
// Validate validates form fields
func (f *AILearningForm) Validate(req *http.Request, errs binding.Errors) binding.Errors {
ctx := context.GetValidateContext(req)
return middleware.Validate(errs, ctx.Data, f, ctx.Locale)
}

View File

@@ -0,0 +1,185 @@
{{template "admin/layout_head" .}}
<div class="admin ai-learning">
<h4 class="ui top attached header">
{{ctx.Locale.Tr "admin.ai_learning"}}
</h4>
<!-- Stats Tiles -->
<div class="ui attached segment">
<div style="display: flex; justify-content: space-around; text-align: center;">
<div style="padding: 15px 25px; background: var(--color-box-body); border: 1px solid var(--color-secondary); border-radius: 8px; min-width: 150px;">
<div style="font-size: 2em; font-weight: bold; color: #2185d0;">{{.Stats.TotalPatterns}}</div>
<div style="color: var(--color-text-light); margin-top: 5px;">{{ctx.Locale.Tr "admin.ai_learning.total_patterns"}}</div>
</div>
<div style="padding: 15px 25px; background: var(--color-box-body); border: 1px solid var(--color-secondary); border-radius: 8px; min-width: 150px;">
<div style="font-size: 2em; font-weight: bold; color: #6435c9;">{{.Stats.TotalOccurrences}}</div>
<div style="color: var(--color-text-light); margin-top: 5px;">{{ctx.Locale.Tr "admin.ai_learning.total_occurrences"}}</div>
</div>
<div style="padding: 15px 25px; background: var(--color-box-body); border: 1px solid var(--color-secondary); border-radius: 8px; min-width: 150px;">
<div style="font-size: 2em; font-weight: bold; color: #21ba45;">{{.Stats.TotalSuccesses}}</div>
<div style="color: var(--color-text-light); margin-top: 5px;">{{ctx.Locale.Tr "admin.ai_learning.total_successes"}}</div>
</div>
<div style="padding: 15px 25px; background: var(--color-box-body); border: 1px solid var(--color-secondary); border-radius: 8px; min-width: 150px;">
<div style="font-size: 2em; font-weight: bold; color: #f2711c;">{{printf "%.1f" .Stats.SuccessRate}}%</div>
<div style="color: var(--color-text-light); margin-top: 5px;">{{ctx.Locale.Tr "admin.ai_learning.success_rate"}}</div>
</div>
</div>
</div>
<!-- Filters -->
<div class="ui attached segment">
<form class="ui form" method="get">
<div class="fields" style="align-items: flex-end; margin-bottom: 0;">
<div class="four wide field">
<label>{{ctx.Locale.Tr "admin.ai_learning.filter_pattern"}}</label>
<input type="text" name="pattern" value="{{.PatternFilter}}" placeholder="e.g., NETSDK1147">
</div>
<div class="three wide field">
<label>{{ctx.Locale.Tr "admin.ai_learning.filter_runner_type"}}</label>
<select class="ui dropdown" name="runner_type">
<option value="">{{ctx.Locale.Tr "admin.ai_learning.all"}}</option>
{{range .Stats.TopRunnerTypes}}
<option value="{{.}}" {{if eq . $.RunnerTypeFilter}}selected{{end}}>{{.}}</option>
{{end}}
</select>
</div>
<div class="four wide field">
<label>{{ctx.Locale.Tr "admin.ai_learning.filter_project_type"}}</label>
<select class="ui dropdown" name="project_type">
<option value="">{{ctx.Locale.Tr "admin.ai_learning.all"}}</option>
{{range .Stats.TopProjectTypes}}
<option value="{{.}}" {{if eq . $.ProjectTypeFilter}}selected{{end}}>{{.}}</option>
{{end}}
</select>
</div>
<div class="field">
<button class="ui primary button" type="submit">{{ctx.Locale.Tr "admin.ai_learning.filter"}}</button>
</div>
<div class="field">
<a class="ui button" href="{{AppSubUrl}}/-/admin/ai-learning">{{ctx.Locale.Tr "admin.ai_learning.clear_filters"}}</a>
</div>
</div>
</form>
</div>
<!-- Pattern List -->
<div class="ui attached segment">
<form method="post" action="{{AppSubUrl}}/-/admin/ai-learning/delete">
{{.CsrfTokenHtml}}
<table class="ui celled striped table">
<thead>
<tr>
<th class="collapsing">
<div class="ui checkbox">
<input type="checkbox" class="select-all">
<label></label>
</div>
</th>
<th>{{ctx.Locale.Tr "admin.ai_learning.pattern"}}</th>
<th>{{ctx.Locale.Tr "admin.ai_learning.runner_type"}}</th>
<th>{{ctx.Locale.Tr "admin.ai_learning.project_type"}}</th>
<th class="collapsing">{{ctx.Locale.Tr "admin.ai_learning.occurrences"}}</th>
<th class="collapsing">{{ctx.Locale.Tr "admin.ai_learning.successes"}}</th>
<th class="collapsing">{{ctx.Locale.Tr "admin.ai_learning.actions"}}</th>
</tr>
</thead>
<tbody>
{{range .Patterns}}
<tr>
<td>
<div class="ui checkbox">
<input type="checkbox" name="ids[]" value="{{.ID}}">
<label></label>
</div>
</td>
<td>
<strong>{{.Pattern}}</strong>
{{if .Diagnosis}}
<br><small class="text truncate" style="color: var(--color-text-light); max-width: 300px; display: inline-block;">{{.Diagnosis}}</small>
{{end}}
</td>
<td>
{{if .RunnerType}}
<span class="ui small label" style="background-color: {{if eq .RunnerType "linux"}}#21ba45{{else if eq .RunnerType "windows"}}#2185d0{{else if eq .RunnerType "macos"}}#a333c8{{else}}#767676{{end}}; color: white;">
{{.RunnerType}}
</span>
{{else}}
<span class="ui small grey label">any</span>
{{end}}
</td>
<td>
{{if .ProjectType}}
<span class="ui small teal label">{{.ProjectType}}</span>
{{if .Framework}}
<span class="ui small label">{{.Framework}}</span>
{{end}}
{{else}}
<span class="ui small grey label">any</span>
{{end}}
</td>
<td class="center aligned">
<span class="ui small label" style="background-color: #6435c9; color: white;">{{.OccurrenceCount}}</span>
</td>
<td class="center aligned">
<span class="ui small label" style="background-color: #21ba45; color: white;">{{.SuccessCount}}</span>
</td>
<td class="collapsing">
<a class="ui mini primary button" href="{{AppSubUrl}}/-/admin/ai-learning/{{.ID}}">
{{svg "octicon-pencil" 14}} {{ctx.Locale.Tr "edit"}}
</a>
<button class="ui mini red button link-action" type="button"
data-url="{{AppSubUrl}}/-/admin/ai-learning/{{.ID}}/delete"
data-modal-confirm="{{ctx.Locale.Tr "admin.ai_learning.delete_confirm"}}">
{{svg "octicon-trash" 14}}
</button>
</td>
</tr>
{{else}}
<tr>
<td colspan="7" class="center aligned">
<i>{{ctx.Locale.Tr "admin.ai_learning.no_patterns"}}</i>
</td>
</tr>
{{end}}
</tbody>
</table>
{{if .Patterns}}
<div class="ui divider"></div>
<button class="ui red button delete-selected" type="submit" disabled>
{{svg "octicon-trash" 14}} {{ctx.Locale.Tr "admin.ai_learning.delete_selected"}}
</button>
{{end}}
</form>
</div>
{{template "base/paginate" .}}
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
const selectAll = document.querySelector('.select-all');
const checkboxes = document.querySelectorAll('input[name="ids[]"]');
const deleteBtn = document.querySelector('.delete-selected');
if (selectAll) {
selectAll.addEventListener('change', function() {
checkboxes.forEach(cb => cb.checked = this.checked);
updateDeleteBtn();
});
}
checkboxes.forEach(cb => {
cb.addEventListener('change', updateDeleteBtn);
});
function updateDeleteBtn() {
if (deleteBtn) {
const checked = document.querySelectorAll('input[name="ids[]"]:checked');
deleteBtn.disabled = checked.length === 0;
}
}
});
</script>
{{template "admin/layout_footer" .}}

View File

@@ -0,0 +1,103 @@
{{template "admin/layout_head" .}}
<div class="admin ai-learning-edit">
<h4 class="ui top attached header">
{{ctx.Locale.Tr "admin.ai_learning.edit"}} - {{.Pattern.Pattern}}
</h4>
<div class="ui attached segment">
<form class="ui form" method="post" action="{{AppSubUrl}}/-/admin/ai-learning/{{.Pattern.ID}}">
{{.CsrfTokenHtml}}
<div class="two fields">
<div class="field">
<label>{{ctx.Locale.Tr "admin.ai_learning.pattern"}}</label>
<input type="text" name="pattern" value="{{.Pattern.Pattern}}" required>
<small>{{ctx.Locale.Tr "admin.ai_learning.pattern_help"}}</small>
</div>
<div class="field">
<label>{{ctx.Locale.Tr "admin.ai_learning.pattern_regex"}}</label>
<input type="text" name="pattern_regex" value="{{.Pattern.PatternRegex}}">
<small>{{ctx.Locale.Tr "admin.ai_learning.pattern_regex_help"}}</small>
</div>
</div>
<div class="three fields">
<div class="field">
<label>{{ctx.Locale.Tr "admin.ai_learning.runner_type"}}</label>
<select class="ui dropdown" name="runner_type">
<option value="" {{if eq .Pattern.RunnerType ""}}selected{{end}}>Any</option>
<option value="linux" {{if eq .Pattern.RunnerType "linux"}}selected{{end}}>Linux</option>
<option value="windows" {{if eq .Pattern.RunnerType "windows"}}selected{{end}}>Windows</option>
<option value="macos" {{if eq .Pattern.RunnerType "macos"}}selected{{end}}>macOS</option>
</select>
</div>
<div class="field">
<label>{{ctx.Locale.Tr "admin.ai_learning.project_type"}}</label>
<input type="text" name="project_type" value="{{.Pattern.ProjectType}}" placeholder="e.g., dotnet-maui, go, node">
</div>
<div class="field">
<label>{{ctx.Locale.Tr "admin.ai_learning.framework"}}</label>
<input type="text" name="framework" value="{{.Pattern.Framework}}" placeholder="e.g., net10.0-android">
</div>
</div>
<div class="field">
<label>{{ctx.Locale.Tr "admin.ai_learning.error_message"}}</label>
<textarea name="error_message" rows="3" placeholder="Full error message example">{{.Pattern.ErrorMessage}}</textarea>
</div>
<div class="field">
<label>{{ctx.Locale.Tr "admin.ai_learning.diagnosis"}}</label>
<textarea name="diagnosis" rows="3" placeholder="What this error means">{{.Pattern.Diagnosis}}</textarea>
</div>
<div class="field">
<label>{{ctx.Locale.Tr "admin.ai_learning.solution"}}</label>
<textarea name="solution" rows="4" placeholder="How to fix this error">{{.Pattern.Solution}}</textarea>
</div>
<div class="field">
<label>{{ctx.Locale.Tr "admin.ai_learning.solution_diff"}}</label>
<textarea name="solution_diff" rows="6" placeholder="Code/config changes that fix it">{{.Pattern.SolutionDiff}}</textarea>
</div>
<div class="ui segment secondary">
<div class="two fields">
<div class="field">
<label>{{ctx.Locale.Tr "admin.ai_learning.occurrences"}}</label>
<div class="ui small label" style="background-color: #6435c9; color: white;">{{.Pattern.OccurrenceCount}}</div>
</div>
<div class="field">
<label>{{ctx.Locale.Tr "admin.ai_learning.successes"}}</label>
<div class="ui small label" style="background-color: #21ba45; color: white;">{{.Pattern.SuccessCount}}</div>
</div>
</div>
<div class="two fields">
<div class="field">
<label>{{ctx.Locale.Tr "admin.ai_learning.created"}}</label>
<span>{{DateUtils.TimeSince .Pattern.CreatedUnix}}</span>
</div>
<div class="field">
<label>{{ctx.Locale.Tr "admin.ai_learning.updated"}}</label>
<span>{{DateUtils.TimeSince .Pattern.UpdatedUnix}}</span>
</div>
</div>
</div>
<div class="ui divider"></div>
<button class="ui primary button" type="submit">
{{svg "octicon-check" 14}} {{ctx.Locale.Tr "save"}}
</button>
<a class="ui button" href="{{AppSubUrl}}/-/admin/ai-learning">
{{ctx.Locale.Tr "cancel"}}
</a>
<button class="ui red button right floated link-action" type="button"
data-url="{{AppSubUrl}}/-/admin/ai-learning/{{.Pattern.ID}}/delete"
data-modal-confirm="{{ctx.Locale.Tr "admin.ai_learning.delete_confirm"}}">
{{svg "octicon-trash" 14}} {{ctx.Locale.Tr "delete"}}
</button>
</form>
</div>
</div>
{{template "admin/layout_footer" .}}

View File

@@ -5,6 +5,15 @@
<p>{{ctx.Locale.Tr "admin.dashboard.new_version_hint" .RemoteVersion AppVer "https://blog.gitea.com"}}</p>
</div>
{{end}}
<!-- Server Status Tiles (auto-refresh) -->
<div class="ui segment">
<div class="no-loading-indicator tw-hidden"></div>
<div hx-get="{{$.Link}}/server_stats" hx-swap="morph:innerHTML" hx-trigger="every 5s" hx-indicator=".no-loading-indicator">
{{template "admin/server_stats" .}}
</div>
</div>
<h4 class="ui top attached header">
{{ctx.Locale.Tr "admin.dashboard.maintenance_operations"}}
</h4>
@@ -74,7 +83,6 @@
<h4 class="ui top attached header">
{{ctx.Locale.Tr "admin.dashboard.system_status"}}
</h4>
{{/* TODO: make these stats work in multi-server deployments, likely needs per-server stats in DB */}}
<div class="ui attached table segment">
<div class="no-loading-indicator tw-hidden"></div>
<div hx-get="{{$.Link}}/system_status" hx-swap="morph:innerHTML" hx-trigger="every 5s" hx-indicator=".no-loading-indicator">

View File

@@ -112,5 +112,8 @@
</a>
</div>
</details>
<a class="{{if .PageIsAdminAILearning}}active {{end}}item" href="{{AppSubUrl}}/-/admin/ai-learning">
{{ctx.Locale.Tr "admin.ai_learning"}}
</a>
</div>
</div>

View File

@@ -0,0 +1,24 @@
{{if .ServerStats}}
<div style="display: flex; flex-direction: row; justify-content: space-between; align-items: stretch; gap: 20px;">
<div style="flex: 1; padding: 20px; background: var(--color-box-body); border: 1px solid var(--color-secondary); border-radius: 8px; text-align: center;">
<div style="font-size: 2em; font-weight: bold; margin-top: 13px; color: #21ba45;">{{svg "octicon-check-circle" 32}}</div>
<div style="font-size: 1.2em; font-weight: bold; margin-top: 8px;">{{.ServerStats.Status}}</div>
<div style="color: var(--color-text-light); margin-top: 5px; font-size: 0.9em;">Server Status</div>
</div>
<div style="flex: 1; padding: 20px; background: var(--color-box-body); border: 1px solid var(--color-secondary); border-radius: 8px; text-align: center;">
<div style="font-size: 2em; font-weight: bold; margin-top: 13px; color: #2185d0;">{{printf "%.2f" .ServerStats.CPULoad}}</div>
<div style="font-size: 1.2em; font-weight: bold; color: var(--color-text-light); margin-top: 10px;">{{.ServerStats.NumCPU}} cores, {{.ServerStats.NumGoroutine}} goroutines</div>
<div style="color: var(--color-text-light); margin-top: 8px; font-size: 0.9em;">CPU Load (1m avg)</div>
</div>
<div style="flex: 1; padding: 20px; background: var(--color-box-body); border: 1px solid var(--color-secondary); border-radius: 8px; text-align: center;">
<div style="font-size: 2em; font-weight: bold; margin-top: 13px; color: #6435c9;">{{printf "%.1f" .ServerStats.MemPercent}}%</div>
<div style="font-size: 1.2em; font-weight: bold; color: var(--color-text-light); margin-top: 10px;">{{FileSize .ServerStats.MemUsed}} / {{FileSize .ServerStats.MemTotal}}</div>
<div style="color: var(--color-text-light); margin-top: 8px; font-size: 0.9em;">Memory Usage</div>
</div>
<div style="flex: 1; padding: 20px; background: var(--color-box-body); border: 1px solid var(--color-secondary); border-radius: 8px; text-align: center;">
<div style="font-size: 2em; font-weight: bold; margin-top: 13px; color: #f2711c;">{{printf "%.1f" .ServerStats.DiskPercent}}%</div>
<div style="font-size: 1.2em; font-weight: bold; color: var(--color-text-light); margin-top: 10px;">{{FileSize .ServerStats.DiskFree}} free</div>
<div style="color: var(--color-text-light); margin-top: 8px; font-size: 0.9em;">Disk Usage</div>
</div>
</div>
{{end}}