Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 50 additions & 4 deletions internal/panel/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ type ViewData struct {
Flash string
FlashKind string // "ok" | "err" | ""
CurrentPage string
Locale string
LanguageURL string

// dashboard
RepoCount int
Expand All @@ -67,6 +69,8 @@ type ViewData struct {
ServerInfo ServerInfo
RecentLines []string
PayloadURL string // public /webhook URL for the guide; empty when accessed locally
Delivery DeliverySummary
Topology Topology

// repos
Repos []RepoRow
Expand Down Expand Up @@ -159,6 +163,8 @@ func New(opts Options) (*App, error) {
}

base := template.New("layout.html").Funcs(template.FuncMap{
"t": translate,
"percent": metricPercent,
"eq": func(a, b string) bool {
a = strings.TrimSpace(a)
b = strings.TrimSpace(b)
Expand Down Expand Up @@ -189,6 +195,7 @@ func New(opts Options) (*App, error) {
for _, page := range []string{
"login",
"dashboard",
"topology",
"repos",
"repo_edit",
"bots",
Expand Down Expand Up @@ -261,8 +268,10 @@ func (a *App) routes() http.Handler {

mux.HandleFunc("/login", a.handleLogin)
mux.HandleFunc("/logout", a.handleLogout)
mux.HandleFunc("/locale", a.handleLocale)

mux.HandleFunc("/", a.requireAuth(a.handleDashboard))
mux.HandleFunc("/topology", a.requireAuth(a.handleTopology))
mux.HandleFunc("/repos", a.requireAuth(a.handleRepos))
mux.HandleFunc("/repos/new", a.requireAuth(a.handleRepoNew))
mux.HandleFunc("/repos/edit", a.requireAuth(a.handleRepoEdit))
Expand Down Expand Up @@ -310,14 +319,30 @@ func (a *App) renderPage(w http.ResponseWriter, page string, data ViewData) {
// baseData returns a ViewData pre-populated with auth + flash from the request.
func (a *App) baseData(r *http.Request) ViewData {
q := r.URL.Query()
locale := localeFrom(r)
return ViewData{
Authed: true,
Username: usernameFrom(r),
Flash: q.Get("flash"),
FlashKind: q.Get("kind"),
Authed: true,
Username: usernameFrom(r),
Locale: locale,
LanguageURL: "/locale?lang=" + alternateLocale(locale) + "&next=" + urlQueryEscape(r.URL.RequestURI()),
Flash: q.Get("flash"),
FlashKind: q.Get("kind"),
}
}

func (a *App) handleLocale(w http.ResponseWriter, r *http.Request) {
locale := r.URL.Query().Get("lang")
if !validLocale(locale) {
locale = "zh-CN"
}
http.SetCookie(w, &http.Cookie{Name: localeCookie, Value: locale, Path: "/", MaxAge: 365 * 24 * 60 * 60, SameSite: http.SameSiteLaxMode})
next := r.URL.Query().Get("next")
if !strings.HasPrefix(next, "/") || strings.HasPrefix(next, "//") {
next = "/"
}
http.Redirect(w, r, next, http.StatusSeeOther)
}

// redirectFlash redirects to dest with a flash message rendered on arrival.
func (a *App) redirectFlash(w http.ResponseWriter, r *http.Request, dest, flash, kind string) {
u := dest
Expand Down Expand Up @@ -390,3 +415,24 @@ func readRecentLogLines(logDir string, n int) []string {
}
return kept
}

func readDashboardLogLines(logDir string) []string {
entries, err := os.ReadDir(logDir)
if err != nil {
return nil
}
var newest os.DirEntry
for _, entry := range entries {
if !entry.IsDir() && (newest == nil || entry.Name() > newest.Name()) {
newest = entry
}
}
if newest == nil {
return nil
}
data, err := os.ReadFile(filepath.Join(logDir, newest.Name()))
if err != nil {
return nil
}
return strings.Split(strings.TrimSpace(string(data)), "\n")
}
142 changes: 142 additions & 0 deletions internal/panel/dashboard_metrics.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
package panel

import (
"regexp"
"strings"
"time"
)

var (
logTimestamp = regexp.MustCompile(`^(\d{4}/\d{2}/\d{2} \d{2}:\d{2}:\d{2})`)
matchedEvent = regexp.MustCompile(`Event matched: ([^,\s]+)`)
sentTarget = regexp.MustCompile(`Successfully sent notification to (.+)$`)
failedTarget = regexp.MustCompile(`Failed to send notification to (.+?)(?::|$)`)
)

// DeliverySummary is deliberately payload-free. It is derived from normal
// application logs and is only used to make the operational dashboard useful.
type DeliverySummary struct {
Total int
Failed int
SuccessRate int
MaxDaily int
Days []DeliveryDay
Events []MetricItem
Recent []DeliveryRow
}

type DeliveryDay struct {
Label string
Success int
Failed int
}

type MetricItem struct {
Label string
Count int
}

type DeliveryRow struct {
Time string
Target string
Success bool
}

func summarizeDeliveries(lines []string, now time.Time) DeliverySummary {
start := now.AddDate(0, 0, -6)
days := make([]DeliveryDay, 7)
dayIndex := make(map[string]int, len(days))
for i := range days {
day := start.AddDate(0, 0, i)
days[i].Label = day.Format("01/02")
dayIndex[day.Format("2006-01-02")] = i
}

summary := DeliverySummary{Days: days}
events := map[string]int{}
for _, line := range lines {
if match := matchedEvent.FindStringSubmatch(line); len(match) == 2 {
events[match[1]]++
}

ok, target := deliveryFromLine(line)
if target == "" {
continue
}
ts, parsed := parseLogTime(line)
if !parsed {
continue
}
idx, exists := dayIndex[ts.Format("2006-01-02")]
if !exists {
continue
}
if ok {
summary.Days[idx].Success++
} else {
summary.Days[idx].Failed++
}
if count := summary.Days[idx].Success + summary.Days[idx].Failed; count > summary.MaxDaily {
summary.MaxDaily = count
}
summary.Total++
if !ok {
summary.Failed++
}
summary.Recent = append(summary.Recent, DeliveryRow{
Time: ts.Format("01/02 15:04"), Target: target, Success: ok,
})
}
if summary.Total > 0 {
summary.SuccessRate = (summary.Total - summary.Failed) * 100 / summary.Total
}
for label, count := range events {
summary.Events = append(summary.Events, MetricItem{Label: label, Count: count})
}
summary.Events = sortedMetricItems(summary.Events)
if len(summary.Recent) > 8 {
summary.Recent = summary.Recent[len(summary.Recent)-8:]
}
for left, right := 0, len(summary.Recent)-1; left < right; left, right = left+1, right-1 {
summary.Recent[left], summary.Recent[right] = summary.Recent[right], summary.Recent[left]
}
return summary
}

func metricPercent(value, total int) int {
if total <= 0 || value <= 0 {
return 0
}
return value * 100 / total
}

func deliveryFromLine(line string) (bool, string) {
if match := sentTarget.FindStringSubmatch(line); len(match) == 2 {
return true, strings.TrimSpace(match[1])
}
if match := failedTarget.FindStringSubmatch(line); len(match) == 2 {
return false, strings.TrimSpace(match[1])
}
return false, ""
}

func parseLogTime(line string) (time.Time, bool) {
match := logTimestamp.FindStringSubmatch(line)
if len(match) != 2 {
return time.Time{}, false
}
ts, err := time.ParseInLocation("2006/01/02 15:04:05", match[1], time.Local)
return ts, err == nil
}

func sortedMetricItems(items []MetricItem) []MetricItem {
for i := 1; i < len(items); i++ {
for j := i; j > 0 && (items[j].Count > items[j-1].Count || (items[j].Count == items[j-1].Count && items[j].Label < items[j-1].Label)); j-- {
items[j], items[j-1] = items[j-1], items[j]
}
}
if len(items) > 6 {
return items[:6]
}
return items
}
85 changes: 85 additions & 0 deletions internal/panel/dashboard_metrics_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
package panel

import (
"net/http/httptest"
"regexp"
"testing"
"time"
)

func TestSummarizeDeliveries(t *testing.T) {
now := time.Date(2026, 7, 24, 12, 0, 0, 0, time.Local)
lines := []string{
"2026/07/24 09:00:00 [INFO] Event matched: push, sending notification",
"2026/07/24 09:00:01 [INFO] Successfully sent notification to dev-team",
"2026/07/24 10:00:00 [INFO] Event matched: issues, sending notification",
"2026/07/24 10:00:01 [ERROR] Failed to send notification to ops-team: unavailable",
"2026/07/17 10:00:01 [INFO] Successfully sent notification to outside-window",
}

got := summarizeDeliveries(lines, now)
if got.Total != 2 || got.Failed != 1 || got.SuccessRate != 50 {
t.Fatalf("unexpected summary: %#v", got)
}
if len(got.Recent) != 2 || got.Recent[0].Target != "ops-team" || got.Recent[0].Success {
t.Fatalf("unexpected recent deliveries: %#v", got.Recent)
}
if len(got.Events) != 2 || got.Events[0].Count != 1 {
t.Fatalf("unexpected events: %#v", got.Events)
}
}

func TestLocaleFromAndTranslate(t *testing.T) {
r := httptest.NewRequest("GET", "/", nil)
r.Header.Set("Accept-Language", "en-GB,en;q=0.9")
if got := localeFrom(r); got != "en-US" {
t.Fatalf("localeFrom() = %q, want en-US", got)
}
if got := translate(ViewData{Locale: "en-US"}, "nav.overview"); got != "Overview" {
t.Fatalf("translate() = %q", got)
}
if got := translate(ViewData{Locale: "invalid"}, "nav.overview"); got != "概览" {
t.Fatalf("fallback = %q", got)
}
}

func TestLocaleCatalogsHaveMatchingKeys(t *testing.T) {
for key := range messages["zh-CN"] {
if messages["en-US"][key] == "" {
t.Errorf("en-US is missing key %q", key)
}
}
for key := range messages["en-US"] {
if messages["zh-CN"][key] == "" {
t.Errorf("zh-CN is missing key %q", key)
}
}
}

func TestTemplateLocaleKeysExistAndHaveNoMixedLanguageCopy(t *testing.T) {
keyPattern := regexp.MustCompile(`\{\{t\s+[$.]\s+"([^"]+)"`)
mixedCopy := regexp.MustCompile(`[\p{Han}]\s*/|/\s*[\p{Han}]`)
entries, err := templatesFS.ReadDir("templates")
if err != nil {
t.Fatal(err)
}
for _, entry := range entries {
if entry.IsDir() {
continue
}
data, err := templatesFS.ReadFile("templates/" + entry.Name())
if err != nil {
t.Fatal(err)
}
for _, match := range keyPattern.FindAllStringSubmatch(string(data), -1) {
for locale, catalog := range messages {
if catalog[match[1]] == "" {
t.Errorf("%s references %q, missing in %s", entry.Name(), match[1], locale)
}
}
}
if mixedCopy.Match(data) {
t.Errorf("%s still contains mixed-language UI copy", entry.Name())
}
}
}
6 changes: 3 additions & 3 deletions internal/panel/handlers_auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,18 +25,18 @@ func (a *App) handleLogin(w http.ResponseWriter, r *http.Request) {

func (a *App) handleLoginPost(w http.ResponseWriter, r *http.Request) {
if !a.Enabled() {
a.redirectFlash(w, r, "/login", "面板未配置管理员密码 / panel login not configured", "err")
a.redirectFlash(w, r, "/login", a.message(r, "flash.panelLoginDisabled"), "err")
return
}
if err := r.ParseForm(); err != nil {
a.redirectFlash(w, r, "/login", "表单解析失败 / invalid form", "err")
a.redirectFlash(w, r, "/login", a.message(r, "flash.invalidForm"), "err")
return
}
wantUser, passHash := a.credentials()
username := r.FormValue("username")
password := r.FormValue("password")
if username != wantUser || !auth.VerifyPassword(string(passHash), password) {
a.redirectFlash(w, r, "/login", "用户名或密码错误 / invalid username or password", "err")
a.redirectFlash(w, r, "/login", a.message(r, "flash.invalidCredentials"), "err")
return
}

Expand Down
Loading
Loading