Files
havline-native/app-src/internal/logstore/reader.go
T

424 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 ""
}
}