feat: create standalone native fnOS repository
This commit is contained in:
@@ -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: "/havline-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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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, "/havline-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 ""
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user