diff --git a/internal/panel/app.go b/internal/panel/app.go index 9e0df3d..dd3b3ad 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,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) @@ -189,6 +195,7 @@ func New(opts Options) (*App, error) { for _, page := range []string{ "login", "dashboard", + "topology", "repos", "repo_edit", "bots", @@ -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)) @@ -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 @@ -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") +} 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..f61514a --- /dev/null +++ b/internal/panel/locales/en-US.json @@ -0,0 +1,159 @@ +{ + "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": "Saving reformats server.yaml and removes comments. 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." +} diff --git a/internal/panel/locales/zh-CN.json b/internal/panel/locales/zh-CN.json new file mode 100644 index 0000000..aac316b --- /dev/null +++ b/internal/panel/locales/zh-CN.json @@ -0,0 +1,159 @@ +{ + "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": "保存会重新格式化 server.yaml 并移除注释。端口、密钥和面板密钥的修改需重启服务;其它设置会立即生效。", + "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": "服务设置和密码已保存。" +} 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"}}
Settings → Webhooks → Add webhook。Payload URL 填下方地址;Content type 选什么都支持;Secret 填你在 server.yaml 或对应仓库规则里配置的 secret(若有)。
- {{if .PayloadURL}}
- {{.PayloadURL}}
-
- server.yaml 中配置 panel.public_url。 / Accessed locally — open via a public host, or set panel.public_url.{{.ServerInfo.Host}}:{{.ServerInfo.Port}}{{.ServerInfo.Host}}:{{.ServerInfo.Port}}event_sets 与 events)。保存时会校验 YAML 语法。 / Edit the raw events.yaml; YAML is validated on save.