diff --git a/.vscode/settings.json b/.vscode/settings.json index b7a6986..da3f9e8 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -11,6 +11,8 @@ "codz", "color", "concl", + "Cpath", + "Csvg", "cython", "delaycompress", "demilestoned", @@ -85,6 +87,7 @@ "rerequested", "resp", "ropeproject", + "rotr", "rtype", "rurl", "scrapy", @@ -102,6 +105,7 @@ "tmpl", "tname", "tokk", + "topbar", "tval", "tzdata", "unlabeled", diff --git a/example-configs/templates.cn.jsonc b/example-configs/templates.cn.jsonc index 05e771d..58b951c 100644 --- a/example-configs/templates.cn.jsonc +++ b/example-configs/templates.cn.jsonc @@ -3686,6 +3686,190 @@ ] } } + }, + { + "tags": [ + "pull_request" + ], + "payload": { + "msg_type": "interactive", + "card": { + "config": { + "wide_screen_mode": true + }, + "header": { + "title": { + "tag": "plain_text", + "content": "💬 PR 评论:#{{issue.number}}" + }, + "template": "orange" + }, + "elements": [ + { + "tag": "div", + "text": { + "tag": "lark_md", + "content": "**作者:** {{sender_link_md | default(sender.login)}}\n**Pull Request:** {{issue_link_md | default(issue.html_url | default(''))}}\n**评论:** {{comment.body}}\n" + } + }, + { + "tag": "action", + "actions": [ + { + "tag": "button", + "text": { + "tag": "plain_text", + "content": "查看评论" + }, + "url": "{{comment.html_url}}", + "type": "primary" + } + ] + } + ] + } + } + }, + { + "tags": [ + "created", + "pull_request" + ], + "payload": { + "msg_type": "interactive", + "card": { + "config": { + "wide_screen_mode": true + }, + "header": { + "title": { + "tag": "plain_text", + "content": "🔔 PR 评论 已创建" + }, + "template": "green" + }, + "elements": [ + { + "tag": "div", + "text": { + "tag": "lark_md", + "content": "**仓库:** {{repository_link_md}}\n**操作:** {{action}}\n**用户:** {{sender_link_md}}" + } + }, + { + "tag": "hr" + }, + { + "tag": "action", + "actions": [ + { + "tag": "button", + "text": { + "tag": "plain_text", + "content": "查看详情" + }, + "url": "{{repository.html_url}}", + "type": "default" + } + ] + } + ] + } + } + }, + { + "tags": [ + "deleted", + "pull_request" + ], + "payload": { + "msg_type": "interactive", + "card": { + "config": { + "wide_screen_mode": true + }, + "header": { + "title": { + "tag": "plain_text", + "content": "🔔 PR 评论 已删除" + }, + "template": "red" + }, + "elements": [ + { + "tag": "div", + "text": { + "tag": "lark_md", + "content": "**仓库:** {{repository_link_md}}\n**操作:** {{action}}\n**用户:** {{sender_link_md}}" + } + }, + { + "tag": "hr" + }, + { + "tag": "action", + "actions": [ + { + "tag": "button", + "text": { + "tag": "plain_text", + "content": "查看详情" + }, + "url": "{{repository.html_url}}", + "type": "default" + } + ] + } + ] + } + } + }, + { + "tags": [ + "edited", + "pull_request" + ], + "payload": { + "msg_type": "interactive", + "card": { + "config": { + "wide_screen_mode": true + }, + "header": { + "title": { + "tag": "plain_text", + "content": "🔔 PR 评论 已编辑" + }, + "template": "blue" + }, + "elements": [ + { + "tag": "div", + "text": { + "tag": "lark_md", + "content": "**仓库:** {{repository_link_md}}\n**操作:** {{action}}\n**用户:** {{sender_link_md}}" + } + }, + { + "tag": "hr" + }, + { + "tag": "action", + "actions": [ + { + "tag": "button", + "text": { + "tag": "plain_text", + "content": "查看详情" + }, + "url": "{{repository.html_url}}", + "type": "default" + } + ] + } + ] + } + } } ] }, diff --git a/example-configs/templates.jsonc b/example-configs/templates.jsonc index 912dc16..ba04c8e 100644 --- a/example-configs/templates.jsonc +++ b/example-configs/templates.jsonc @@ -3686,6 +3686,190 @@ ] } } + }, + { + "tags": [ + "pull_request" + ], + "payload": { + "msg_type": "interactive", + "card": { + "config": { + "wide_screen_mode": true + }, + "header": { + "title": { + "tag": "plain_text", + "content": "💬 PR Comment: #{{issue.number}}" + }, + "template": "orange" + }, + "elements": [ + { + "tag": "div", + "text": { + "tag": "lark_md", + "content": "**Author:** {{sender_link_md | default(sender.login)}}\n**Pull Request:** {{issue_link_md | default(issue.html_url | default(''))}}\n**Comment:** {{comment.body}}\n" + } + }, + { + "tag": "action", + "actions": [ + { + "tag": "button", + "text": { + "tag": "plain_text", + "content": "View Comment" + }, + "url": "{{comment.html_url}}", + "type": "primary" + } + ] + } + ] + } + } + }, + { + "tags": [ + "created", + "pull_request" + ], + "payload": { + "msg_type": "interactive", + "card": { + "config": { + "wide_screen_mode": true + }, + "header": { + "title": { + "tag": "plain_text", + "content": "🔔 PR Comment Created" + }, + "template": "green" + }, + "elements": [ + { + "tag": "div", + "text": { + "tag": "lark_md", + "content": "**Repository:** {{repository_link_md}}\n**Action:** {{action}}\n**User:** {{sender_link_md}}" + } + }, + { + "tag": "hr" + }, + { + "tag": "action", + "actions": [ + { + "tag": "button", + "text": { + "tag": "plain_text", + "content": "View Details" + }, + "url": "{{repository.html_url}}", + "type": "default" + } + ] + } + ] + } + } + }, + { + "tags": [ + "deleted", + "pull_request" + ], + "payload": { + "msg_type": "interactive", + "card": { + "config": { + "wide_screen_mode": true + }, + "header": { + "title": { + "tag": "plain_text", + "content": "🔔 PR Comment Deleted" + }, + "template": "red" + }, + "elements": [ + { + "tag": "div", + "text": { + "tag": "lark_md", + "content": "**Repository:** {{repository_link_md}}\n**Action:** {{action}}\n**User:** {{sender_link_md}}" + } + }, + { + "tag": "hr" + }, + { + "tag": "action", + "actions": [ + { + "tag": "button", + "text": { + "tag": "plain_text", + "content": "View Details" + }, + "url": "{{repository.html_url}}", + "type": "default" + } + ] + } + ] + } + } + }, + { + "tags": [ + "edited", + "pull_request" + ], + "payload": { + "msg_type": "interactive", + "card": { + "config": { + "wide_screen_mode": true + }, + "header": { + "title": { + "tag": "plain_text", + "content": "🔔 PR Comment Edited" + }, + "template": "blue" + }, + "elements": [ + { + "tag": "div", + "text": { + "tag": "lark_md", + "content": "**Repository:** {{repository_link_md}}\n**Action:** {{action}}\n**User:** {{sender_link_md}}" + } + }, + { + "tag": "hr" + }, + { + "tag": "action", + "actions": [ + { + "tag": "button", + "text": { + "tag": "plain_text", + "content": "View Details" + }, + "url": "{{repository.html_url}}", + "type": "default" + } + ] + } + ] + } + } } ] }, diff --git a/internal/handler/handler_pull_request_review_comment.go b/internal/handler/handler_pull_request_review_comment.go index 79e2c41..3931a57 100644 --- a/internal/handler/handler_pull_request_review_comment.go +++ b/internal/handler/handler_pull_request_review_comment.go @@ -5,6 +5,7 @@ func preparePullRequestReviewCommentData(data map[string]any, payload map[string if comment, ok := payload["comment"].(map[string]any); ok { data["comment_body"] = comment["body"] data["comment_url"] = comment["html_url"] + data["comment"] = comment if user, ok := comment["user"].(map[string]any); ok { if login, ok2 := user["login"].(string); ok2 { if url, ok3 := user["html_url"].(string); ok3 { @@ -17,5 +18,6 @@ func preparePullRequestReviewCommentData(data map[string]any, payload map[string data["pr_number"] = pr["number"] data["pr_title"] = pr["title"] data["pr_url"] = pr["html_url"] + data["pull_request"] = pr } } diff --git a/internal/panel/app.go b/internal/panel/app.go index 9e0df3d..89e6b74 100644 --- a/internal/panel/app.go +++ b/internal/panel/app.go @@ -58,6 +58,8 @@ type ViewData struct { Flash string FlashKind string // "ok" | "err" | "" CurrentPage string + Locale string + LanguageURL string // dashboard RepoCount int @@ -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 @@ -159,6 +163,14 @@ func New(opts Options) (*App, error) { } base := template.New("layout.html").Funcs(template.FuncMap{ + "t": translate, + // th returns a trusted static locale string as HTML (unescaped), for the + // few values that embed markup (e.g. ). Locale strings are app- + // shipped constants, never user input. + "th": func(data ViewData, key string) template.HTML { + return template.HTML(translate(data, key)) + }, + "percent": metricPercent, "eq": func(a, b string) bool { a = strings.TrimSpace(a) b = strings.TrimSpace(b) @@ -189,6 +201,7 @@ func New(opts Options) (*App, error) { for _, page := range []string{ "login", "dashboard", + "topology", "repos", "repo_edit", "bots", @@ -261,8 +274,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)) @@ -310,12 +325,28 @@ 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. @@ -390,3 +421,27 @@ func readRecentLogLines(logDir string, n int) []string { } return kept } + +// readDashboardLogLines reads ALL .log files in logDir (the logger creates one +// file per day: feishu-github-tracker-YYYY-MM-DD.log) and returns every line. +// summarizeDeliveries then distributes entries across the 7-day chart based on +// each line's timestamp — reading only the newest file would put everything on +// today's bar. +func readDashboardLogLines(logDir string) []string { + entries, err := os.ReadDir(logDir) + if err != nil { + return nil + } + var lines []string + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".log") { + continue + } + data, err := os.ReadFile(filepath.Join(logDir, entry.Name())) + if err != nil { + continue + } + lines = append(lines, strings.Split(strings.TrimSpace(string(data)), "\n")...) + } + return lines +} diff --git a/internal/panel/dashboard_metrics.go b/internal/panel/dashboard_metrics.go new file mode 100644 index 0000000..fa04881 --- /dev/null +++ b/internal/panel/dashboard_metrics.go @@ -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 +} diff --git a/internal/panel/dashboard_metrics_test.go b/internal/panel/dashboard_metrics_test.go new file mode 100644 index 0000000..31ac077 --- /dev/null +++ b/internal/panel/dashboard_metrics_test.go @@ -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()) + } + } +} diff --git a/internal/panel/handlers_auth.go b/internal/panel/handlers_auth.go index 4b2c143..f998c62 100644 --- a/internal/panel/handlers_auth.go +++ b/internal/panel/handlers_auth.go @@ -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 } diff --git a/internal/panel/handlers_bots.go b/internal/panel/handlers_bots.go index 8761736..5d9cfba 100644 --- a/internal/panel/handlers_bots.go +++ b/internal/panel/handlers_bots.go @@ -36,12 +36,12 @@ func (a *App) handleBotEdit(w http.ResponseWriter, r *http.Request) { idx, _ := strconv.Atoi(r.URL.Query().Get("index")) cfg, err := a.loadConfig() if err != nil { - a.redirectFlash(w, r, "/bots", "读取配置失败 / failed to load config", "err") + a.redirectFlash(w, r, "/bots", a.message(r, "flash.configLoadFailed"), "err") return } data.Templates = a.knownTemplates(cfg) if idx < 0 || idx >= len(cfg.FeishuBots.FeishuBots) { - a.redirectFlash(w, r, "/bots", "机器人不存在 / bot not found", "err") + a.redirectFlash(w, r, "/bots", a.message(r, "flash.botNotFound"), "err") return } b := cfg.FeishuBots.FeishuBots[idx] @@ -52,7 +52,7 @@ func (a *App) handleBotEdit(w http.ResponseWriter, r *http.Request) { // handleBotSave creates or updates a bot. func (a *App) handleBotSave(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { - a.redirectFlash(w, r, "/bots", "表单解析失败 / invalid form", "err") + a.redirectFlash(w, r, "/bots", a.message(r, "flash.invalidForm"), "err") return } idx, _ := strconv.Atoi(r.FormValue("index")) @@ -60,13 +60,13 @@ func (a *App) handleBotSave(w http.ResponseWriter, r *http.Request) { url := strings.TrimSpace(r.FormValue("url")) tmpl := strings.TrimSpace(r.FormValue("template")) if alias == "" || url == "" { - a.redirectFlash(w, r, "/bots", "alias 和 url 不能为空 / alias and url required", "err") + a.redirectFlash(w, r, "/bots", a.message(r, "flash.botFieldsRequired"), "err") return } cfg, err := a.loadConfig() if err != nil { - a.redirectFlash(w, r, "/bots", "读取配置失败 / failed to load config", "err") + a.redirectFlash(w, r, "/bots", a.message(r, "flash.configLoadFailed"), "err") return } @@ -78,34 +78,34 @@ func (a *App) handleBotSave(w http.ResponseWriter, r *http.Request) { } if err := SaveYAML(a.cfgDir+"/feishu-bots.yaml", cfg.FeishuBots); err != nil { - a.redirectFlash(w, r, "/bots", "保存失败: "+err.Error(), "err") + a.redirectFlash(w, r, "/bots", a.message(r, "flash.saveFailed", err), "err") return } a.notifySaved() - a.redirectFlash(w, r, "/bots", "机器人已保存 / bot saved", "ok") + a.redirectFlash(w, r, "/bots", a.message(r, "flash.botSaved"), "ok") } // handleBotDelete removes a bot by index. func (a *App) handleBotDelete(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { - a.redirectFlash(w, r, "/bots", "表单解析失败 / invalid form", "err") + a.redirectFlash(w, r, "/bots", a.message(r, "flash.invalidForm"), "err") return } idx, _ := strconv.Atoi(r.FormValue("index")) cfg, err := a.loadConfig() if err != nil { - a.redirectFlash(w, r, "/bots", "读取配置失败 / failed to load config", "err") + a.redirectFlash(w, r, "/bots", a.message(r, "flash.configLoadFailed"), "err") return } if idx < 0 || idx >= len(cfg.FeishuBots.FeishuBots) { - a.redirectFlash(w, r, "/bots", "机器人不存在 / bot not found", "err") + a.redirectFlash(w, r, "/bots", a.message(r, "flash.botNotFound"), "err") return } cfg.FeishuBots.FeishuBots = append(cfg.FeishuBots.FeishuBots[:idx], cfg.FeishuBots.FeishuBots[idx+1:]...) if err := SaveYAML(a.cfgDir+"/feishu-bots.yaml", cfg.FeishuBots); err != nil { - a.redirectFlash(w, r, "/bots", "保存失败: "+err.Error(), "err") + a.redirectFlash(w, r, "/bots", a.message(r, "flash.saveFailed", err), "err") return } a.notifySaved() - a.redirectFlash(w, r, "/bots", "机器人已删除 / bot deleted", "ok") + a.redirectFlash(w, r, "/bots", a.message(r, "flash.botDeleted"), "ok") } diff --git a/internal/panel/handlers_dashboard.go b/internal/panel/handlers_dashboard.go index b16ae98..3418406 100644 --- a/internal/panel/handlers_dashboard.go +++ b/internal/panel/handlers_dashboard.go @@ -5,6 +5,7 @@ import ( "net/http" "net/url" "strings" + "time" "github.com/hnrobert/feishu-github-tracker/internal/config" ) @@ -32,11 +33,19 @@ func (a *App) handleDashboard(w http.ResponseWriter, r *http.Request) { publicURL = cfg.Server.Panel.PublicURL } data.PayloadURL = payloadURLFor(r, publicURL) - data.RecentLines = readRecentLogLines(a.logDir, 20) + data.Topology = topologyFromConfig(cfg) + data.Delivery = summarizeDeliveries(readDashboardLogLines(a.logDir), time.Now()) a.renderPage(w, "dashboard", data) } +func (a *App) handleTopology(w http.ResponseWriter, r *http.Request) { + data := a.baseData(r) + cfg, _ := a.loadConfig() + data.Topology = topologyFromConfig(cfg) + a.renderPage(w, "topology", data) +} + // payloadURLFor returns the /webhook URL to show in the setup guide. // // - If publicURL (panel.public_url) is set, it is used verbatim (always shown, diff --git a/internal/panel/handlers_events.go b/internal/panel/handlers_events.go index 03facbf..d761754 100644 --- a/internal/panel/handlers_events.go +++ b/internal/panel/handlers_events.go @@ -22,21 +22,21 @@ func (a *App) handleEvents(w http.ResponseWriter, r *http.Request) { // valid, writes the raw text back verbatim. func (a *App) handleEventsSave(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { - a.redirectFlash(w, r, "/events", "表单解析失败 / invalid form", "err") + a.redirectFlash(w, r, "/events", a.message(r, "flash.invalidForm"), "err") return } text := r.FormValue("events_yaml") var check config.EventsConfig if err := yaml.Unmarshal([]byte(text), &check); err != nil { - a.redirectFlash(w, r, "/events", "events.yaml 解析失败: "+err.Error(), "err") + a.redirectFlash(w, r, "/events", a.message(r, "flash.eventsParseFailed", err), "err") return } if err := os.WriteFile(a.cfgDir+"/events.yaml", []byte(text), 0o644); err != nil { - a.redirectFlash(w, r, "/events", "保存失败: "+err.Error(), "err") + a.redirectFlash(w, r, "/events", a.message(r, "flash.saveFailed", err), "err") return } a.notifySaved() - a.redirectFlash(w, r, "/events", "事件配置已保存 / events saved", "ok") + a.redirectFlash(w, r, "/events", a.message(r, "flash.eventsSaved"), "ok") } diff --git a/internal/panel/handlers_repos.go b/internal/panel/handlers_repos.go index d596bc9..f1487f0 100644 --- a/internal/panel/handlers_repos.go +++ b/internal/panel/handlers_repos.go @@ -33,11 +33,11 @@ func (a *App) handleRepoEdit(w http.ResponseWriter, r *http.Request) { idx, _ := strconv.Atoi(r.URL.Query().Get("index")) cfg, err := a.loadConfig() if err != nil { - a.redirectFlash(w, r, "/repos", "读取配置失败 / failed to load config", "err") + a.redirectFlash(w, r, "/repos", a.message(r, "flash.configLoadFailed"), "err") return } if idx < 0 || idx >= len(cfg.Repos.Repos) { - a.redirectFlash(w, r, "/repos", "仓库不存在 / repo not found", "err") + a.redirectFlash(w, r, "/repos", a.message(r, "flash.repoNotFound"), "err") return } data.EditRepo = repoEditRow(idx, cfg.Repos.Repos[idx]) @@ -47,19 +47,19 @@ func (a *App) handleRepoEdit(w http.ResponseWriter, r *http.Request) { // handleRepoSave creates or updates a repo rule. func (a *App) handleRepoSave(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { - a.redirectFlash(w, r, "/repos", "表单解析失败 / invalid form", "err") + a.redirectFlash(w, r, "/repos", a.message(r, "flash.invalidForm"), "err") return } idx, _ := strconv.Atoi(r.FormValue("index")) pattern := strings.TrimSpace(r.FormValue("pattern")) if pattern == "" { - a.redirectFlash(w, r, "/repos", "pattern 不能为空 / pattern must not be empty", "err") + a.redirectFlash(w, r, "/repos", a.message(r, "flash.patternRequired"), "err") return } events, err := parseEventsYAML(r.FormValue("events")) if err != nil { - a.redirectFlash(w, r, "/repos", "events YAML 解析失败: "+err.Error(), "err") + a.redirectFlash(w, r, "/repos", a.message(r, "flash.eventsParseFailed", err), "err") return } notifyTo := splitLines(r.FormValue("notify_to")) @@ -67,7 +67,7 @@ func (a *App) handleRepoSave(w http.ResponseWriter, r *http.Request) { cfg, err := a.loadConfig() if err != nil { - a.redirectFlash(w, r, "/repos", "读取配置失败 / failed to load config", "err") + a.redirectFlash(w, r, "/repos", a.message(r, "flash.configLoadFailed"), "err") return } @@ -79,36 +79,36 @@ func (a *App) handleRepoSave(w http.ResponseWriter, r *http.Request) { } if err := SaveYAML(a.cfgDir+"/repos.yaml", cfg.Repos); err != nil { - a.redirectFlash(w, r, "/repos", "保存失败: "+err.Error(), "err") + a.redirectFlash(w, r, "/repos", a.message(r, "flash.saveFailed", err), "err") return } a.notifySaved() - a.redirectFlash(w, r, "/repos", "仓库规则已保存 / repo rule saved", "ok") + a.redirectFlash(w, r, "/repos", a.message(r, "flash.repoSaved"), "ok") } // handleRepoDelete removes a repo rule by index. func (a *App) handleRepoDelete(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { - a.redirectFlash(w, r, "/repos", "表单解析失败 / invalid form", "err") + a.redirectFlash(w, r, "/repos", a.message(r, "flash.invalidForm"), "err") return } idx, _ := strconv.Atoi(r.FormValue("index")) cfg, err := a.loadConfig() if err != nil { - a.redirectFlash(w, r, "/repos", "读取配置失败 / failed to load config", "err") + a.redirectFlash(w, r, "/repos", a.message(r, "flash.configLoadFailed"), "err") return } if idx < 0 || idx >= len(cfg.Repos.Repos) { - a.redirectFlash(w, r, "/repos", "仓库不存在 / repo not found", "err") + a.redirectFlash(w, r, "/repos", a.message(r, "flash.repoNotFound"), "err") return } cfg.Repos.Repos = append(cfg.Repos.Repos[:idx], cfg.Repos.Repos[idx+1:]...) if err := SaveYAML(a.cfgDir+"/repos.yaml", cfg.Repos); err != nil { - a.redirectFlash(w, r, "/repos", "保存失败: "+err.Error(), "err") + a.redirectFlash(w, r, "/repos", a.message(r, "flash.saveFailed", err), "err") return } a.notifySaved() - a.redirectFlash(w, r, "/repos", "仓库规则已删除 / repo rule deleted", "ok") + a.redirectFlash(w, r, "/repos", a.message(r, "flash.repoDeleted"), "ok") } // repoListRow builds a RepoRow for list display. diff --git a/internal/panel/handlers_settings.go b/internal/panel/handlers_settings.go index 0ace4db..87f5269 100644 --- a/internal/panel/handlers_settings.go +++ b/internal/panel/handlers_settings.go @@ -38,7 +38,7 @@ func (a *App) handleSettings(w http.ResponseWriter, r *http.Request) { // changes take effect immediately. Port/secret still require a restart. func (a *App) handleSettingsSave(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { - a.redirectFlash(w, r, "/settings", "表单解析失败 / invalid form", "err") + a.redirectFlash(w, r, "/settings", a.message(r, "flash.invalidForm"), "err") return } @@ -62,11 +62,11 @@ func (a *App) handleSettingsSave(w http.ResponseWriter, r *http.Request) { passwordChanged := newPassword != "" if passwordChanged { if newPassword != confirmPassword { - a.redirectFlash(w, r, "/settings", "两次输入的新密码不一致 / new passwords do not match", "err") + a.redirectFlash(w, r, "/settings", a.message(r, "flash.passwordMismatch"), "err") return } if !auth.VerifyPassword(string(currentHash), oldPassword) { - a.redirectFlash(w, r, "/settings", "旧密码错误,密码未修改 / incorrect old password", "err") + a.redirectFlash(w, r, "/settings", a.message(r, "flash.incorrectOldPassword"), "err") return } } @@ -74,7 +74,7 @@ func (a *App) handleSettingsSave(w http.ResponseWriter, r *http.Request) { // 1. Persist server.* fields (+ panel.username) in one comment-preserving write. root, err := loadServerRoot(a.cfgDir) if err != nil { - a.redirectFlash(w, r, "/settings", "读取配置失败 / failed to load config", "err") + a.redirectFlash(w, r, "/settings", a.message(r, "flash.configLoadFailed"), "err") return } serverMap := ensureMap(root, "server") @@ -94,7 +94,7 @@ func (a *App) handleSettingsSave(w http.ResponseWriter, r *http.Request) { mapSet(ensureMap(root, "panel"), "username", newUsername) } if err := writeServerRoot(a.cfgDir, root); err != nil { - a.redirectFlash(w, r, "/settings", "保存失败: "+err.Error(), "err") + a.redirectFlash(w, r, "/settings", a.message(r, "flash.saveFailed", err), "err") return } @@ -102,7 +102,7 @@ func (a *App) handleSettingsSave(w http.ResponseWriter, r *http.Request) { // password_hash produced by the browser, so store it directly. if passwordChanged { if err := SetPanelPasswordHash(a.cfgDir, newPassword); err != nil { - a.redirectFlash(w, r, "/settings", "密码保存失败: "+err.Error(), "err") + a.redirectFlash(w, r, "/settings", a.message(r, "flash.passwordSaveFailed", err), "err") return } } @@ -110,14 +110,14 @@ func (a *App) handleSettingsSave(w http.ResponseWriter, r *http.Request) { // 3. Reload so changes take effect immediately. a.notifySaved() - flash := "服务设置已保存(端口/密钥需重启生效)/ settings saved (port/secret need restart)" + flash := a.message(r, "flash.settingsSavedRestart") switch { case usernameChanged && passwordChanged: - flash = "服务设置、用户名与密码已保存 / settings, username and password saved" + flash = a.message(r, "flash.settingsUsernamePasswordSaved") case usernameChanged: - flash = "服务设置与用户名已保存 / settings and username saved" + flash = a.message(r, "flash.settingsUsernameSaved") case passwordChanged: - flash = "服务设置与密码已保存 / settings and password saved" + flash = a.message(r, "flash.settingsPasswordSaved") } a.redirectFlash(w, r, "/settings", flash, "ok") } diff --git a/internal/panel/handlers_templates.go b/internal/panel/handlers_templates.go index 1fac865..6064047 100644 --- a/internal/panel/handlers_templates.go +++ b/internal/panel/handlers_templates.go @@ -46,7 +46,7 @@ func (a *App) handleTemplateEdit(w http.ResponseWriter, r *http.Request) { root := map[string]any{} if err := loadJSONC(path, &root); err != nil { - a.redirectFlash(w, r, "/templates", "读取模板失败: "+err.Error(), "err") + a.redirectFlash(w, r, "/templates", a.message(r, "flash.templateLoadFailed", err), "err") return } @@ -66,27 +66,27 @@ func (a *App) handleTemplateEdit(w http.ResponseWriter, r *http.Request) { // alphabetically reorders keys. Functionality is preserved. func (a *App) handleTemplateSave(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { - a.redirectFlash(w, r, "/templates", "表单解析失败 / invalid form", "err") + a.redirectFlash(w, r, "/templates", a.message(r, "flash.invalidForm"), "err") return } file := r.FormValue("file") event := r.FormValue("event") text := r.FormValue("payloads_json") if event == "" { - a.redirectFlash(w, r, "/templates", "未选择事件 / no event selected", "err") + a.redirectFlash(w, r, "/templates", a.message(r, "flash.eventRequired"), "err") return } var payloads any if err := json.Unmarshal([]byte(text), &payloads); err != nil { - a.redirectFlash(w, r, "/templates", "payloads JSON 解析失败: "+err.Error(), "err") + a.redirectFlash(w, r, "/templates", a.message(r, "flash.payloadsParseFailed", err), "err") return } path := a.templateFilePath(file) root := map[string]any{} if err := loadJSONC(path, &root); err != nil { - a.redirectFlash(w, r, "/templates", "读取模板失败: "+err.Error(), "err") + a.redirectFlash(w, r, "/templates", a.message(r, "flash.templateLoadFailed", err), "err") return } @@ -103,11 +103,11 @@ func (a *App) handleTemplateSave(w http.ResponseWriter, r *http.Request) { templatesNode[event] = eventNode if err := SaveJSON(path, root); err != nil { - a.redirectFlash(w, r, "/templates", "保存失败: "+err.Error(), "err") + a.redirectFlash(w, r, "/templates", a.message(r, "flash.saveFailed", err), "err") return } a.notifySaved() - a.redirectFlash(w, r, "/templates", "模板已保存(注释与格式会被重排)/ template saved (comments/format reformatted)", "ok") + a.redirectFlash(w, r, "/templates", a.message(r, "flash.templateSaved"), "ok") } // eventKeys returns the sorted event keys present in a parsed templates file. diff --git a/internal/panel/i18n.go b/internal/panel/i18n.go new file mode 100644 index 0000000..c443b48 --- /dev/null +++ b/internal/panel/i18n.go @@ -0,0 +1,72 @@ +package panel + +import ( + "embed" + "encoding/json" + "fmt" + "net/http" + "strings" +) + +const localeCookie = "fgt_locale" + +//go:embed locales/*.json +var localesFS embed.FS + +var messages = loadMessages() + +func loadMessages() map[string]map[string]string { + locales := map[string]map[string]string{} + for _, name := range []string{"zh-CN", "en-US"} { + data, err := localesFS.ReadFile("locales/" + name + ".json") + if err != nil { + panic("read panel locale " + name + ": " + err.Error()) + } + catalog := map[string]string{} + if err := json.Unmarshal(data, &catalog); err != nil { + panic("parse panel locale " + name + ": " + err.Error()) + } + locales[name] = catalog + } + return locales +} + +func localeFrom(r *http.Request) string { + if c, err := r.Cookie(localeCookie); err == nil && validLocale(c.Value) { + return c.Value + } + if strings.HasPrefix(strings.ToLower(r.Header.Get("Accept-Language")), "en") { + return "en-US" + } + return "zh-CN" +} + +func validLocale(locale string) bool { + _, ok := messages[locale] + return ok +} + +func translate(data ViewData, key string) string { + return message(data.Locale, key) +} + +func (a *App) message(r *http.Request, key string, args ...any) string { + return fmt.Sprintf(message(localeFrom(r), key), args...) +} + +func message(locale, key string) string { + if value := messages[locale][key]; value != "" { + return value + } + if value := messages["zh-CN"][key]; value != "" { + return value + } + return key +} + +func alternateLocale(locale string) string { + if locale == "en-US" { + return "zh-CN" + } + return "en-US" +} diff --git a/internal/panel/locales/en-US.json b/internal/panel/locales/en-US.json new file mode 100644 index 0000000..4aa9182 --- /dev/null +++ b/internal/panel/locales/en-US.json @@ -0,0 +1,161 @@ +{ + "app.admin": "Admin panel", + "action.login": "Log in", + "action.signIn": "Sign in", + "action.logout": "Log out", + "action.language": "简体中文", + "action.save": "Save", + "action.cancel": "Cancel", + "action.new": "New", + "action.edit": "Edit", + "action.delete": "Delete", + "action.back": "Back to list", + "action.browse": "Browse events", + "action.copy": "Copy", + "action.copied": "Copied", + "nav.overview": "Overview", + "nav.repos": "Repo rules", + "nav.bots": "Feishu bots", + "nav.events": "Event sets", + "nav.templates": "Templates", + "nav.settings": "Server settings", + "nav.topology": "Topology", + "menu": "Menu", + "close": "Close", + "login.title": "Log in", + "login.description": "Enter the administrator password to open the admin panel.", + "login.username": "Username", + "login.password": "Administrator password", + "dashboard.title": "Operations overview", + "dashboard.subtitle": "Monitor notification configuration, delivery health, and relationships.", + "dashboard.repoRules": "Repo rules", + "dashboard.bots": "Feishu bots", + "dashboard.eventSets": "Event sets", + "dashboard.templates": "Template files", + "dashboard.failed": "Failed deliveries", + "dashboard.successRate": "Success rate", + "dashboard.noData": "No delivery data yet", + "dashboard.last7Days": "Delivery trend: last 7 days", + "dashboard.eventMix": "Event mix", + "dashboard.activity": "Recent activity", + "dashboard.topology": "Configuration relationships", + "dashboard.openTopology": "Open topology", + "dashboard.server": "Server status", + "dashboard.listen": "Listen address", + "dashboard.logLevel": "Log level", + "dashboard.timeout": "Timeout", + "dashboard.maxPayload": "Maximum payload", + "dashboard.allowedSources": "Allowed sources", + "topology.title": "Configuration topology", + "topology.subtitle": "Select a node to view or edit its configuration.", + "topology.empty": "No configuration relationships to show. Create a repo rule and connect events and bots first.", + "topology.legend.repo": "Repo rules", + "topology.legend.event": "Events or sets", + "topology.legend.bot": "Delivery targets", + "topology.routes": "configuration rules", + "topology.nodes": "configuration nodes", + "topology.edges": "relationships", + "topology.events": "events", + "topology.targets": "delivery targets", + "topology.column.repo": "Repo rules", + "topology.column.event": "Events and sets", + "topology.column.target": "Delivery targets", + "bots.title": "Feishu bots", + "bots.subtitle": "Bot aliases and webhook URLs can be referenced by repo rules.", + "bots.alias": "Alias", + "bots.webhookURL": "Webhook URL", + "bots.template": "Template", + "bots.empty": "No bots yet. Select New to add one.", + "bots.deleteConfirm": "Delete this bot?", + "bot.newTitle": "New bot", + "bot.editTitle": "Edit bot", + "bot.subtitle": "Repo rules use this alias to reference the bot.", + "bot.templateHint": "Optional; default is used when empty", + "repos.title": "Repo rules", + "repos.subtitle": "Repository patterns, event subscriptions, and delivery targets are evaluated in order.", + "repos.pattern": "Pattern", + "repos.events": "Events", + "repos.notifyTo": "Notify to", + "repos.eventsCount": "events", + "repos.empty": "No repo rules yet. Select New to add one.", + "repos.deleteConfirm": "Delete this repo rule?", + "repos.customSecret": "Custom secret", + "repo.newTitle": "New repo rule", + "repo.editTitle": "Edit repo rule", + "repo.subtitle": "Configure the rule's events and delivery targets.", + "repo.patternHint": "Glob patterns are supported, for example org/*", + "repo.secret": "Webhook secret", + "repo.secretHint": "Optional; uses the global server.secret when empty", + "repo.secretPlaceholder": "Empty uses the global secret", + "repo.notifyHint": "One per line", + "repo.eventsHint": "Events can reference entries in events.yaml or compose named event_sets.", + "events.title": "Event sets", + "events.subtitle": "Edit event sets and event definitions.", + "events.preserve": "Comments and formatting are preserved exactly.", + "templates.title": "Message templates", + "templates.subtitle": "Edit Feishu message templates by event.", + "templates.name": "Template name", + "templates.eventsCount": "events", + "templates.empty": "No template files were found.", + "template.editTitle": "Edit template", + "template.file": "File", + "template.event": "Event", + "template.selectEvent": "Select event", + "template.noEvents": "This file has no events.", + "template.payloads": "payloads", + "template.jsonArray": "JSON array", + "template.saveWarning": "Saving removes // comments and sorts keys alphabetically. Behavior is unchanged.", + "settings.title": "Server settings", + "settings.subtitle": "Edit server.yaml. Port and secret changes require a service restart.", + "settings.host": "Listen host", + "settings.port": "Port", + "settings.secret": "Webhook secret", + "settings.secretHint": "Global fallback. A repo rule's own secret takes precedence.", + "settings.logLevel": "Log level", + "settings.maxPayload": "Maximum payload", + "settings.timeout": "Timeout", + "settings.seconds": "seconds", + "settings.matchAll": "Match all repo rules", + "settings.matchAllHint": "By default only the first matching rule is used. When enabled, all matching rules are evaluated in order and a target receives one notification per webhook.", + "settings.allowedSources": "Allowed sources", + "settings.onePerLine": "One per line", + "settings.account": "Panel account", + "settings.adminUsername": "Administrator username", + "settings.currentPassword": "Current password", + "settings.currentPasswordHint": "Required when changing the password", + "settings.newPassword": "New password", + "settings.newPasswordHint": "Leave empty to keep the current password", + "settings.confirmPassword": "Confirm new password", + "settings.passwordHint": "Changing the password requires the current password and two matching new values.", + "settings.saveWarning": "Port, webhook secret, and panel secret changes require a restart; other settings apply immediately.", + "settings.passwordMismatch": "The new passwords do not match.", + "settings.currentPasswordRequired": "Enter the current password to change it.", + "flash.panelLoginDisabled": "The panel administrator password is not configured.", + "flash.invalidForm": "The submitted form could not be parsed.", + "flash.invalidCredentials": "Invalid username or password.", + "flash.configLoadFailed": "Configuration could not be loaded.", + "flash.saveFailed": "Save failed: %s", + "flash.botNotFound": "The bot was not found.", + "flash.botFieldsRequired": "Alias and URL are required.", + "flash.botSaved": "Bot saved.", + "flash.botDeleted": "Bot deleted.", + "flash.repoNotFound": "The repo rule was not found.", + "flash.patternRequired": "Pattern is required.", + "flash.eventsParseFailed": "events YAML could not be parsed: %s", + "flash.repoSaved": "Repo rule saved.", + "flash.repoDeleted": "Repo rule deleted.", + "flash.eventsSaved": "Event configuration saved.", + "flash.templateLoadFailed": "Template could not be loaded: %s", + "flash.eventRequired": "Select an event first.", + "flash.payloadsParseFailed": "payloads JSON could not be parsed: %s", + "flash.templateSaved": "Template saved; comments and formatting were rewritten.", + "flash.passwordMismatch": "The new passwords do not match.", + "flash.incorrectOldPassword": "The current password is incorrect; the password was not changed.", + "flash.passwordSaveFailed": "Password save failed: %s", + "flash.settingsSavedRestart": "Server settings saved. Port and secret changes require a restart.", + "flash.settingsUsernamePasswordSaved": "Server settings, username, and password saved.", + "flash.settingsUsernameSaved": "Server settings and username saved.", + "flash.settingsPasswordSaved": "Server settings and password saved.", + "footer.tagline": "Feishu GitHub Tracker · GitHub → Feishu webhook forwarder.", + "footer.note": "Panel edits auto-reload on save; hand-edited config files apply via -reload or a restart." +} diff --git a/internal/panel/locales/zh-CN.json b/internal/panel/locales/zh-CN.json new file mode 100644 index 0000000..77ce38c --- /dev/null +++ b/internal/panel/locales/zh-CN.json @@ -0,0 +1,161 @@ +{ + "app.admin": "管理面板", + "action.login": "登录", + "action.signIn": "登录", + "action.logout": "退出登录", + "action.language": "English", + "action.save": "保存", + "action.cancel": "取消", + "action.new": "新建", + "action.edit": "编辑", + "action.delete": "删除", + "action.back": "返回列表", + "action.browse": "浏览事件", + "action.copy": "复制", + "action.copied": "已复制", + "nav.overview": "概览", + "nav.repos": "仓库规则", + "nav.bots": "飞书机器人", + "nav.events": "事件集合", + "nav.templates": "消息模板", + "nav.settings": "服务设置", + "nav.topology": "配置图谱", + "menu": "菜单", + "close": "关闭", + "login.title": "登录", + "login.description": "输入管理员密码以进入管理面板。", + "login.username": "用户名", + "login.password": "管理员密码", + "dashboard.title": "运行概览", + "dashboard.subtitle": "查看通知配置、投递健康度与配置关系。", + "dashboard.repoRules": "仓库规则", + "dashboard.bots": "飞书机器人", + "dashboard.eventSets": "事件集合", + "dashboard.templates": "模板文件", + "dashboard.failed": "投递失败", + "dashboard.successRate": "成功率", + "dashboard.noData": "暂无投递数据", + "dashboard.last7Days": "最近 7 天投递趋势", + "dashboard.eventMix": "事件分布", + "dashboard.activity": "最近活动", + "dashboard.topology": "配置关系", + "dashboard.openTopology": "打开图谱", + "dashboard.server": "服务状态", + "dashboard.listen": "监听地址", + "dashboard.logLevel": "日志级别", + "dashboard.timeout": "超时", + "dashboard.maxPayload": "最大载荷", + "dashboard.allowedSources": "允许来源", + "topology.title": "配置图谱", + "topology.subtitle": "选择节点查看或修改对应配置。", + "topology.empty": "暂无可展示的配置关系。先创建仓库规则并关联事件和机器人。", + "topology.legend.repo": "仓库规则", + "topology.legend.event": "事件或集合", + "topology.legend.bot": "通知目标", + "topology.routes": "条配置规则", + "topology.nodes": "个配置节点", + "topology.edges": "条关联", + "topology.events": "个事件", + "topology.targets": "个通知目标", + "topology.column.repo": "仓库规则", + "topology.column.event": "事件与集合", + "topology.column.target": "通知目标", + "bots.title": "飞书机器人", + "bots.subtitle": "机器人别名与 Webhook URL,可在仓库规则中通过别名引用。", + "bots.alias": "别名", + "bots.webhookURL": "Webhook URL", + "bots.template": "模板", + "bots.empty": "暂无机器人,点击“新建”添加。", + "bots.deleteConfirm": "删除该机器人?", + "bot.newTitle": "新建机器人", + "bot.editTitle": "编辑机器人", + "bot.subtitle": "别名用于在仓库规则中引用此机器人。", + "bot.templateHint": "可选,默认使用 default", + "repos.title": "仓库规则", + "repos.subtitle": "仓库匹配模式、订阅事件与通知目标按配置顺序匹配。", + "repos.pattern": "模式", + "repos.events": "事件", + "repos.notifyTo": "通知目标", + "repos.eventsCount": "个事件", + "repos.empty": "暂无仓库规则,点击“新建”添加。", + "repos.deleteConfirm": "删除该仓库规则?", + "repos.customSecret": "自定义密钥", + "repo.newTitle": "新建仓库规则", + "repo.editTitle": "编辑仓库规则", + "repo.subtitle": "设置规则的事件与通知目标。", + "repo.patternHint": "支持 glob,例如 org/*", + "repo.secret": "Webhook 密钥", + "repo.secretHint": "可选;留空则使用全局 server.secret", + "repo.secretPlaceholder": "留空则使用全局密钥", + "repo.notifyHint": "每行一个", + "repo.eventsHint": "事件可直接引用 events.yaml 中的事件,也可叠加 event_sets 中的模板名称。", + "events.title": "事件集合", + "events.subtitle": "编辑事件集合和事件定义。", + "events.preserve": "注释与格式会完整保留。", + "templates.title": "消息模板", + "templates.subtitle": "按事件编辑飞书消息模板。", + "templates.name": "模板名", + "templates.eventsCount": "个事件", + "templates.empty": "未发现模板文件。", + "template.editTitle": "编辑模板", + "template.file": "文件", + "template.event": "事件", + "template.selectEvent": "选择事件", + "template.noEvents": "该文件没有事件。", + "template.payloads": "的 payloads", + "template.jsonArray": "JSON 数组", + "template.saveWarning": "保存会移除该文件中的 // 注释,并按字母顺序重排键;功能不变。", + "settings.title": "服务设置", + "settings.subtitle": "编辑 server.yaml。端口和密钥的修改需要重启服务后生效。", + "settings.host": "监听主机", + "settings.port": "端口", + "settings.secret": "Webhook 密钥", + "settings.secretHint": "全局回退密钥。若规则设置了单独密钥,则优先使用规则密钥。", + "settings.logLevel": "日志级别", + "settings.maxPayload": "最大载荷", + "settings.timeout": "超时", + "settings.seconds": "秒", + "settings.matchAll": "匹配全部仓库规则", + "settings.matchAllHint": "默认只使用首条匹配规则。开启后会按顺序评估全部匹配规则;同一目标在一次 webhook 中只发送一次。", + "settings.allowedSources": "允许来源", + "settings.onePerLine": "每行一个", + "settings.account": "面板账号", + "settings.adminUsername": "管理员用户名", + "settings.currentPassword": "当前密码", + "settings.currentPasswordHint": "修改密码时必填", + "settings.newPassword": "新密码", + "settings.newPasswordHint": "留空则不修改", + "settings.confirmPassword": "确认新密码", + "settings.passwordHint": "修改密码需提供当前密码,并输入两次相同的新密码。", + "settings.saveWarning": "端口、密钥和面板密钥的修改需重启服务;其它设置会立即生效。", + "settings.passwordMismatch": "两次输入的新密码不一致。", + "settings.currentPasswordRequired": "修改密码需填写当前密码。", + "flash.panelLoginDisabled": "面板未配置管理员密码。", + "flash.invalidForm": "表单解析失败。", + "flash.invalidCredentials": "用户名或密码错误。", + "flash.configLoadFailed": "读取配置失败。", + "flash.saveFailed": "保存失败:%s", + "flash.botNotFound": "机器人不存在。", + "flash.botFieldsRequired": "别名和 URL 不能为空。", + "flash.botSaved": "机器人已保存。", + "flash.botDeleted": "机器人已删除。", + "flash.repoNotFound": "仓库规则不存在。", + "flash.patternRequired": "模式不能为空。", + "flash.eventsParseFailed": "events YAML 解析失败:%s", + "flash.repoSaved": "仓库规则已保存。", + "flash.repoDeleted": "仓库规则已删除。", + "flash.eventsSaved": "事件配置已保存。", + "flash.templateLoadFailed": "读取模板失败:%s", + "flash.eventRequired": "未选择事件。", + "flash.payloadsParseFailed": "payloads JSON 解析失败:%s", + "flash.templateSaved": "模板已保存;注释与格式会被重排。", + "flash.passwordMismatch": "两次输入的新密码不一致。", + "flash.incorrectOldPassword": "旧密码错误,密码未修改。", + "flash.passwordSaveFailed": "密码保存失败:%s", + "flash.settingsSavedRestart": "服务设置已保存;端口和密钥的修改需重启后生效。", + "flash.settingsUsernamePasswordSaved": "服务设置、用户名和密码已保存。", + "flash.settingsUsernameSaved": "服务设置和用户名已保存。", + "flash.settingsPasswordSaved": "服务设置和密码已保存。", + "footer.tagline": "Feishu GitHub Tracker · GitHub → 飞书 webhook 转发。", + "footer.note": "面板内修改保存后会自动 reload;手动编辑配置文件则需以 -reload 启动或重启进程。" +} diff --git a/internal/panel/templates/bot_edit.html b/internal/panel/templates/bot_edit.html index 98db61c..22b6fa0 100644 --- a/internal/panel/templates/bot_edit.html +++ b/internal/panel/templates/bot_edit.html @@ -1,20 +1,20 @@ -{{define "title"}}编辑机器人 · Feishu Bot{{end}} +{{define "title"}}{{if lt .EditBot.Index 0}}{{t . "bot.newTitle"}}{{else}}{{t . "bot.editTitle"}}{{end}} · Feishu GitHub Tracker{{end}} {{define "content"}}
-

{{if lt .EditBot.Index 0}}新建机器人 / New Bot{{else}}编辑机器人 / Edit Bot{{end}}

-
别名用于在仓库规则中引用此机器人。 / The alias is how repo rules reference this bot.
+

{{if lt .EditBot.Index 0}}{{t . "bot.newTitle"}}{{else}}{{t . "bot.editTitle"}}{{end}}

+
{{t . "bot.subtitle"}}
- + - + - +
- - 取消 / Cancel + + {{t . "action.cancel"}}
{{end}} diff --git a/internal/panel/templates/bots.html b/internal/panel/templates/bots.html index a1d5e87..131924e 100644 --- a/internal/panel/templates/bots.html +++ b/internal/panel/templates/bots.html @@ -1,22 +1,23 @@ -{{define "title"}}飞书机器人 · Feishu Bots{{end}} +{{define "title"}}{{t . "bots.title"}} · Feishu GitHub Tracker{{end}} {{define "content"}}
-

飞书机器人 / Feishu Bots

-
机器人别名与 Webhook URL,在仓库规则中以 alias 引用。 / Bot aliases + webhook URLs, referenced by alias in repo rules.
+

{{t . "bots.title"}}

+
{{t . "bots.subtitle"}}
- + 新建 / New + + {{t . "action.new"}}
{{if .Bots}} +
- - - + + + @@ -29,10 +30,10 @@

飞书机器人 / Feishu Bots

@@ -40,8 +41,9 @@

飞书机器人 / Feishu Bots

{{end}}
#别名 / AliasWebhook URL模板 / Template{{t . "bots.alias"}}{{t . "bots.webhookURL"}}{{t . "bots.template"}}
{{if .Template}}{{.Template}}{{else}}default{{end}}
- 编辑 / Edit + {{t $ "action.edit"}}
- +
+
{{else}} -
暂无机器人,点击「新建」添加。 / No bots yet — click “New”.
+
{{t . "bots.empty"}}
{{end}}
{{end}} diff --git a/internal/panel/templates/dashboard.html b/internal/panel/templates/dashboard.html index 389de87..59daa95 100644 --- a/internal/panel/templates/dashboard.html +++ b/internal/panel/templates/dashboard.html @@ -1,100 +1,77 @@ -{{define "title"}}仪表盘 · Dashboard{{end}} +{{define "title"}}{{t . "dashboard.title"}} · Feishu GitHub Tracker{{end}} {{define "content"}}
-

仪表盘 / Dashboard

-
配置概览与最近投递活动。 / Configuration overview and recent delivery activity.
+

{{t . "dashboard.title"}}

+
{{t . "dashboard.subtitle"}}
-
-
-
{{.RepoCount}}
-
仓库规则 / Repo rules
-
-
-
{{.BotCount}}
-
飞书机器人 / Feishu bots
-
-
-
{{.EventSetCount}}
-
事件集合 / Event sets
-
-
-
{{len .TemplateFiles}}
-
模板文件 / Template files
-
+
+
{{.RepoCount}}
{{t . "dashboard.repoRules"}}
+
{{.BotCount}}
{{t . "dashboard.bots"}}
+
{{.EventSetCount}}
{{t . "dashboard.eventSets"}}
+
{{len .TemplateFiles}}
{{t . "dashboard.templates"}}
+
{{.Delivery.Failed}}
{{t . "dashboard.failed"}}
+
{{if .Delivery.Total}}{{.Delivery.SuccessRate}}%{{else}}—{{end}}
{{t . "dashboard.successRate"}}
-
-

快速上手 / Getting started

+
+
+
{{t . "dashboard.last7Days"}}{{.Delivery.Total}}
+ {{if .Delivery.Total}} +
+ {{range .Delivery.Days}} +
+ + + +
+ {{end}} +
+ {{else}}
{{t . "dashboard.noData"}}
{{end}} +
-
- 1. 添加飞书机器人 / Add a Feishu bot -
    -
  1. 飞书群 → 设置 → 群机器人 → 添加「自定义机器人」,复制其 Webhook URL。
  2. -
  3. 飞书机器人 / Feishu Bots 页面新增:填入别名与该 URL。
  4. -
-
+
+
{{t . "dashboard.eventMix"}}
+ {{if .Delivery.Events}} +
+ {{range .Delivery.Events}} +
{{.Label}}
{{.Count}}
+ {{end}} +
+ {{else}}
{{t . "dashboard.noData"}}
{{end}} +
-
- 2. 添加 GitHub Webhook / Add a GitHub webhook -
    -
  1. GitHub 仓库 → SettingsWebhooksAdd webhook
  2. -
  3. Payload URL 填下方地址;Content type 选什么都支持;Secret 填你在 server.yaml 或对应仓库规则里配置的 secret(若有)。 - {{if .PayloadURL}} -
    - {{.PayloadURL}} - -
    - {{else}} -
    当前通过本地地址(localhost / 内网 IP)访问,无法生成可被 GitHub 访问的 Payload URL。请通过公网域名 / 公网 IP 访问本面板,或在 server.yaml 中配置 panel.public_url。 / Accessed locally — open via a public host, or set panel.public_url.
    - {{end}} -
  4. -
  5. 选择要监听的事件并保存,再到 仓库规则 / Repos 配置监听与通知目标。
  6. -
-
-
+
+
{{t . "dashboard.activity"}}
+ {{if .Delivery.Recent}} +
{{range .Delivery.Recent}}
{{.Time}}{{.Target}}
{{end}}
+ {{else}}
{{t . "dashboard.noData"}}
{{end}} +
-
-
-

服务信息 / Server

-
-
监听 / Listen
-
{{.ServerInfo.Host}}:{{.ServerInfo.Port}}
-
日志级别 / Log level
-
{{if .ServerInfo.LogLevel}}{{.ServerInfo.LogLevel}}{{else}}—{{end}}
-
超时 / Timeout
-
{{if .ServerInfo.Timeout}}{{.ServerInfo.Timeout}}s{{else}}—{{end}}
-
最大载荷 / Max payload
-
{{if .ServerInfo.MaxPayloadSize}}{{.ServerInfo.MaxPayloadSize}}{{else}}—{{end}}
-
允许来源 / Allowed
-
- {{if .ServerInfo.AllowedSources}}{{range .ServerInfo.AllowedSources}}{{.}}{{end}}{{else}}—{{end}} +
+
{{t . "dashboard.topology"}}{{t . "dashboard.openTopology"}}
+ {{if .Topology.Routes}} + -
- -
-

模板文件 / Template files

- {{if .TemplateFiles}} -
- {{range .TemplateFiles}}{{.}}{{end}} -
- {{else}} -
无模板文件 / none
- {{end}} -
在「消息模板 / Templates」页面编辑各事件卡片。 / Edit per-event cards under Templates.
-
+ {{else}}
{{t . "topology.empty"}}
{{end}} +
-
-

最近投递 / Recent deliveries

-
来自日志文件尾部(只读)。 / Tailed from the log file (read-only).
- {{if .RecentLines}} -
- {{range .RecentLines}}
{{.}}
{{end}} +
+
{{t . "dashboard.server"}}
+
+
{{t . "dashboard.listen"}}
{{.ServerInfo.Host}}:{{.ServerInfo.Port}}
+
{{t . "dashboard.logLevel"}}
{{if .ServerInfo.LogLevel}}{{.ServerInfo.LogLevel}}{{else}}—{{end}}
+
{{t . "dashboard.timeout"}}
{{if .ServerInfo.Timeout}}{{.ServerInfo.Timeout}}s{{else}}—{{end}}
+
{{t . "dashboard.maxPayload"}}
{{if .ServerInfo.MaxPayloadSize}}{{.ServerInfo.MaxPayloadSize}}{{else}}—{{end}}
+
{{t . "dashboard.allowedSources"}}
{{range .ServerInfo.AllowedSources}}{{.}}{{else}}—{{end}}
- {{else}} -
暂无投递记录 / no recent activity
- {{end}} -
+ {{end}} diff --git a/internal/panel/templates/events.html b/internal/panel/templates/events.html index edb1e5a..b571bb4 100644 --- a/internal/panel/templates/events.html +++ b/internal/panel/templates/events.html @@ -1,16 +1,16 @@ -{{define "title"}}事件 · Events{{end}} +{{define "title"}}{{t . "events.title"}} · Feishu GitHub Tracker{{end}} {{define "content"}}
-

事件 / Events

-
编辑 events.yaml 原文(event_setsevents)。保存时会校验 YAML 语法。 / Edit the raw events.yaml; YAML is validated on save.
+

{{t . "events.title"}}

+
{{t . "events.subtitle"}}
-
注释与格式将被完整保留。 / Comments and formatting are preserved verbatim.
+
{{t . "events.preserve"}}
- +
{{end}} diff --git a/internal/panel/templates/layout.html b/internal/panel/templates/layout.html index 958af30..9ab0359 100644 --- a/internal/panel/templates/layout.html +++ b/internal/panel/templates/layout.html @@ -1,33 +1,48 @@ {{define "nav"}} {{end}} +{{define "sidebarFooter"}} +{{if .Authed}} +
+ {{t . "action.language"}} +
+ {{.Username}} +
+ +
+
+
+{{end}} +{{end}} + {{define "layout"}} - + @@ -120,9 +135,11 @@ .sidebar { width: 248px; flex: 0 0 248px; + display: flex; + flex-direction: column; border-right: 1px solid var(--border); background: var(--bg-soft); - padding: 18px 14px; + padding: 4px 18px 14px; height: 100vh; overflow-y: auto; overscroll-behavior: contain; @@ -134,7 +151,7 @@ gap: 10px; padding: 8px 10px 16px; font-weight: 700; - font-size: 16px; + font-size: 12px; color: var(--text); border-bottom: 1px solid var(--border); margin-bottom: 12px; @@ -151,7 +168,7 @@ .brand small { display: block; font-weight: 500; - font-size: 11px; + font-size: 10px; color: var(--muted); letter-spacing: 0; } @@ -203,6 +220,22 @@ color: var(--accent-strong); } + .sidebarFooter { + margin-top: auto; + padding-top: 14px; + border-top: 1px solid var(--border); + display: flex; + flex-direction: column; + gap: 10px; + } + + .sidebarUser { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + } + .main { flex: 1 1 auto; min-width: 0; @@ -212,6 +245,14 @@ padding: 26px 30px 60px; } + /* Login (HideNav) page: center the card both axes. */ + .main-center { + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + } + .wrap { max-width: 1080px; margin: 0 auto; @@ -379,6 +420,8 @@ color: var(--text); font-size: 14px; font-weight: 550; + line-height: 1; + text-align: center; cursor: pointer; text-decoration: none; transition: background .12s, border-color .12s, transform .05s; @@ -433,12 +476,31 @@ font-size: 14px; } + /* Tables with non-wrapping code cells can overflow; let them scroll sideways + instead of breaking the layout. */ + .tableScroll { + overflow-x: auto; + -webkit-overflow-scrolling: touch; + } + + .tableScroll table { + min-width: 100%; + width: max-content; + } + + /* Cards that contain a table: remove padding so the table edge-to-edge + fills the card, and clip corners via overflow hidden. */ + .card:has(> .tableScroll) { + padding: 0; + overflow: hidden; + } + th, td { text-align: left; padding: 11px 12px; border-bottom: 1px solid var(--border); - vertical-align: top; + vertical-align: middle; } th { @@ -466,6 +528,7 @@ padding: 2px 6px; border-radius: 6px; border: 1px solid var(--border); + white-space: nowrap; } .pill { @@ -568,6 +631,7 @@ min-width: 0; font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; font-size: 13px; + white-space: normal; word-break: break-all; background: transparent; border: none; @@ -599,6 +663,7 @@ justify-content: center; align-items: center; gap: 4px; + margin: 6px 0; border-radius: 10px; border: 1px solid var(--border); background: var(--bg); @@ -663,6 +728,7 @@ .mobileTop { display: flex; + align-items: center; } /* On mobile the sidebar is hidden, so drop the fixed-height app-shell and @@ -680,6 +746,11 @@ padding: 16px 16px 50px; } + /* Login page: give the flex centering enough vertical space on mobile. */ + .main-center { + min-height: 100vh; + } + .grid.two, .grid.three, .grid.four { @@ -690,14 +761,421 @@ flex: 1 1 100%; } } + + /* Operations workspace: navigation stays quiet while tools sit on a + separate, lightly elevated canvas. */ + body { + background: #edf3f8; + } + + .sidebar { + width: 236px; + flex-basis: 236px; + background: #f6f9fd; + border-right: 0; + } + + .main { + padding: 8px 12px 12px; + background: #edf3f8; + } + + .wrap { + max-width: none; + margin: 0; + } + + .topbar { + min-height: 36px; + margin: 0 8px 8px; + } + + .workspace { + min-width: 0; + min-height: calc(100vh - 66px); + padding: 24px; + border: 1px solid #dce6f0; + border-radius: 16px; + background: #fff; + } + + .sidebar .brand { + margin: 0 2px 12px; + padding: 12px 10px 16px; + } + + .nav a { + margin: 2px 0; + padding: 10px 12px; + border-radius: 9px; + } + + .nav a small { + display: none; + } + + .card, + .stat { + border-radius: 10px; + } + + .languageLink { + padding: 7px 10px; + border: 1px solid var(--border); + border-radius: 999px; + background: var(--bg); + color: var(--muted); + font-size: 12px; + } + + .languageLink:hover { + color: var(--accent-strong); + text-decoration: none; + } + + .dashboardGrid { + display: grid; + grid-template-columns: minmax(0, 1.55fr) minmax(330px, .85fr); + gap: 14px; + } + + .metricsGrid { + display: grid; + grid-template-columns: repeat(6, minmax(0, 1fr)); + gap: 12px; + } + + .metricTile { + /* min-height: 98px; */ + padding: 16px; + border: 1px solid var(--border); + border-radius: 10px; + background: linear-gradient(145deg, #fff, #f7fbff); + box-shadow: 0 5px 14px rgba(43, 71, 106, .05); + } + + .metricTile:hover { + border-color: rgba(78, 172, 248, .55); + text-decoration: none; + transform: translateY(-1px); + } + + .metricValue { + color: var(--text); + font-size: 27px; + font-weight: 720; + line-height: 1; + } + + .metricLabel { + margin-top: 8px; + color: var(--muted); + font-size: 12px; + } + + .metricTile.warn .metricValue { + color: var(--danger); + } + + .metricTile.ok .metricValue { + color: #159563; + } + + .chartCard { + padding: 18px; + border: 1px solid var(--border); + border-radius: 10px; + background: #fff; + box-shadow: 0 4px 12px rgba(43, 71, 106, .035); + } + + .chartTitle { + display: flex; + justify-content: space-between; + gap: 12px; + margin-bottom: 14px; + font-size: 14px; + font-weight: 650; + } + + .deliveryChart { + display: flex; + align-items: flex-end; + gap: 8px; + height: 152px; + padding: 0 2px 18px; + /* border-bottom: 1px solid var(--border); */ + } + + .deliveryDay { + position: relative; + display: flex; + flex: 1 1 0; + align-items: flex-end; + justify-content: center; + gap: 3px; + height: 100%; + min-width: 20px; + } + + .deliveryBar { + width: min(18px, 42%); + min-height: 2px; + border-radius: 5px 5px 2px 2px; + background: #24a9e8; + } + + .deliveryBar.failed { + background: #ef6d72; + } + + .deliveryDay label { + position: absolute; + bottom: -20px; + margin: 0; + color: var(--muted); + font-size: 10px; + font-weight: 500; + } + + .eventBars { + display: grid; + gap: 10px; + } + + .eventBar { + display: grid; + grid-template-columns: minmax(74px, 1fr) minmax(0, 2.5fr) 22px; + align-items: center; + gap: 8px; + font-size: 12px; + } + + .eventBar>span:first-child { + overflow: hidden; + color: var(--muted); + text-overflow: ellipsis; + white-space: nowrap; + } + + .eventTrack { + height: 7px; + overflow: hidden; + border-radius: 99px; + background: #edf3f8; + } + + .eventTrack i { + display: block; + height: 100%; + border-radius: inherit; + background: #7869ea; + } + + .activityList { + display: grid; + gap: 2px; + } + + .activityRow { + display: grid; + grid-template-columns: 76px minmax(0, 1fr) auto; + align-items: center; + gap: 10px; + padding: 10px 0; + border-bottom: 1px solid var(--border); + font-size: 12px; + } + + .activityRow:last-child { + border-bottom: 0; + } + + .activityTarget { + overflow: hidden; + color: var(--text); + text-overflow: ellipsis; + white-space: nowrap; + } + + .statusDot { + width: 8px; + height: 8px; + border-radius: 50%; + background: #17ad75; + } + + .statusDot.failed { + background: #ee5f67; + } + + .topologyPreview { + display: grid; + align-content: start; + gap: 9px; + } + + .topologyStep { + display: grid; + grid-template-columns: minmax(0, 1fr) 22px minmax(0, 1.1fr); + align-items: center; + gap: 8px; + } + + .topologyStep .arrow { + color: var(--accent-strong); + text-align: center; + } + + .topologyNode { + display: block; + overflow: hidden; + padding: 10px 11px; + border: 1px solid var(--border); + border-left: 3px solid var(--accent); + border-radius: 8px; + background: var(--bg); + color: var(--text); + font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; + } + + .topologyNode:hover { + border-color: var(--accent); + text-decoration: none; + } + + .topologyNode.event { + border-left-color: #7869ea; + } + + .topologyNode.bot { + border-left-color: #16a870; + } + + .topologyStats { + display: flex; + gap: 0; + width: max-content; + max-width: 100%; + margin: 4px 0 16px; + overflow: hidden; + border: 1px solid var(--border); + border-radius: 10px; + background: #fbfdff; + } + + .topologyStats div { + display: flex; + align-items: baseline; + gap: 7px; + padding: 10px 18px; + border-right: 1px solid var(--border); + } + + .topologyStats div:last-child { + border-right: 0; + } + + .topologyStats strong { + color: var(--accent-strong); + font-size: 20px; + } + + .topologyStats span { + color: var(--muted); + font-size: 12px; + } + + .topologyCanvas { + min-height: 620px; + overflow: auto; + border: 1px solid var(--border); + border-radius: 12px; + background: radial-gradient(circle at 1px 1px, #dfe9f3 1px, transparent 0) 0 0/18px 18px, #fbfdff; + } + + .topologyCanvas svg { + display: block; + width: 100%; + height: 620px; + min-width: 1240px; + } + + .topologyLegend { + display: flex; + flex-wrap: wrap; + gap: 14px; + margin: 14px 0; + color: var(--muted); + font-size: 12px; + } + + .legendMark { + display: inline-block; + width: 9px; + height: 9px; + margin-right: 5px; + border-radius: 3px; + background: var(--accent); + } + + .legendMark.event { + background: #7869ea; + } + + .legendMark.bot { + background: #16a870; + } + + @media (max-width:1280px) { + .metricsGrid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + } + + @media (max-width:920px) { + .main { + padding: 0; + } + + .workspace { + min-height: 0; + padding: 16px; + border: 0; + border-radius: 0; + } + + .dashboardGrid, + .metricsGrid { + grid-template-columns: 1fr; + } + + .topologyStats { + width: 100%; + } + + .topologyStats div { + flex: 1; + padding: 9px; + flex-direction: column; + gap: 1px; + } + + .topologyCanvas { + margin: 0 -16px; + border-radius: 0; + } + } - + {{if .HideNav}}
-
-
+
+
{{if .Flash}}
{{.Flash}}
{{end}} @@ -713,45 +1191,40 @@ - Feishu GitHub Tracker + + + Feishu GitHub Tracker +
- {{if .Authed}} -
- -
- {{end}}
-
- {{if .Authed}} - {{.Username}} -
- -
- {{end}} -
- {{if .Flash}}
{{.Flash}}
{{end}} - {{block "content" .}}{{end}} +
+ {{block "content" .}}{{end}} -
- Feishu GitHub Tracker · GitHub → 飞书 webhook 转发 / forwarder.
- 面板内修改保存后会自动 reload;手动编辑配置文件则需以 -reload 启动或重启进程。 / Panel edits auto-reload on save; hand-edited files apply via -reload or restart. +
+ {{t . "footer.tagline"}}
+ {{th . "footer.note"}}
+ GitHub +
@@ -760,10 +1233,11 @@ {{end}} @@ -773,8 +1247,8 @@ // Uses the native Web Crypto API when available (secure contexts: https / // localhost), otherwise a self-contained SHA-256 fallback. function sha256Sync(bytes) { - var k = [0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5,0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174,0xe49b69c1,0xefbe4786,0x0fc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da,0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x06ca6351,0x14292967,0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85,0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070,0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3,0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2]; - var h = [0x6a09e667,0xbb67ae85,0x3c6ef372,0xa54ff53a,0x510e527f,0x9b05688c,0x1f83d9ab,0x5be0cd19]; + var k = [0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2]; + var h = [0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19]; function rotr(n, x) { return (x >>> n) | (x << (32 - n)); } var l = bytes.length, bitLen = l * 8; var padLen = (Math.floor((l + 8) / 64) + 1) * 64; @@ -824,7 +1298,7 @@ if (!btn) return; var orig = btn.getAttribute('data-orig') || btn.textContent; if (!btn.getAttribute('data-orig')) btn.setAttribute('data-orig', orig); - btn.textContent = '已复制 / Copied'; + btn.textContent = document.body.getAttribute('data-copied-label') || btn.textContent; setTimeout(function () { btn.textContent = orig; }, 1500); }; var fallback = function () { @@ -834,7 +1308,7 @@ ta.style.opacity = '0'; document.body.appendChild(ta); ta.select(); - try { document.execCommand('copy'); } catch (e) {} + try { document.execCommand('copy'); } catch (e) { } document.body.removeChild(ta); }; if (navigator.clipboard && navigator.clipboard.writeText) { @@ -848,4 +1322,4 @@ -{{end}} +{{end}} \ No newline at end of file diff --git a/internal/panel/templates/login.html b/internal/panel/templates/login.html index fb4da6b..e23d9e8 100644 --- a/internal/panel/templates/login.html +++ b/internal/panel/templates/login.html @@ -1,20 +1,20 @@ -{{define "title"}}登录 · Login · Feishu GitHub Tracker{{end}} +{{define "title"}}{{t . "login.title"}} · Feishu GitHub Tracker{{end}} {{define "content"}}
-

登录 / Login

+

{{t . "login.title"}}

-
输入管理员密码以进入管理面板。
Enter the admin password to access the management panel. +
{{t . "login.description"}}
- + - +
- +
diff --git a/internal/panel/templates/repo_edit.html b/internal/panel/templates/repo_edit.html index 34ab611..498f41f 100644 --- a/internal/panel/templates/repo_edit.html +++ b/internal/panel/templates/repo_edit.html @@ -1,38 +1,37 @@ -{{define "title"}}编辑仓库规则 · Repo Rule{{end}} +{{define "title"}}{{if lt .EditRepo.Index 0}}{{t . "repo.newTitle"}}{{else}}{{t . "repo.editTitle"}}{{end}} · Feishu GitHub Tracker{{end}} {{define "content"}}
-

{{if lt .EditRepo.Index 0}}新建仓库规则 / New Repo Rule{{else}}编辑仓库规则 / Edit Repo Rule{{end}}

-
事件以 YAML 书写;通知目标每行一个(alias 或完整 URL)。 / Events as YAML; one notify target per line (alias or full URL).
+

{{if lt .EditRepo.Index 0}}{{t . "repo.newTitle"}}{{else}}{{t . "repo.editTitle"}}{{end}}

+
{{t . "repo.subtitle"}}
- + - - + +
- +
- +
- 事件可直接引用 events.yaml 中的事件(如 push:),或叠加 event_sets 中的模板名称。 - / Events may reference events.yaml entries (e.g. push:) or compose event_sets names. + {{t . "repo.eventsHint"}}
- - 取消 / Cancel + + {{t . "action.cancel"}}
{{end}} diff --git a/internal/panel/templates/repos.html b/internal/panel/templates/repos.html index 9242dd1..a060f48 100644 --- a/internal/panel/templates/repos.html +++ b/internal/panel/templates/repos.html @@ -1,22 +1,23 @@ -{{define "title"}}仓库规则 · Repos{{end}} +{{define "title"}}{{t . "repos.title"}} · Feishu GitHub Tracker{{end}} {{define "content"}}
-

仓库规则 / Repo Rules

-
仓库匹配模式 → 订阅事件 → 通知目标。按顺序匹配。 / Patterns → events → notify targets. Matched in order.
+

{{t . "repos.title"}}

+
{{t . "repos.subtitle"}}
- + 新建 / New + + {{t . "action.new"}}
{{if .Repos}} +
- - - + + + @@ -26,18 +27,18 @@

仓库规则 / Repo Rules

@@ -45,8 +46,9 @@

仓库规则 / Repo Rules

{{end}}
#模式 / Pattern事件 / Events通知 / Notify to{{t . "repos.pattern"}}{{t . "repos.events"}}{{t . "repos.notifyTo"}}
{{.Index}} {{.Pattern}} - {{if gt .EventCount 0}}{{.EventCount}} 个 / events{{else}}0{{end}} - {{if .HasSecret}}🔒{{end}} + {{if gt .EventCount 0}}{{.EventCount}} {{t $ "repos.eventsCount"}}{{else}}0{{end}} + {{if .HasSecret}}🔒{{end}} {{if .NotifyTo}}{{range .NotifyTo}}{{.}}{{end}}{{else}}{{end}}
- 编辑 / Edit + {{t $ "action.edit"}}
- +
+
{{else}} -
暂无仓库规则,点击「新建」添加。 / No repo rules yet — click “New”.
+
{{t . "repos.empty"}}
{{end}}
{{end}} diff --git a/internal/panel/templates/server_settings.html b/internal/panel/templates/server_settings.html index 6291d5f..bca5ab6 100644 --- a/internal/panel/templates/server_settings.html +++ b/internal/panel/templates/server_settings.html @@ -1,29 +1,29 @@ -{{define "title"}}服务设置 · Server Settings{{end}} +{{define "title"}}{{t . "settings.title"}} · Feishu GitHub Tracker{{end}} {{define "content"}}
-

服务设置 / Server Settings

-
编辑 server.yaml。端口与密钥的修改需重启进程后生效。 / Edit server.yaml. Port and secret changes need a restart.
+

{{t . "settings.title"}}

+
{{t . "settings.subtitle"}}
- +
- +
- +
- +
- +
-
默认只使用首条匹配规则。开启后会按配置顺序检查全部匹配规则,每条规则可独立筛选事件和通知目标;同一目标在一次 webhook 中只发送一次。 / By default only the first matching rule is used. When enabled, every matching rule is evaluated in order; each filters events and selects targets independently, while duplicate targets receive one notification per webhook.
+
{{t . "settings.matchAllHint"}}
- +
-

面板账号 / Panel account

+

{{t . "settings.account"}}

- + - + - + - + -
修改密码需提供当前密码,并输入两次相同的新密码;密码会在浏览器内 SHA-256 后再传输(明文不发送到服务端),该 SHA-256 值即直接作为 password_hash 存储。 / Changing the password requires the current password and entering the new password twice; the password is SHA-256'd in the browser before sending (no plaintext on the wire), and that SHA-256 value is stored directly as password_hash.
+
{{t . "settings.passwordHint"}}
- +
- ⚠️ 保存会重新格式化 server.yaml(移除注释)。端口 / 密钥 / 面板 secret 改动需重启进程;其它改动保存后会自动 reload 生效。 / Saving reformats server.yaml (comments removed). Port / secret / panel-secret changes require a restart; everything else is reloaded automatically on save. + ⚠️ {{t . "settings.saveWarning"}}
+{{else}}
{{t . "topology.empty"}}
{{end}} +{{end}} diff --git a/internal/panel/topology.go b/internal/panel/topology.go new file mode 100644 index 0000000..ab1fb27 --- /dev/null +++ b/internal/panel/topology.go @@ -0,0 +1,83 @@ +package panel + +import ( + "fmt" + "strings" + + "github.com/hnrobert/feishu-github-tracker/internal/config" +) + +// Topology is a payload-free projection of the notification configuration. +// It deliberately preserves YAML as the source of truth; it never writes it. +type Topology struct { + Nodes []TopologyNode + Edges []TopologyEdge + Routes []TopologyRoute +} + +// TopologyRoute is the high-signal summary used by the dashboard preview. +// It keeps the full graph available without making the overview repeat every +// event and target node at the same visual weight. +type TopologyRoute struct { + Label string + Href string + EventCount int + TargetCount int +} + +type TopologyNode struct { + ID string + Kind string + Label string + Href string +} + +type TopologyEdge struct { + From string + To string +} + +func topologyFromConfig(cfg *config.Config) Topology { + if cfg == nil { + return Topology{} + } + topology := Topology{} + nodes := map[string]bool{} + addNode := func(id, kind, label, href string) { + if !nodes[id] { + nodes[id] = true + topology.Nodes = append(topology.Nodes, TopologyNode{ID: id, Kind: kind, Label: label, Href: href}) + } + } + addEdge := func(from, to string) { topology.Edges = append(topology.Edges, TopologyEdge{From: from, To: to}) } + + for index, rule := range cfg.Repos.Repos { + ruleID := fmt.Sprintf("repo-%d", index) + addNode(ruleID, "repo", rule.Pattern, fmt.Sprintf("/repos/edit?index=%d", index)) + route := TopologyRoute{Label: rule.Pattern, Href: fmt.Sprintf("/repos/edit?index=%d", index)} + for event := range rule.Events { + eventID := "event-" + event + kind := "event" + if _, ok := cfg.Events.EventSets[event]; ok { + kind = "set" + } + addNode(eventID, kind, event, "/events") + addEdge(ruleID, eventID) + route.EventCount++ + } + for _, target := range rule.NotifyTo { + id := "bot-" + target + href := "/bots" + kind := "bot" + if strings.HasPrefix(target, "http://") || strings.HasPrefix(target, "https://") { + id = fmt.Sprintf("target-%d-%d", index, len(topology.Edges)) + kind = "url" + } + addNode(id, kind, target, href) + addEdge(ruleID, id) + route.TargetCount++ + } + topology.Routes = append(topology.Routes, route) + } + return topology +} diff --git a/internal/panel/topology_test.go b/internal/panel/topology_test.go new file mode 100644 index 0000000..774ff9c --- /dev/null +++ b/internal/panel/topology_test.go @@ -0,0 +1,31 @@ +package panel + +import ( + "testing" + + "github.com/hnrobert/feishu-github-tracker/internal/config" +) + +func TestTopologyFromConfig(t *testing.T) { + cfg := &config.Config{} + cfg.Repos.Repos = []config.RepoPattern{{ + Pattern: "acme/api", + Events: map[string]any{"basic": nil, "push": nil}, + NotifyTo: []string{"dev-team", "https://example.test/hook"}, + }} + cfg.Events.EventSets = map[string]map[string]any{"basic": {"push": nil}} + + graph := topologyFromConfig(cfg) + if len(graph.Nodes) != 5 { + t.Fatalf("nodes = %d, want 5: %#v", len(graph.Nodes), graph.Nodes) + } + if len(graph.Edges) != 4 { + t.Fatalf("edges = %d, want 4: %#v", len(graph.Edges), graph.Edges) + } + if len(graph.Routes) != 1 || graph.Routes[0].EventCount != 2 || graph.Routes[0].TargetCount != 2 { + t.Fatalf("routes = %#v", graph.Routes) + } + if graph.Nodes[0].Href != "/repos/edit?index=0" { + t.Fatalf("repo href = %q", graph.Nodes[0].Href) + } +} diff --git a/internal/template/template.go b/internal/template/template.go index 57e5090..1102f44 100644 --- a/internal/template/template.go +++ b/internal/template/template.go @@ -465,6 +465,17 @@ func DetermineTags(eventType string, payload map[string]any) []string { } } + case "issue_comment": + // GitHub fires issue_comment for both issue comments and PR conversation + // comments. When the comment is on a PR, payload.issue.pull_request is + // present — tag it so a PR-specific comment template can be selected + // (without misclassifying real issue comments, which have no such field). + if issue, ok := payload["issue"].(map[string]any); ok { + if pr, ok := issue["pull_request"].(map[string]any); ok && len(pr) > 0 { + tags = append(tags, "pull_request") + } + } + case "workflow_run": // For workflow runs, emit tags that describe completion and outcome so // templates can select success/failure-specific payloads.