chore: 初始提交 Fonu 1.0.2

- 公网反代:Nginx 状态卡片(版本 / 运行状态 / 配置校验 / 端口监听)
- 部署设置第一、二梯队(HTTPS 开关、安全头、限流、连接数、仅中国大陆 IP)
- 隧道检测兼容 frps v0.68 / v0.71(V2 与旧版端点)
- 恢复被清空的 internal/frp/store.go、internal/api/ddns_handler.go、web/src/views/DdnsView.vue
This commit is contained in:
2026-09-23 09:40:59 +08:00
commit 278f8267b7
333 changed files with 53562 additions and 0 deletions
+46
View File
@@ -0,0 +1,46 @@
package logstore
import "testing"
func TestParseRealNginxAccessLine(t *testing.T) {
line := "2026-09-12T19:55:18+08:00 127.0.0.1 GET / 200 0.000 127.0.0.1 [::1]:5173"
entry, ok := parseAccess(line)
if !ok {
t.Fatal("parse failed")
}
if entry.Status != 200 {
t.Fatalf("status %d", entry.Status)
}
}
func TestParseAccessWithBytes(t *testing.T) {
line := "2026-09-12T19:55:18+08:00 speedtest.example.com 6893 GET /api 200 0.010 1.2.3.4 192.168.1.1:8080 512 4096"
entry, ok := parseAccess(line)
if !ok {
t.Fatal("parse failed")
}
if entry.ServerPort != 6893 {
t.Fatalf("server_port=%d", entry.ServerPort)
}
if entry.RequestLength != 512 || entry.BytesSent != 4096 {
t.Fatalf("bytes upload=%d download=%d", entry.RequestLength, entry.BytesSent)
}
}
func TestAccessEndpointFilterMatchesPort(t *testing.T) {
line := "2026-09-12T19:55:18+08:00 speedtest.example.com 6893 GET /api 200 0.010 1.2.3.4 192.168.1.1:8080"
other := "2026-09-12T19:55:19+08:00 speedtest.example.com 8443 GET / 404 0.010 1.2.3.4 192.168.1.2:8080"
filter := AccessEndpointFilter([]AccessEndpoint{{Host: "speedtest.example.com", Port: 6893}})
if filter == nil || !filter(line) || filter(other) {
t.Fatal("endpoint filter mismatch")
}
}
func TestAccessHostFilter(t *testing.T) {
line := "2026-09-12T19:55:18+08:00 speedtest.example.com GET /api 200 0.010 1.2.3.4 192.168.1.1:8080"
other := "2026-09-12T19:55:19+08:00 other.example.com GET / 404 0.010 1.2.3.4 192.168.1.2:8080"
filter := AccessHostFilter([]string{"speedtest.example.com"})
if filter == nil || !filter(line) || filter(other) {
t.Fatal("host filter mismatch")
}
}
@@ -0,0 +1,37 @@
package logstore
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func TestDashboardAccessFilterSkipsNoisePaths(t *testing.T) {
if isDashboardAccess(AccessEntry{Path: "/login"}) != true {
t.Fatal("expected /login to count")
}
if isDashboardAccess(AccessEntry{Path: "/favicon.ico"}) {
t.Fatal("expected favicon to be excluded")
}
if isDashboardAccess(AccessEntry{Path: "/fonu-errors/error.png"}) {
t.Fatal("expected error asset to be excluded")
}
}
func TestCountAccessForDayExcludesNoisePaths(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "access.log")
now := time.Now()
line := formatAccessLine(now, "/login")
noise := formatAccessLine(now, "/favicon.ico")
if err := os.WriteFile(path, []byte(strings.Join([]string{line, noise}, "\n")), 0o644); err != nil {
t.Fatal(err)
}
total, _, _ := CountAccessForDay(path, now)
if total != 1 {
t.Fatalf("expected 1 dashboard request, got %d", total)
}
}
+211
View File
@@ -0,0 +1,211 @@
package logstore
import (
"fmt"
"strings"
)
func localizeSystemEntry(entry SystemEntry, raw map[string]any) SystemEntry {
msg := strings.TrimSpace(entry.Message)
if msg == "" {
return entry
}
entry.Module = localizeSystemModule(entry.Module)
entry.Message = localizeSystemMessage(msg, raw)
return entry
}
func localizeSystemModule(module string) string {
switch strings.ToUpper(strings.TrimSpace(module)) {
case "SYSTEM":
return "系统"
case "NGINX":
return "Nginx"
case "DDNS":
return "域名解析"
case "ACME", "CERT":
return "证书"
case "CHINA_CIDR":
return "中国 IP 段"
case "API":
return "API"
case "HTTP":
return "HTTP"
case "FRP":
return "内网穿透"
default:
if module == "" {
return "系统"
}
return module
}
}
func localizeSystemMessage(msg string, raw map[string]any) string {
if looksChinese(msg) {
return appendSystemAttrs(msg, raw)
}
switch msg {
case "application started":
if listen := rawString(raw, "listen"); listen != "" {
return fmt.Sprintf("应用已启动,监听 %s", listen)
}
return "应用已启动"
case "application shutting down":
return "应用正在关闭"
case "nginx started":
return "Nginx 已启动"
case "nginx stopped":
return "Nginx 已停止"
case "nginx reloaded":
return "Nginx 已重载"
case "nginx reloaded existing master":
return "Nginx 已重载(使用现有主进程)"
case "nginx start skipped duplicate master, reloaded instead":
return "检测到重复 Nginx 主进程,已改为重载"
case "nginx reload failed, trying restart":
return fmt.Sprintf("Nginx 重载失败,正在尝试重启:%s", rawString(raw, "error"))
case "nginx signal reload failed, falling back to cli":
return fmt.Sprintf("Nginx 信号重载失败,回退到命令行重载(PID %s):%s", rawString(raw, "pid"), rawString(raw, "error"))
case "nginx command failed":
return fmt.Sprintf("Nginx 命令执行失败(%s):%s", rawString(raw, "args"), rawString(raw, "error"))
case "nginx start failed":
return fmt.Sprintf("Nginx 启动失败(%s):%s", rawString(raw, "args"), rawString(raw, "error"))
case "nginx binary not found, applied syntax-only validation":
return fmt.Sprintf("未找到 Nginx 可执行文件,仅进行配置语法校验(%s)", rawString(raw, "bin"))
case "nginx binary not found, syntax-only validation":
return fmt.Sprintf("未找到 Nginx 可执行文件,仅进行语法校验(%s)", rawString(raw, "bin"))
case "initial nginx apply skipped":
return fmt.Sprintf("初始 Nginx 配置应用已跳过:%s", rawString(raw, "error"))
case "initial frpc bootstrap skipped":
return fmt.Sprintf("初始 frpc 引导已跳过:%s", rawString(raw, "error"))
case "nginx stop failed":
return fmt.Sprintf("Nginx 停止失败:%s", rawString(raw, "error"))
case "frpc stop failed":
return fmt.Sprintf("frpc 停止失败:%s", rawString(raw, "error"))
case "frpc started":
return "frpc 已启动"
case "frpc stopped":
return "frpc 已停止"
case "frpc reloaded":
return "frpc 已重载"
case "frpc reload failed, trying restart":
return fmt.Sprintf("frpc 重载失败,正在尝试重启:%s", rawString(raw, "error"))
case "frpc binary not found, skipping bootstrap":
return fmt.Sprintf("未找到 frpc 可执行文件,已跳过引导(%s)", rawString(raw, "bin"))
case "initial frpc apply skipped":
return fmt.Sprintf("初始 frpc 配置应用已跳过:%s", rawString(raw, "error"))
case "frpc exited during start":
return fmt.Sprintf("frpc 启动过程中退出:%s", rawString(raw, "error"))
case "china cidr update failed":
return fmt.Sprintf("中国 IP 段更新失败:%s", rawString(raw, "error"))
case "china cidr updated":
return fmt.Sprintf("中国 IP 段已更新(IPv4 %s 条,IPv6 %s 条)", rawString(raw, "v4"), rawString(raw, "v6"))
case "ddns update failed":
return fmt.Sprintf("DDNS 更新失败(%s):%s", rawString(raw, "domain"), rawString(raw, "error"))
case "ddns ipv4 updated":
return fmt.Sprintf("DDNS IPv4 已更新:%s → %s", rawString(raw, "domain"), rawString(raw, "ip"))
case "ddns ipv6 updated":
return fmt.Sprintf("DDNS IPv6 已更新:%s → %s", rawString(raw, "domain"), rawString(raw, "ip"))
case "ddns records updated":
return fmt.Sprintf("DDNS 记录已更新(服务商:%s)", rawString(raw, "provider"))
case "notify load config failed":
return fmt.Sprintf("通知配置加载失败:%s", rawString(raw, "error"))
case "notify send failed":
return fmt.Sprintf("通知发送失败(%s):%s", rawString(raw, "event"), rawString(raw, "error"))
case "notify test send failed":
return fmt.Sprintf("通知测试发送失败:%s", rawString(raw, "error"))
case "request failed":
return fmt.Sprintf("API 请求失败(%s %s,状态 %s):%s",
rawString(raw, "method"), rawString(raw, "path"), rawString(raw, "status"), rawString(raw, "message"))
case "request rejected":
return fmt.Sprintf("API 请求被拒绝(%s %s,状态 %s):%s",
rawString(raw, "method"), rawString(raw, "path"), rawString(raw, "status"), rawString(raw, "message"))
case "api request completed with client error":
return fmt.Sprintf("API 请求返回客户端错误(%s %s,状态 %s,耗时 %sms)",
rawString(raw, "method"), rawString(raw, "path"), rawString(raw, "status"), rawString(raw, "duration_ms"))
case "api request failed":
return fmt.Sprintf("API 请求返回服务端错误(%s %s,状态 %s,耗时 %sms)",
rawString(raw, "method"), rawString(raw, "path"), rawString(raw, "status"), rawString(raw, "duration_ms"))
case "certificate operation failed":
return fmt.Sprintf("证书操作失败(%s):%s", rawString(raw, "domain"), rawString(raw, "error"))
case "certificate obtained":
return fmt.Sprintf("证书已获取:%s(CA %s,到期 %s)",
rawString(raw, "domain"), rawString(raw, "ca"), rawString(raw, "expires_at"))
case "certificate imported":
return fmt.Sprintf("证书已导入:%s(到期 %s)", rawString(raw, "domain"), rawString(raw, "expires_at"))
case "certificate deleted":
return fmt.Sprintf("证书已删除:%s", rawString(raw, "domain"))
case "certificate renew failed":
return fmt.Sprintf("证书续签失败(%s):%s", rawString(raw, "domain"), rawString(raw, "error"))
case "save cert error status failed":
return fmt.Sprintf("保存证书错误状态失败(%s):%s", rawString(raw, "domain"), rawString(raw, "error"))
case "nginx reload after cert apply failed":
return fmt.Sprintf("证书申请后 Nginx 重载失败:%s", rawString(raw, "error"))
case "nginx reload after cert import failed":
return fmt.Sprintf("证书导入后 Nginx 重载失败:%s", rawString(raw, "error"))
case "nginx reload after cert delete failed":
return fmt.Sprintf("证书删除后 Nginx 重载失败:%s", rawString(raw, "error"))
case "nginx reload after cert renew failed":
return fmt.Sprintf("证书续签后 Nginx 重载失败:%s", rawString(raw, "error"))
case "save acme ca setting failed":
return fmt.Sprintf("保存 ACME 颁发机构设置失败:%s", rawString(raw, "error"))
case "save acme email failed":
return fmt.Sprintf("保存 ACME 邮箱失败:%s", rawString(raw, "error"))
case "remove cert files failed":
return fmt.Sprintf("删除证书文件失败(%s):%s", rawString(raw, "domain"), rawString(raw, "error"))
default:
return appendSystemAttrs(msg, raw)
}
}
func appendSystemAttrs(msg string, raw map[string]any) string {
if strings.Contains(msg, "首次启动已创建管理员账户") {
username := rawString(raw, "username")
password := rawString(raw, "password")
if username != "" && password != "" {
return fmt.Sprintf("%s(用户名:%s,密码:%s)", msg, username, password)
}
}
if err := rawString(raw, "error"); err != "" && !strings.Contains(msg, err) {
return fmt.Sprintf("%s:%s", msg, err)
}
return msg
}
func looksChinese(msg string) bool {
for _, r := range msg {
if r >= 0x4e00 && r <= 0x9fff {
return true
}
}
return false
}
func rawString(raw map[string]any, key string) string {
v, ok := raw[key]
if !ok || v == nil {
return ""
}
switch t := v.(type) {
case string:
return t
case float64:
if t == float64(int64(t)) {
return fmt.Sprintf("%d", int64(t))
}
return fmt.Sprintf("%v", t)
case int:
return fmt.Sprintf("%d", t)
case int64:
return fmt.Sprintf("%d", t)
case bool:
if t {
return "true"
}
return "false"
default:
return fmt.Sprintf("%v", t)
}
}
+54
View File
@@ -0,0 +1,54 @@
package logstore
import "testing"
func TestLocalizeSystemMessage(t *testing.T) {
raw := map[string]any{
"msg": "application started",
"listen": ":6893",
}
entry := localizeSystemEntry(SystemEntry{
Level: "INFO",
Module: "SYSTEM",
Message: "application started",
}, raw)
if entry.Message != "应用已启动,监听 :6893" {
t.Fatalf("unexpected message: %q", entry.Message)
}
if entry.Module != "系统" {
t.Fatalf("unexpected module: %q", entry.Module)
}
}
func TestLocalizeSystemMessageAdminBootstrap(t *testing.T) {
raw := map[string]any{
"msg": "首次启动已创建管理员账户,请使用以下凭据登录,并在「设置」中尽快修改密码",
"username": "admin",
"password": "secret",
}
entry := localizeSystemEntry(SystemEntry{
Message: "首次启动已创建管理员账户,请使用以下凭据登录,并在「设置」中尽快修改密码",
Module: "SYSTEM",
}, raw)
want := "首次启动已创建管理员账户,请使用以下凭据登录,并在「设置」中尽快修改密码(用户名:admin,密码:secret)"
if entry.Message != want {
t.Fatalf("unexpected message: %q", entry.Message)
}
}
func TestLocalizeSystemMessageAPIRejected(t *testing.T) {
raw := map[string]any{
"msg": "request rejected",
"method": "POST",
"path": "/api/settings/notify/test",
"status": 400,
"message": "Telegram Bot Token 未配置",
}
entry := localizeSystemEntry(SystemEntry{Message: "request rejected", Module: "API"}, raw)
if entry.Message != "API 请求被拒绝(POST /api/settings/notify/test,状态 400):Telegram Bot Token 未配置" {
t.Fatalf("unexpected message: %q", entry.Message)
}
if entry.Module != "API" {
t.Fatalf("unexpected module: %q", entry.Module)
}
}
+52
View File
@@ -0,0 +1,52 @@
package logstore
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func TestHourlyAccessCountsCalendarDay(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "access.log")
now := time.Now()
todayMorning := time.Date(now.Year(), now.Month(), now.Day(), 8, 15, 0, 0, now.Location())
todayNoon := time.Date(now.Year(), now.Month(), now.Day(), 11, 30, 0, 0, now.Location())
yesterday := todayMorning.Add(-24 * time.Hour)
lines := []string{
formatAccessLine(todayMorning, "/"),
formatAccessLine(todayMorning.Add(30*time.Minute), "/"),
formatAccessLine(todayNoon, "/"),
formatAccessLine(yesterday, "/"),
}
if err := os.WriteFile(path, []byte(strings.Join(lines, "\n")), 0o644); err != nil {
t.Fatal(err)
}
got, labels := HourlyAccessCounts(path)
if len(got) != 12 || len(labels) != 12 {
t.Fatalf("expected 12 buckets, got %d counts / %d labels", len(got), len(labels))
}
if labels[0] != "00:00" || labels[11] != "22:00" {
t.Fatalf("unexpected labels: %v", labels)
}
sum := 0
for _, n := range got {
sum += n
}
if sum != 3 {
t.Fatalf("expected 3 requests today, got %d (%v)", sum, got)
}
currentBucket := now.Hour() / 2
for i := currentBucket + 1; i < 12; i++ {
if got[i] != 0 {
t.Fatalf("future bucket %d should be 0, got %d", i, got[i])
}
}
}
func formatAccessLine(at time.Time, path string) string {
return at.Format(time.RFC3339) + " app.example.com GET " + path + " 200 0.010 1.2.3.4 127.0.0.1:8080"
}
+423
View File
@@ -0,0 +1,423 @@
package logstore
import (
"bufio"
"encoding/json"
"fmt"
"os"
"regexp"
"strconv"
"strings"
"time"
)
type AccessEntry struct {
Time string `json:"time"`
Domain string `json:"domain"`
ServerPort int `json:"server_port,omitempty"`
Method string `json:"method"`
Path string `json:"path"`
Status int `json:"status"`
ResponseTime float64 `json:"response_time"`
ClientIP string `json:"client_ip"`
Upstream string `json:"upstream"`
RequestLength int64 `json:"request_length,omitempty"`
BytesSent int64 `json:"bytes_sent,omitempty"`
}
type SystemEntry struct {
Time string `json:"time"`
Level string `json:"level"`
Module string `json:"module"`
Message string `json:"message"`
}
var accessRe = regexp.MustCompile(`^(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\d{3})\s+([\d.]+)\s+(\S+)\s+(\S+)$`)
var accessReWithBytes = regexp.MustCompile(`^(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\d{3})\s+([\d.]+)\s+(\S+)\s+(\S+)\s+(\d+)\s+(\d+)$`)
var accessReWithPort = regexp.MustCompile(`^(\S+)\s+(\S+)\s+(\d+)\s+(\S+)\s+(\S+)\s+(\d{3})\s+([\d.]+)\s+(\S+)\s+(\S+)$`)
var accessReWithPortAndBytes = regexp.MustCompile(`^(\S+)\s+(\S+)\s+(\d+)\s+(\S+)\s+(\S+)\s+(\d{3})\s+([\d.]+)\s+(\S+)\s+(\S+)\s+(\d+)\s+(\d+)$`)
func ParseAccess(line string) (AccessEntry, bool) {
return parseAccess(line)
}
func ReadAccess(path string, limit int, keyword string, status int) ([]AccessEntry, error) {
lines, err := tailLines(path, limit*4)
if err != nil {
return nil, err
}
var out []AccessEntry
for i := len(lines) - 1; i >= 0 && len(out) < limit; i-- {
entry, ok := parseAccess(lines[i])
if !ok {
continue
}
if keyword != "" && !strings.Contains(strings.ToLower(entry.Domain+" "+entry.Path), strings.ToLower(keyword)) {
continue
}
if status > 0 && entry.Status != status {
continue
}
out = append(out, entry)
}
return out, nil
}
func AccessHostFilter(hosts []string) LineFilter {
if len(hosts) == 0 {
return nil
}
endpoints := make([]AccessEndpoint, 0, len(hosts))
for _, host := range hosts {
endpoints = append(endpoints, AccessEndpoint{Host: host, Port: 0})
}
return AccessEndpointFilter(endpoints)
}
type AccessEndpoint struct {
Host string
Port int
}
func AccessEndpointFilter(endpoints []AccessEndpoint) LineFilter {
if len(endpoints) == 0 {
return nil
}
exact := make(map[string]bool, len(endpoints))
hosts := make(map[string]bool, len(endpoints))
for _, ep := range endpoints {
host := normalizeAccessHost(ep.Host)
if host == "" {
continue
}
hosts[host] = true
if ep.Port > 0 {
exact[endpointMatchKey(host, ep.Port)] = true
}
}
if len(hosts) == 0 {
return nil
}
return func(line string) bool {
entry, ok := parseAccess(line)
if !ok {
return false
}
host := normalizeAccessHost(entry.Domain)
if entry.ServerPort > 0 {
return exact[endpointMatchKey(host, entry.ServerPort)]
}
return hosts[host]
}
}
func parseAccess(line string) (AccessEntry, bool) {
line = strings.TrimSpace(line)
if m := accessReWithPortAndBytes.FindStringSubmatch(line); len(m) == 12 {
status, _ := strconv.Atoi(m[6])
rt, _ := strconv.ParseFloat(m[7], 64)
port, _ := strconv.Atoi(m[3])
reqLen, _ := strconv.ParseInt(m[10], 10, 64)
bytesSent, _ := strconv.ParseInt(m[11], 10, 64)
return AccessEntry{
Time: m[1],
Domain: m[2],
ServerPort: port,
Method: m[4],
Path: m[5],
Status: status,
ResponseTime: rt,
ClientIP: m[8],
Upstream: m[9],
RequestLength: reqLen,
BytesSent: bytesSent,
}, true
}
if m := accessReWithPort.FindStringSubmatch(line); len(m) == 10 {
status, _ := strconv.Atoi(m[6])
rt, _ := strconv.ParseFloat(m[7], 64)
port, _ := strconv.Atoi(m[3])
return AccessEntry{
Time: m[1],
Domain: m[2],
ServerPort: port,
Method: m[4],
Path: m[5],
Status: status,
ResponseTime: rt,
ClientIP: m[8],
Upstream: m[9],
}, true
}
if m := accessReWithBytes.FindStringSubmatch(line); len(m) == 11 {
status, _ := strconv.Atoi(m[5])
rt, _ := strconv.ParseFloat(m[6], 64)
reqLen, _ := strconv.ParseInt(m[9], 10, 64)
bytesSent, _ := strconv.ParseInt(m[10], 10, 64)
return AccessEntry{
Time: m[1],
Domain: m[2],
Method: m[3],
Path: m[4],
Status: status,
ResponseTime: rt,
ClientIP: m[7],
Upstream: m[8],
RequestLength: reqLen,
BytesSent: bytesSent,
}, true
}
m := accessRe.FindStringSubmatch(line)
if len(m) != 9 {
return AccessEntry{}, false
}
status, _ := strconv.Atoi(m[5])
rt, _ := strconv.ParseFloat(m[6], 64)
return AccessEntry{
Time: m[1],
Domain: m[2],
Method: m[3],
Path: m[4],
Status: status,
ResponseTime: rt,
ClientIP: m[7],
Upstream: m[8],
}, true
}
func normalizeAccessHost(host string) string {
host = strings.ToLower(strings.TrimSpace(host))
if i := strings.Index(host, ":"); i > 0 {
host = host[:i]
}
return host
}
func endpointMatchKey(host string, port int) string {
return fmt.Sprintf("%s:%d", normalizeAccessHost(host), port)
}
func ReadError(path string, limit int, keyword string) ([]string, error) {
lines, err := tailLines(path, limit*2)
if err != nil {
return nil, err
}
var out []string
for i := len(lines) - 1; i >= 0 && len(out) < limit; i-- {
line := strings.TrimSpace(lines[i])
if line == "" {
continue
}
if keyword != "" && !strings.Contains(strings.ToLower(line), strings.ToLower(keyword)) {
continue
}
out = append(out, line)
}
return out, nil
}
func isNoiseSystemEntry(entry SystemEntry) bool {
return entry.Module == "HTTP" && strings.EqualFold(entry.Level, "INFO")
}
func ReadSystem(path string, limit int, level string, keyword string) ([]SystemEntry, error) {
lines, err := tailLines(path, limit*20)
if err != nil {
return nil, err
}
var out []SystemEntry
for i := len(lines) - 1; i >= 0 && len(out) < limit; i-- {
entry, ok := parseSystem(lines[i])
if !ok {
continue
}
if isNoiseSystemEntry(entry) {
continue
}
if level != "" && !strings.EqualFold(entry.Level, level) {
continue
}
if keyword != "" && !strings.Contains(strings.ToLower(entry.Message+" "+entry.Module), strings.ToLower(keyword)) {
continue
}
out = append(out, entry)
}
return out, nil
}
func parseSystem(line string) (SystemEntry, bool) {
var raw map[string]any
if err := json.Unmarshal([]byte(line), &raw); err != nil {
return SystemEntry{}, false
}
entry := SystemEntry{
Time: asString(raw["time"]),
Level: asString(raw["level"]),
Message: asString(raw["msg"]),
Module: asString(raw["module"]),
}
if entry.Time == "" {
entry.Time = time.Now().UTC().Format(time.RFC3339)
}
entry = localizeSystemEntry(entry, raw)
return entry, entry.Message != ""
}
const hourlyBucketCount = 12
func isDashboardAccess(entry AccessEntry) bool {
switch entry.Path {
case "/favicon.ico", "/robots.txt":
return false
}
if strings.HasPrefix(entry.Path, "/fonu-errors/") {
return false
}
return true
}
func isSameCalendarDay(t, day time.Time) bool {
lt := t.In(day.Location())
dy, dm, dd := day.Date()
ty, tm, td := lt.Date()
return ty == dy && tm == dm && td == dd
}
// HourlyAccessCounts returns today's request counts on a fixed 0–24h axis (12 two-hour buckets).
// Future buckets (after the current time) stay zero.
func HourlyAccessCounts(path string) ([]int, []string) {
return hourlyAccessCountsForDay(path, time.Now())
}
func hourlyAccessCountsForDay(path string, day time.Time) ([]int, []string) {
now := time.Now()
currentBucket := now.Hour() / 2
if !isSameCalendarDay(day, now) {
currentBucket = hourlyBucketCount - 1
}
counts := make([]int, hourlyBucketCount)
labels := calendarHourlyLabels()
file, err := os.Open(path)
if err != nil {
return counts, labels
}
defer file.Close()
scanner := bufio.NewScanner(file)
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
for scanner.Scan() {
entry, ok := parseAccess(scanner.Text())
if !ok || !isDashboardAccess(entry) {
continue
}
t, ok := parseAccessTime(entry.Time)
if !ok || !isSameCalendarDay(t, day) {
continue
}
bucket := t.In(day.Location()).Hour() / 2
if bucket < 0 || bucket > currentBucket {
continue
}
counts[bucket]++
}
return counts, labels
}
func calendarHourlyLabels() []string {
labels := make([]string, hourlyBucketCount)
for i := 0; i < hourlyBucketCount; i++ {
labels[i] = fmt.Sprintf("%02d:00", i*2)
}
return labels
}
func parseAccessTime(raw string) (time.Time, bool) {
raw = strings.TrimSpace(raw)
for _, layout := range []string{
time.RFC3339Nano,
time.RFC3339,
"2006-01-02T15:04:05Z07:00",
"2006-01-02T15:04:05Z0700",
"2006-01-02T15:04:05",
"2006-01-02 15:04:05",
} {
if t, err := time.Parse(layout, raw); err == nil {
return t, true
}
}
return time.Time{}, false
}
func isLocalToday(t time.Time, now time.Time) bool {
return isSameCalendarDay(t, now)
}
func CountTodayAccess(path string) (total int, errors int, avgMs float64) {
return CountAccessForDay(path, time.Now())
}
func CountAccessForDay(path string, day time.Time) (total int, errors int, avgMs float64) {
file, err := os.Open(path)
if err != nil {
return 0, 0, 0
}
defer file.Close()
var sum float64
scanner := bufio.NewScanner(file)
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
for scanner.Scan() {
entry, ok := parseAccess(scanner.Text())
if !ok || !isDashboardAccess(entry) {
continue
}
t, ok := parseAccessTime(entry.Time)
if !ok || !isSameCalendarDay(t, day) {
continue
}
total++
sum += entry.ResponseTime
if entry.Status >= 400 {
errors++
}
}
if total > 0 {
avgMs = sum / float64(total) * 1000
}
return total, errors, avgMs
}
func tailLines(path string, max int) ([]string, error) {
file, err := os.Open(path)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
defer file.Close()
scanner := bufio.NewScanner(file)
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
var lines []string
for scanner.Scan() {
lines = append(lines, scanner.Text())
if len(lines) > max {
lines = lines[1:]
}
}
return lines, scanner.Err()
}
func asString(v any) string {
if v == nil {
return ""
}
switch t := v.(type) {
case string:
return t
default:
return ""
}
}
+78
View File
@@ -0,0 +1,78 @@
package logstore
import (
"bufio"
"context"
"fmt"
"io"
"os"
"strings"
"time"
)
type LineFilter func(line string) bool
func StreamFile(ctx context.Context, path string, w io.Writer, flush func() error, tail int, filter LineFilter) error {
file, err := os.Open(path)
if err != nil {
if os.IsNotExist(err) {
fmt.Fprintf(w, "event: info\ndata: 日志文件尚未生成\n\n")
_ = flush()
return nil
}
return err
}
defer file.Close()
if tail > 0 {
lines, err := tailLines(path, tail)
if err != nil {
return err
}
for _, line := range lines {
if strings.TrimSpace(line) == "" {
continue
}
if filter != nil && !filter(line) {
continue
}
fmt.Fprintf(w, "event: log\ndata: %s\n\n", trimSSE(line+"\n"))
if err := flush(); err != nil {
return err
}
}
}
if _, err := file.Seek(0, io.SeekEnd); err != nil {
return err
}
reader := bufio.NewReader(file)
for {
if ctx.Err() != nil {
return ctx.Err()
}
line, err := reader.ReadString('\n')
if err != nil {
if err == io.EOF {
time.Sleep(500 * time.Millisecond)
continue
}
return err
}
if filter != nil && !filter(strings.TrimRight(line, "\r\n")) {
continue
}
fmt.Fprintf(w, "event: log\ndata: %s\n\n", trimSSE(line))
if err := flush(); err != nil {
return err
}
}
}
func trimSSE(line string) string {
line = line[:len(line)-1]
if len(line) > 0 && line[len(line)-1] == '\r' {
line = line[:len(line)-1]
}
return line
}
+22
View File
@@ -0,0 +1,22 @@
package logstore
import (
"io"
"os"
"path/filepath"
"gopkg.in/natefinch/lumberjack.v2"
)
func NewAppLogWriter(logsDir string) io.Writer {
if err := os.MkdirAll(logsDir, 0o755); err != nil {
return os.Stdout
}
return &lumberjack.Logger{
Filename: filepath.Join(logsDir, "app.log"),
MaxSize: 20,
MaxBackups: 10,
MaxAge: 30,
Compress: true,
}
}