mirror of
https://github.com/Alexander-D-Karpov/about.git
synced 2026-03-16 22:06:08 +03:00
540 lines
14 KiB
Go
540 lines
14 KiB
Go
package plugins
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"html/template"
|
|
"log"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/Alexander-D-Karpov/about/internal/storage"
|
|
"github.com/Alexander-D-Karpov/about/internal/stream"
|
|
)
|
|
|
|
type VisitorsPlugin struct {
|
|
storage *storage.Storage
|
|
hub *stream.Hub
|
|
visitCount int64
|
|
todayCount int64
|
|
currentDay string
|
|
dataPath string
|
|
mutex sync.RWMutex
|
|
lastSaveTime time.Time
|
|
dailyStats map[string]int64
|
|
}
|
|
|
|
type VisitorsData struct {
|
|
TotalVisits int64 `json:"total_visits"`
|
|
TodayVisits int64 `json:"today_visits"`
|
|
CurrentDay string `json:"current_day"`
|
|
LastUpdate time.Time `json:"last_update"`
|
|
DailyStats map[string]int64 `json:"daily_stats"`
|
|
MonthlyStats map[string]int64 `json:"monthly_stats"`
|
|
}
|
|
|
|
func NewVisitorsPlugin(storage *storage.Storage, hub *stream.Hub, dataPath string) *VisitorsPlugin {
|
|
plugin := &VisitorsPlugin{
|
|
storage: storage,
|
|
hub: hub,
|
|
dataPath: dataPath,
|
|
dailyStats: make(map[string]int64),
|
|
currentDay: time.Now().Format("2006-01-02"),
|
|
}
|
|
|
|
log.Printf("[Visitors] Initializing plugin, data path: %s", dataPath)
|
|
|
|
plugin.loadVisitorsData()
|
|
|
|
log.Printf("[Visitors] Loaded data: total=%d, today=%d, current_day=%s",
|
|
plugin.visitCount, plugin.todayCount, plugin.currentDay)
|
|
|
|
plugin.checkDayTransition()
|
|
|
|
if os.Getenv("VISITORS_DEBUG") == "true" {
|
|
log.Printf("[Visitors] DEBUG MODE: Testing day transition immediately")
|
|
plugin.testDayTransition()
|
|
}
|
|
|
|
go plugin.startDayChecker()
|
|
|
|
return plugin
|
|
}
|
|
|
|
func (p *VisitorsPlugin) Name() string {
|
|
return "visitors"
|
|
}
|
|
|
|
func (p *VisitorsPlugin) startDayChecker() {
|
|
ticker := time.NewTicker(time.Minute)
|
|
defer ticker.Stop()
|
|
|
|
log.Printf("[Visitors] Day checker started, will check every minute")
|
|
|
|
for range ticker.C {
|
|
log.Printf("[Visitors] Day checker tick at %s", time.Now().Format("15:04:05"))
|
|
p.checkDayTransition()
|
|
}
|
|
}
|
|
|
|
func (p *VisitorsPlugin) checkDayTransition() {
|
|
p.mutex.Lock()
|
|
today := time.Now().Format("2006-01-02")
|
|
needsSave := false
|
|
var oldDay string
|
|
var oldCount int64
|
|
|
|
if today != p.currentDay {
|
|
log.Printf("[Visitors] Day transition detected: %s -> %s (today count: %d)", p.currentDay, today, p.todayCount)
|
|
|
|
if p.currentDay != "" && p.todayCount > 0 {
|
|
p.dailyStats[p.currentDay] = p.todayCount
|
|
oldDay = p.currentDay
|
|
oldCount = p.todayCount
|
|
}
|
|
|
|
p.currentDay = today
|
|
p.todayCount = 0
|
|
needsSave = true
|
|
}
|
|
p.mutex.Unlock()
|
|
|
|
if needsSave {
|
|
log.Printf("[Visitors] Saving day transition data for %s with %d visits", oldDay, oldCount)
|
|
|
|
go func() {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
if err := p.saveVisitorsDataAsync(ctx); err != nil {
|
|
log.Printf("[Visitors] ERROR: Failed to save day transition data: %v", err)
|
|
} else {
|
|
log.Printf("[Visitors] Successfully saved day transition data")
|
|
}
|
|
|
|
p.broadcastUpdate()
|
|
}()
|
|
}
|
|
}
|
|
|
|
func (p *VisitorsPlugin) saveVisitorsDataAsync(ctx context.Context) error {
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
default:
|
|
}
|
|
|
|
p.mutex.RLock()
|
|
visitorsData := VisitorsData{
|
|
TotalVisits: p.visitCount,
|
|
TodayVisits: p.todayCount,
|
|
CurrentDay: p.currentDay,
|
|
LastUpdate: time.Now(),
|
|
DailyStats: make(map[string]int64),
|
|
MonthlyStats: make(map[string]int64),
|
|
}
|
|
|
|
for k, v := range p.dailyStats {
|
|
visitorsData.DailyStats[k] = v
|
|
}
|
|
|
|
month := time.Now().Format("2006-01")
|
|
visitorsData.MonthlyStats[month] = p.visitCount
|
|
p.mutex.RUnlock()
|
|
|
|
data, err := json.MarshalIndent(visitorsData, "", " ")
|
|
if err != nil {
|
|
return fmt.Errorf("marshal error: %w", err)
|
|
}
|
|
|
|
dataFile := filepath.Join(p.dataPath, "visitors.json")
|
|
if err := os.MkdirAll(filepath.Dir(dataFile), 0755); err != nil {
|
|
return fmt.Errorf("create dir error: %w", err)
|
|
}
|
|
|
|
tempFile := dataFile + ".tmp"
|
|
if err := os.WriteFile(tempFile, data, 0644); err != nil {
|
|
return fmt.Errorf("write temp file error: %w", err)
|
|
}
|
|
|
|
if err := os.Rename(tempFile, dataFile); err != nil {
|
|
os.Remove(tempFile)
|
|
return fmt.Errorf("rename file error: %w", err)
|
|
}
|
|
|
|
log.Printf("[Visitors] Data saved: total=%d, today=%d, day=%s",
|
|
visitorsData.TotalVisits, visitorsData.TodayVisits, visitorsData.CurrentDay)
|
|
|
|
return nil
|
|
}
|
|
|
|
func (p *VisitorsPlugin) Render(ctx context.Context) (string, error) {
|
|
config := p.storage.GetPluginConfig(p.Name())
|
|
settings := config.Settings
|
|
|
|
showVisitors := p.getConfigBool(settings, "ui.showVisitors", true)
|
|
if !showVisitors {
|
|
return "", nil
|
|
}
|
|
|
|
p.mutex.RLock()
|
|
totalVisits := p.visitCount
|
|
todayVisits := p.todayCount
|
|
p.mutex.RUnlock()
|
|
|
|
totalFormatted := formatNumber(totalVisits)
|
|
totalExact := formatNumberWithCommas(totalVisits)
|
|
todayFormatted := formatNumber(todayVisits)
|
|
todayExact := formatNumberWithCommas(todayVisits)
|
|
|
|
sectionTitle := p.getConfigValue(settings, "ui.sectionTitle", "Visitors")
|
|
showTotal := p.getConfigBool(settings, "ui.showTotal", true)
|
|
showToday := p.getConfigBool(settings, "ui.showToday", true)
|
|
|
|
tmpl := `
|
|
<section class="visitors-section section plugin" data-w="1">
|
|
<header class="plugin-header">
|
|
<h3 class="plugin-title">{{.SectionTitle}}</h3>
|
|
</header>
|
|
<div class="plugin__inner">
|
|
<div class="visitors-stats">
|
|
{{if .ShowTotal}}
|
|
<div class="visitor-stat" data-tooltip="Exact: {{.TotalExact}}">
|
|
<div class="visitor-number" data-stat="total">{{.TotalFormatted}}</div>
|
|
<div class="visitor-label">Total</div>
|
|
</div>
|
|
{{end}}
|
|
{{if .ShowToday}}
|
|
<div class="visitor-stat" data-tooltip="Exact: {{.TodayExact}}">
|
|
<div class="visitor-number" data-stat="today">{{.TodayFormatted}}</div>
|
|
<div class="visitor-label">Today</div>
|
|
</div>
|
|
{{end}}
|
|
</div>
|
|
</div>
|
|
</section>`
|
|
|
|
data := struct {
|
|
SectionTitle string
|
|
ShowTotal bool
|
|
ShowToday bool
|
|
TotalFormatted string
|
|
TotalExact string
|
|
TodayFormatted string
|
|
TodayExact string
|
|
}{
|
|
SectionTitle: sectionTitle,
|
|
ShowTotal: showTotal,
|
|
ShowToday: showToday,
|
|
TotalFormatted: totalFormatted,
|
|
TotalExact: totalExact,
|
|
TodayFormatted: todayFormatted,
|
|
TodayExact: todayExact,
|
|
}
|
|
|
|
t, err := template.New("visitors").Parse(tmpl)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
var buf strings.Builder
|
|
if err := t.Execute(&buf, data); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
return buf.String(), nil
|
|
}
|
|
|
|
func (p *VisitorsPlugin) RecordVisit(userAgent, ip string) {
|
|
p.mutex.Lock()
|
|
|
|
today := time.Now().Format("2006-01-02")
|
|
if today != p.currentDay {
|
|
log.Printf("[Visitors] Day change detected in RecordVisit: %s -> %s", p.currentDay, today)
|
|
if p.currentDay != "" && p.todayCount > 0 {
|
|
p.dailyStats[p.currentDay] = p.todayCount
|
|
}
|
|
p.currentDay = today
|
|
p.todayCount = 0
|
|
}
|
|
|
|
p.visitCount++
|
|
p.todayCount++
|
|
|
|
currentTotal := p.visitCount
|
|
currentToday := p.todayCount
|
|
p.mutex.Unlock()
|
|
|
|
log.Printf("[Visitors] Visit recorded: total=%d, today=%d, IP=%s", currentTotal, currentToday, ip)
|
|
|
|
go func() {
|
|
p.broadcastUpdate()
|
|
|
|
if time.Since(p.lastSaveTime) > 30*time.Second {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
|
defer cancel()
|
|
|
|
if err := p.saveVisitorsDataAsync(ctx); err != nil {
|
|
log.Printf("[Visitors] ERROR: Failed to save after visit: %v", err)
|
|
} else {
|
|
p.mutex.Lock()
|
|
p.lastSaveTime = time.Now()
|
|
p.mutex.Unlock()
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
|
|
func (p *VisitorsPlugin) broadcastUpdate() {
|
|
p.mutex.RLock()
|
|
total := p.visitCount
|
|
today := p.todayCount
|
|
p.mutex.RUnlock()
|
|
|
|
p.hub.Broadcast("visitors_update", map[string]interface{}{
|
|
"total": total,
|
|
"today": today,
|
|
"timestamp": time.Now().Unix(),
|
|
})
|
|
}
|
|
|
|
func (p *VisitorsPlugin) UpdateData(ctx context.Context) error {
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
default:
|
|
}
|
|
|
|
log.Printf("[Visitors] UpdateData called, attempting save...")
|
|
|
|
saveDone := make(chan error, 1)
|
|
go func() {
|
|
saveCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
|
defer cancel()
|
|
saveDone <- p.saveVisitorsDataAsync(saveCtx)
|
|
}()
|
|
|
|
select {
|
|
case err := <-saveDone:
|
|
if err != nil {
|
|
log.Printf("[Visitors] UpdateData save error: %v", err)
|
|
return nil
|
|
}
|
|
log.Printf("[Visitors] UpdateData completed successfully")
|
|
return nil
|
|
case <-ctx.Done():
|
|
log.Printf("[Visitors] UpdateData cancelled by context")
|
|
return nil
|
|
case <-time.After(2 * time.Second):
|
|
log.Printf("[Visitors] UpdateData save timeout, continuing anyway")
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func (p *VisitorsPlugin) loadVisitorsData() {
|
|
dataFile := filepath.Join(p.dataPath, "visitors.json")
|
|
|
|
log.Printf("[Visitors] Loading data from %s", dataFile)
|
|
|
|
data, err := os.ReadFile(dataFile)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
log.Printf("[Visitors] No existing data file, starting fresh")
|
|
} else {
|
|
log.Printf("[Visitors] ERROR reading data file: %v", err)
|
|
}
|
|
p.visitCount = 0
|
|
p.todayCount = 0
|
|
p.dailyStats = make(map[string]int64)
|
|
return
|
|
}
|
|
|
|
log.Printf("[Visitors] Loaded %d bytes from data file", len(data))
|
|
|
|
var visitorsData VisitorsData
|
|
if err := json.Unmarshal(data, &visitorsData); err != nil {
|
|
log.Printf("[Visitors] ERROR parsing JSON: %v", err)
|
|
p.visitCount = 0
|
|
p.todayCount = 0
|
|
p.dailyStats = make(map[string]int64)
|
|
return
|
|
}
|
|
|
|
p.visitCount = visitorsData.TotalVisits
|
|
if visitorsData.DailyStats != nil {
|
|
p.dailyStats = visitorsData.DailyStats
|
|
} else {
|
|
p.dailyStats = make(map[string]int64)
|
|
}
|
|
|
|
today := time.Now().Format("2006-01-02")
|
|
if visitorsData.CurrentDay == today {
|
|
p.todayCount = visitorsData.TodayVisits
|
|
log.Printf("[Visitors] Loaded today's count: %d", p.todayCount)
|
|
} else {
|
|
if visitorsData.CurrentDay != "" && visitorsData.TodayVisits > 0 {
|
|
p.dailyStats[visitorsData.CurrentDay] = visitorsData.TodayVisits
|
|
log.Printf("[Visitors] Saved previous day %s count: %d", visitorsData.CurrentDay, visitorsData.TodayVisits)
|
|
}
|
|
p.todayCount = 0
|
|
}
|
|
|
|
p.currentDay = today
|
|
|
|
log.Printf("[Visitors] Data loaded successfully: total=%d, today=%d, day=%s, dailyStats=%d days",
|
|
p.visitCount, p.todayCount, p.currentDay, len(p.dailyStats))
|
|
}
|
|
|
|
func (p *VisitorsPlugin) GetSettings() map[string]interface{} {
|
|
config := p.storage.GetPluginConfig(p.Name())
|
|
return config.Settings
|
|
}
|
|
|
|
func (p *VisitorsPlugin) SetSettings(settings map[string]interface{}) error {
|
|
config := p.storage.GetPluginConfig(p.Name())
|
|
config.Settings = settings
|
|
|
|
err := p.storage.SetPluginConfig(p.Name(), config)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
p.hub.Broadcast("plugin_update", map[string]interface{}{
|
|
"plugin": p.Name(),
|
|
"action": "settings_changed",
|
|
})
|
|
|
|
return nil
|
|
}
|
|
|
|
func (p *VisitorsPlugin) getConfigValue(settings map[string]interface{}, key string, defaultValue string) string {
|
|
keys := strings.Split(key, ".")
|
|
current := settings
|
|
|
|
for i, k := range keys {
|
|
if i == len(keys)-1 {
|
|
if value, ok := current[k].(string); ok {
|
|
return value
|
|
}
|
|
return defaultValue
|
|
} else {
|
|
if next, ok := current[k].(map[string]interface{}); ok {
|
|
current = next
|
|
} else {
|
|
return defaultValue
|
|
}
|
|
}
|
|
}
|
|
|
|
return defaultValue
|
|
}
|
|
|
|
func (p *VisitorsPlugin) getConfigBool(settings map[string]interface{}, key string, defaultValue bool) bool {
|
|
keys := strings.Split(key, ".")
|
|
current := settings
|
|
|
|
for i, k := range keys {
|
|
if i == len(keys)-1 {
|
|
if value, ok := current[k].(bool); ok {
|
|
return value
|
|
}
|
|
return defaultValue
|
|
} else {
|
|
if next, ok := current[k].(map[string]interface{}); ok {
|
|
current = next
|
|
} else {
|
|
return defaultValue
|
|
}
|
|
}
|
|
}
|
|
|
|
return defaultValue
|
|
}
|
|
|
|
func formatNumber(n int64) string {
|
|
if n < 1000 {
|
|
return fmt.Sprintf("%d", n)
|
|
} else if n < 1000000 {
|
|
return fmt.Sprintf("%.1fK", float64(n)/1000)
|
|
} else {
|
|
return fmt.Sprintf("%.1fM", float64(n)/1000000)
|
|
}
|
|
}
|
|
|
|
func (p *VisitorsPlugin) RenderText(ctx context.Context) (string, error) {
|
|
p.mutex.RLock()
|
|
total := p.visitCount
|
|
today := p.todayCount
|
|
p.mutex.RUnlock()
|
|
|
|
return fmt.Sprintf("Visitors: %s total, %s today",
|
|
formatNumber(total), formatNumber(today)), nil
|
|
}
|
|
|
|
func (p *VisitorsPlugin) testDayTransition() {
|
|
log.Printf("[Visitors] TEST: Starting day transition test")
|
|
|
|
p.mutex.Lock()
|
|
initialTotal := p.visitCount
|
|
initialToday := p.todayCount
|
|
initialDay := p.currentDay
|
|
|
|
p.todayCount = 42
|
|
p.currentDay = "2025-01-01"
|
|
log.Printf("[Visitors] TEST: Set test values - day: %s, today: %d", p.currentDay, p.todayCount)
|
|
p.mutex.Unlock()
|
|
|
|
time.Sleep(100 * time.Millisecond)
|
|
|
|
p.checkDayTransition()
|
|
|
|
time.Sleep(500 * time.Millisecond)
|
|
|
|
p.mutex.RLock()
|
|
newDay := p.currentDay
|
|
newToday := p.todayCount
|
|
savedCount := p.dailyStats["2025-01-01"]
|
|
p.mutex.RUnlock()
|
|
|
|
log.Printf("[Visitors] TEST RESULTS:")
|
|
log.Printf(" - Initial: day=%s, today=%d, total=%d", initialDay, initialToday, initialTotal)
|
|
log.Printf(" - After transition: day=%s, today=%d", newDay, newToday)
|
|
log.Printf(" - Saved in dailyStats[2025-01-01]: %d", savedCount)
|
|
|
|
if savedCount != 42 {
|
|
log.Printf("[Visitors] TEST FAILED: Expected 42 in dailyStats, got %d", savedCount)
|
|
} else if newToday != 0 {
|
|
log.Printf("[Visitors] TEST FAILED: Expected today count to reset to 0, got %d", newToday)
|
|
} else {
|
|
log.Printf("[Visitors] TEST PASSED: Day transition working correctly")
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
if err := p.saveVisitorsDataAsync(ctx); err != nil {
|
|
log.Printf("[Visitors] TEST: Save failed: %v", err)
|
|
} else {
|
|
log.Printf("[Visitors] TEST: Save succeeded")
|
|
|
|
dataFile := filepath.Join(p.dataPath, "visitors.json")
|
|
if data, err := os.ReadFile(dataFile); err == nil {
|
|
log.Printf("[Visitors] TEST: Saved file size: %d bytes", len(data))
|
|
}
|
|
}
|
|
}
|
|
|
|
func (p *VisitorsPlugin) GetMetrics() map[string]interface{} {
|
|
p.mutex.RLock()
|
|
defer p.mutex.RUnlock()
|
|
|
|
return map[string]interface{}{
|
|
"total_visits": p.visitCount,
|
|
"today_visits": p.todayCount,
|
|
}
|
|
}
|