390 lines
10 KiB
Go
390 lines
10 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
"os/exec"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
"watch-tool/models"
|
|
)
|
|
|
|
type WebService struct {
|
|
server *http.Server
|
|
storage StorageInterface
|
|
config *Config
|
|
}
|
|
|
|
func LoggingMiddleware(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
slog.Debug("WebService", "Remote-Address", r.RemoteAddr, "Method", r.Method, "Path", r.URL.Path)
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
func NewWebService(config *Config, storage StorageInterface) *WebService {
|
|
mux := http.NewServeMux()
|
|
|
|
ws := &WebService{
|
|
storage: storage,
|
|
config: config,
|
|
}
|
|
|
|
mux.HandleFunc("GET /health", ws.handleHealth)
|
|
mux.HandleFunc("GET /logs", ws.handleLogs)
|
|
mux.HandleFunc("GET /export", ws.handleExport)
|
|
mux.HandleFunc("GET /stats", ws.handleStats)
|
|
mux.HandleFunc("GET /stats/{service}", ws.handleServiceStats)
|
|
|
|
loggedMux := LoggingMiddleware(mux)
|
|
|
|
addr := fmt.Sprintf("%s:%d", config.WebService.Host, config.WebService.Port)
|
|
ws.server = &http.Server{
|
|
Addr: addr,
|
|
Handler: loggedMux,
|
|
ReadTimeout: 30 * time.Second,
|
|
WriteTimeout: 300 * time.Second,
|
|
IdleTimeout: 60 * time.Second,
|
|
}
|
|
|
|
return ws
|
|
}
|
|
|
|
func (ws *WebService) Start(ctx context.Context) error {
|
|
go func() {
|
|
<-ctx.Done()
|
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
|
|
if err := ws.server.Shutdown(shutdownCtx); err != nil {
|
|
slog.Error("web service shutdown error", "error", err)
|
|
}
|
|
}()
|
|
|
|
slog.Info("Starting web service", "address", ws.server.Addr)
|
|
|
|
if err := ws.server.ListenAndServe(); err != http.ErrServerClosed {
|
|
return fmt.Errorf("web service error: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (ws *WebService) handleHealth(w http.ResponseWriter, r *http.Request) {
|
|
|
|
status := map[string]any{
|
|
"status": "healthy",
|
|
"timestamp": time.Now(),
|
|
"storage": "sqlite",
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
_, err := ws.storage.Query(ctx, StorageQuery{
|
|
Limit: 1,
|
|
})
|
|
|
|
if err != nil {
|
|
status["storage_status"] = "unhealthy"
|
|
status["storage_error"] = err.Error()
|
|
w.WriteHeader(http.StatusServiceUnavailable)
|
|
} else {
|
|
status["storage_status"] = "healthy"
|
|
}
|
|
statusMap := make(map[string]any)
|
|
statusMap["tixel-watch"] = status
|
|
|
|
for _, service := range ws.config.Services {
|
|
statusCommand := []string{"sudo", "systemctl", "status", service.Name, "--no-pager"}
|
|
if service.Enabled {
|
|
serviceStatus, err := exec.Command(statusCommand[0], statusCommand[1:]...).Output()
|
|
if err != nil {
|
|
slog.Error("error executing status command", "error", err)
|
|
continue
|
|
}
|
|
lines := strings.SplitSeq(string(serviceStatus), "\n")
|
|
for line := range lines {
|
|
if strings.Contains(line, "Active:") {
|
|
serviceHealth, found := strings.CutPrefix(strings.TrimSpace(line), "Active:")
|
|
if found {
|
|
statusMap[service.Name] = map[string]any{"status": serviceHealth, "timestamp": time.Now()}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(statusMap)
|
|
}
|
|
|
|
func (ws *WebService) handleLogs(w http.ResponseWriter, r *http.Request) {
|
|
|
|
query := ws.parseLogsQuery(r)
|
|
|
|
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
|
defer cancel()
|
|
|
|
entries, err := ws.storage.Query(ctx, query)
|
|
if err != nil {
|
|
http.Error(w, fmt.Sprintf("Query error: %v", err), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
response := map[string]any{
|
|
"entries": entries,
|
|
"count": len(entries),
|
|
"query": query,
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(response)
|
|
}
|
|
|
|
func (ws *WebService) handleExport(w http.ResponseWriter, r *http.Request) {
|
|
|
|
query := ws.parseLogsQuery(r)
|
|
|
|
ctx, cancel := context.WithTimeout(r.Context(), 300*time.Second)
|
|
defer cancel()
|
|
|
|
entries, err := ws.storage.Query(ctx, query)
|
|
if err != nil {
|
|
http.Error(w, fmt.Sprintf("Export query error: %v", err), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
filename := fmt.Sprintf("tixel_export_%s.json", time.Now().Format("20060102_150405"))
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename))
|
|
|
|
exportData := map[string]any{
|
|
"export_info": map[string]any{
|
|
"timestamp": time.Now(),
|
|
"entry_count": len(entries),
|
|
"query": query,
|
|
"exported_by": "tixel-watch",
|
|
},
|
|
"entries": entries,
|
|
}
|
|
|
|
if err := json.NewEncoder(w).Encode(exportData); err != nil {
|
|
slog.Error("Failed to encode export data", "error", err)
|
|
return
|
|
}
|
|
|
|
slog.Info("Data exported", "count", len(entries), "query", query)
|
|
}
|
|
|
|
func (ws *WebService) handleStats(w http.ResponseWriter, r *http.Request) {
|
|
|
|
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
|
defer cancel()
|
|
|
|
allEntries, err := ws.storage.Query(ctx, StorageQuery{})
|
|
if err != nil {
|
|
http.Error(w, fmt.Sprintf("Stats query error: %v", err), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
recentEntries, err := ws.storage.Query(ctx, StorageQuery{
|
|
StartTime: time.Now().Add(-time.Hour),
|
|
})
|
|
if err != nil {
|
|
slog.Error("Failed to query recent entries", "error", err)
|
|
recentEntries = []models.LogMessage{}
|
|
}
|
|
|
|
stats := map[string]any{
|
|
"total_entries": len(allEntries),
|
|
"recent_entries": len(recentEntries),
|
|
"timestamp": time.Now(),
|
|
}
|
|
|
|
typeCounts := make(map[string]int)
|
|
serviceCounts := make(map[string]int)
|
|
toolCounts := make(map[string]int)
|
|
|
|
for _, entry := range allEntries {
|
|
typeCounts[entry.Type]++
|
|
if entry.Service != "" {
|
|
serviceCounts[entry.Service]++
|
|
}
|
|
if entry.Tool != "" {
|
|
toolCounts[entry.Tool]++
|
|
}
|
|
}
|
|
|
|
stats["by_type"] = typeCounts
|
|
stats["by_service"] = serviceCounts
|
|
stats["by_tool"] = toolCounts
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(stats)
|
|
}
|
|
|
|
func (ws *WebService) handleServiceStats(w http.ResponseWriter, r *http.Request) {
|
|
service := r.PathValue("service")
|
|
if service == "" {
|
|
http.Error(w, "Service parameter is missing", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
timeRangeStr := r.URL.Query().Get("time_range")
|
|
var startTime time.Time
|
|
if timeRangeStr == "" {
|
|
startTime = time.Now().Add(-24 * time.Hour)
|
|
} else {
|
|
duration, err := time.ParseDuration(timeRangeStr)
|
|
if err != nil {
|
|
http.Error(w, fmt.Sprintf("Invalid time_range: %v", err), http.StatusBadRequest)
|
|
return
|
|
}
|
|
startTime = time.Now().Add(-duration)
|
|
}
|
|
|
|
query := StorageQuery{
|
|
Service: service,
|
|
StartTime: startTime,
|
|
Limit: 0,
|
|
OrderDesc: false,
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
|
defer cancel()
|
|
|
|
entries, err := ws.storage.Query(ctx, query)
|
|
if err != nil {
|
|
slog.Error("Failed to query service stats", "service", service, "error", err)
|
|
http.Error(w, fmt.Sprintf("Query error: %v", err), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
uniqueTransfersTotal := make(map[string]struct{})
|
|
uniqueTransfersIncoming := make(map[string]struct{})
|
|
uniqueTransfersOutgoing := make(map[string]struct{})
|
|
uniqueTransfersNil := make(map[string]struct{})
|
|
|
|
for _, entry := range entries {
|
|
var identifier string
|
|
var direction string
|
|
|
|
switch v := entry.ServiceInformation.(type) {
|
|
case models.TSTransferInfo:
|
|
identifier = v.TransferID
|
|
direction = v.Direction
|
|
case *models.TSTransferInfo:
|
|
identifier = v.TransferID
|
|
direction = v.Direction
|
|
case models.TJMTransferInfo:
|
|
identifier = v.TransferID
|
|
direction = v.Direction
|
|
case *models.TJMTransferInfo:
|
|
identifier = v.TransferID
|
|
direction = v.Direction
|
|
case map[string]any:
|
|
identifier, _ = v["transfer_identifier"].(string)
|
|
direction, _ = v["direction"].(string)
|
|
default:
|
|
continue
|
|
}
|
|
|
|
if identifier != "" {
|
|
uniqueTransfersTotal[identifier] = struct{}{}
|
|
|
|
switch strings.ToLower(direction) {
|
|
case "incoming":
|
|
uniqueTransfersIncoming[identifier] = struct{}{}
|
|
case "outgoing":
|
|
uniqueTransfersOutgoing[identifier] = struct{}{}
|
|
default:
|
|
uniqueTransfersNil[identifier] = struct{}{}
|
|
}
|
|
}
|
|
}
|
|
|
|
stats := map[string]any{
|
|
"service": service,
|
|
"start_time": startTime,
|
|
"end_time": time.Now(),
|
|
"transfer_counts": map[string]any{
|
|
"total": len(uniqueTransfersTotal),
|
|
"incoming": len(uniqueTransfersIncoming),
|
|
"outgoing": len(uniqueTransfersOutgoing),
|
|
"nil_or_unknown_direction": len(uniqueTransfersNil),
|
|
},
|
|
"entry_count": len(entries),
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(stats)
|
|
}
|
|
|
|
func (ws *WebService) parseLogsQuery(r *http.Request) StorageQuery {
|
|
query := StorageQuery{
|
|
Limit: 100,
|
|
OrderBy: "timestamp",
|
|
OrderDesc: true,
|
|
}
|
|
|
|
if limitStr := r.URL.Query().Get("limit"); limitStr != "" {
|
|
if limit, err := strconv.Atoi(limitStr); err == nil && limit > 0 {
|
|
if limit > 10000 {
|
|
limit = 10000
|
|
}
|
|
query.Limit = limit
|
|
}
|
|
}
|
|
|
|
if offsetStr := r.URL.Query().Get("offset"); offsetStr != "" {
|
|
if offset, err := strconv.Atoi(offsetStr); err == nil && offset >= 0 {
|
|
query.Offset = offset
|
|
}
|
|
}
|
|
|
|
if startTime := r.URL.Query().Get("start_time"); startTime != "" {
|
|
if t, err := time.Parse(time.RFC3339, startTime); err == nil {
|
|
query.StartTime = t
|
|
}
|
|
}
|
|
|
|
if endTime := r.URL.Query().Get("end_time"); endTime != "" {
|
|
if t, err := time.Parse(time.RFC3339, endTime); err == nil {
|
|
query.EndTime = t
|
|
}
|
|
}
|
|
|
|
if service := r.URL.Query().Get("service"); service != "" {
|
|
query.Service = strings.TrimSpace(service)
|
|
}
|
|
|
|
if tool := r.URL.Query().Get("tool"); tool != "" {
|
|
query.Tool = strings.TrimSpace(tool)
|
|
}
|
|
|
|
if logLevel := r.URL.Query().Get("log_level"); logLevel != "" {
|
|
query.LogLevel = strings.TrimSpace(logLevel)
|
|
}
|
|
|
|
if entryType := r.URL.Query().Get("type"); entryType != "" {
|
|
query.Type = strings.TrimSpace(entryType)
|
|
}
|
|
|
|
if orderBy := r.URL.Query().Get("order_by"); orderBy != "" {
|
|
validFields := []string{"timestamp", "service", "tool", "type", "log_level"}
|
|
if slices.Contains(validFields, orderBy) {
|
|
query.OrderBy = orderBy
|
|
}
|
|
}
|
|
|
|
if orderDesc := r.URL.Query().Get("order_desc"); orderDesc != "" {
|
|
query.OrderDesc = orderDesc == "true"
|
|
}
|
|
|
|
return query
|
|
}
|