add telemetry system with analytics and crash reporting (M08)

This commit is contained in:
2026-01-18 21:53:06 +01:00
parent fbcb5c9543
commit a5aa3cc9d7
6 changed files with 1484 additions and 3 deletions

View File

@@ -0,0 +1,269 @@
package handlers
import (
"encoding/json"
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
"github.com/omixlab/mosis-portal/internal/database"
"github.com/omixlab/mosis-portal/internal/telemetry"
)
// TelemetryHandler handles telemetry API requests
type TelemetryHandler struct {
db *database.DB
telemetry *telemetry.Service
}
// NewTelemetryHandler creates a new telemetry handler
func NewTelemetryHandler(db *database.DB, ts *telemetry.Service) *TelemetryHandler {
return &TelemetryHandler{
db: db,
telemetry: ts,
}
}
// RecordEvents handles POST /v1/telemetry/events
func (h *TelemetryHandler) RecordEvents(w http.ResponseWriter, r *http.Request) {
var batch telemetry.EventBatch
if err := json.NewDecoder(r.Body).Decode(&batch); err != nil {
Error(w, http.StatusBadRequest, "invalid_body", "Invalid request body")
return
}
if batch.AppID == "" {
Error(w, http.StatusBadRequest, "missing_app_id", "App ID is required")
return
}
if batch.DeviceID == "" {
Error(w, http.StatusBadRequest, "missing_device_id", "Device ID is required")
return
}
if len(batch.Events) == 0 {
Error(w, http.StatusBadRequest, "no_events", "At least one event is required")
return
}
count, err := h.telemetry.RecordEvents(r.Context(), &batch)
if err != nil {
Error(w, http.StatusInternalServerError, "record_error", err.Error())
return
}
JSON(w, http.StatusOK, map[string]interface{}{
"received": count,
})
}
// RecordCrash handles POST /v1/telemetry/crash
func (h *TelemetryHandler) RecordCrash(w http.ResponseWriter, r *http.Request) {
var report telemetry.CrashReport
if err := json.NewDecoder(r.Body).Decode(&report); err != nil {
Error(w, http.StatusBadRequest, "invalid_body", "Invalid request body")
return
}
if report.AppID == "" {
Error(w, http.StatusBadRequest, "missing_app_id", "App ID is required")
return
}
if report.DeviceID == "" {
Error(w, http.StatusBadRequest, "missing_device_id", "Device ID is required")
return
}
if report.Crash.Type == "" {
Error(w, http.StatusBadRequest, "missing_crash_type", "Crash type is required")
return
}
groupID, err := h.telemetry.RecordCrash(r.Context(), &report)
if err != nil {
Error(w, http.StatusInternalServerError, "record_error", err.Error())
return
}
JSON(w, http.StatusOK, map[string]interface{}{
"id": groupID,
})
}
// GetAnalyticsOverview handles GET /v1/apps/:appID/analytics/overview
func (h *TelemetryHandler) GetAnalyticsOverview(w http.ResponseWriter, r *http.Request) {
appID := chi.URLParam(r, "appID")
// Verify app ownership
developerID := getDeveloperID(r)
app, err := h.db.GetApp(r.Context(), appID)
if err != nil {
Error(w, http.StatusNotFound, "not_found", "App not found")
return
}
if app.DeveloperID != developerID {
Error(w, http.StatusForbidden, "forbidden", "Access denied")
return
}
days := 30
if d := r.URL.Query().Get("days"); d != "" {
if parsed, err := strconv.Atoi(d); err == nil && parsed > 0 && parsed <= 365 {
days = parsed
}
}
overview, err := h.telemetry.GetAnalyticsOverview(r.Context(), appID, days)
if err != nil {
Error(w, http.StatusInternalServerError, "query_error", err.Error())
return
}
JSON(w, http.StatusOK, overview)
}
// GetAnalyticsEvents handles GET /v1/apps/:appID/analytics/events
func (h *TelemetryHandler) GetAnalyticsEvents(w http.ResponseWriter, r *http.Request) {
appID := chi.URLParam(r, "appID")
// Verify app ownership
developerID := getDeveloperID(r)
app, err := h.db.GetApp(r.Context(), appID)
if err != nil {
Error(w, http.StatusNotFound, "not_found", "App not found")
return
}
if app.DeveloperID != developerID {
Error(w, http.StatusForbidden, "forbidden", "Access denied")
return
}
eventType := r.URL.Query().Get("event_type")
days := 30
if d := r.URL.Query().Get("days"); d != "" {
if parsed, err := strconv.Atoi(d); err == nil && parsed > 0 && parsed <= 365 {
days = parsed
}
}
stats, err := h.telemetry.GetDailyStats(r.Context(), appID, eventType, days)
if err != nil {
Error(w, http.StatusInternalServerError, "query_error", err.Error())
return
}
JSON(w, http.StatusOK, map[string]interface{}{
"data": stats,
})
}
// GetCrashes handles GET /v1/apps/:appID/crashes
func (h *TelemetryHandler) GetCrashes(w http.ResponseWriter, r *http.Request) {
appID := chi.URLParam(r, "appID")
// Verify app ownership
developerID := getDeveloperID(r)
app, err := h.db.GetApp(r.Context(), appID)
if err != nil {
Error(w, http.StatusNotFound, "not_found", "App not found")
return
}
if app.DeveloperID != developerID {
Error(w, http.StatusForbidden, "forbidden", "Access denied")
return
}
status := r.URL.Query().Get("status")
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
if page < 1 {
page = 1
}
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
if limit < 1 || limit > 100 {
limit = 20
}
offset := (page - 1) * limit
crashes, total, err := h.telemetry.GetCrashGroups(r.Context(), appID, status, limit, offset)
if err != nil {
Error(w, http.StatusInternalServerError, "query_error", err.Error())
return
}
JSON(w, http.StatusOK, map[string]interface{}{
"crashes": crashes,
"pagination": map[string]int{
"page": page,
"limit": limit,
"total": total,
"total_pages": (total + limit - 1) / limit,
},
})
}
// GetCrash handles GET /v1/apps/:appID/crashes/:crashID
func (h *TelemetryHandler) GetCrash(w http.ResponseWriter, r *http.Request) {
appID := chi.URLParam(r, "appID")
crashID := chi.URLParam(r, "crashID")
// Verify app ownership
developerID := getDeveloperID(r)
app, err := h.db.GetApp(r.Context(), appID)
if err != nil {
Error(w, http.StatusNotFound, "not_found", "App not found")
return
}
if app.DeveloperID != developerID {
Error(w, http.StatusForbidden, "forbidden", "Access denied")
return
}
crash, err := h.telemetry.GetCrashGroup(r.Context(), appID, crashID)
if err != nil {
Error(w, http.StatusNotFound, "not_found", "Crash group not found")
return
}
JSON(w, http.StatusOK, crash)
}
// UpdateCrashStatus handles PATCH /v1/apps/:appID/crashes/:crashID
func (h *TelemetryHandler) UpdateCrashStatus(w http.ResponseWriter, r *http.Request) {
appID := chi.URLParam(r, "appID")
crashID := chi.URLParam(r, "crashID")
// Verify app ownership
developerID := getDeveloperID(r)
app, err := h.db.GetApp(r.Context(), appID)
if err != nil {
Error(w, http.StatusNotFound, "not_found", "App not found")
return
}
if app.DeveloperID != developerID {
Error(w, http.StatusForbidden, "forbidden", "Access denied")
return
}
var req struct {
Status string `json:"status"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
Error(w, http.StatusBadRequest, "invalid_body", "Invalid request body")
return
}
if req.Status != "open" && req.Status != "resolved" && req.Status != "ignored" {
Error(w, http.StatusBadRequest, "invalid_status", "Status must be open, resolved, or ignored")
return
}
if err := h.telemetry.UpdateCrashGroupStatus(r.Context(), appID, crashID, req.Status); err != nil {
Error(w, http.StatusInternalServerError, "update_error", err.Error())
return
}
crash, _ := h.telemetry.GetCrashGroup(r.Context(), appID, crashID)
JSON(w, http.StatusOK, crash)
}

View File

@@ -13,6 +13,7 @@ import (
"github.com/omixlab/mosis-portal/internal/config"
"github.com/omixlab/mosis-portal/internal/database"
"github.com/omixlab/mosis-portal/internal/storage"
"github.com/omixlab/mosis-portal/internal/telemetry"
"github.com/omixlab/mosis-portal/internal/web"
)
@@ -32,6 +33,13 @@ func NewRouter(cfg *config.Config, db *database.DB) http.Handler {
log.Fatalf("Failed to initialize storage: %v", err)
}
// Initialize telemetry service
telemetryDB := cfg.StoragePath + "/telemetry.db"
telemetrySvc, err := telemetry.New(telemetryDB)
if err != nil {
log.Fatalf("Failed to initialize telemetry service: %v", err)
}
// Initialize auth components
jwtManager := auth.NewJWTManager(cfg.JWTSecret)
oauthManager := auth.NewOAuthManager(
@@ -44,6 +52,7 @@ func NewRouter(cfg *config.Config, db *database.DB) http.Handler {
appHandler := handlers.NewAppHandler(db, store)
storeHandler := handlers.NewStoreHandler(db, store)
adminHandler := handlers.NewAdminHandler(db, store)
telemetryHandler := handlers.NewTelemetryHandler(db, telemetrySvc)
// Health check
r.Get("/health", func(w http.ResponseWriter, r *http.Request) {
@@ -95,6 +104,19 @@ func NewRouter(cfg *config.Config, db *database.DB) http.Handler {
// Package upload
r.Post("/{versionID}/upload", appHandler.UploadPackage)
})
// Analytics
r.Route("/{appID}/analytics", func(r chi.Router) {
r.Get("/overview", telemetryHandler.GetAnalyticsOverview)
r.Get("/events", telemetryHandler.GetAnalyticsEvents)
})
// Crashes
r.Route("/{appID}/crashes", func(r chi.Router) {
r.Get("/", telemetryHandler.GetCrashes)
r.Get("/{crashID}", telemetryHandler.GetCrash)
r.Patch("/{crashID}", telemetryHandler.UpdateCrashStatus)
})
})
// API Keys
@@ -121,10 +143,10 @@ func NewRouter(cfg *config.Config, db *database.DB) http.Handler {
r.Get("/apps/{packageID}/versions/{versionCode}/download", storeHandler.DownloadVersion)
})
// Telemetry (API key auth preferred, but can work without for initial setup)
// Telemetry endpoints (public - devices send events here)
r.Route("/telemetry", func(r chi.Router) {
r.Post("/events", handlers.NotImplemented)
r.Post("/crash", handlers.NotImplemented)
r.Post("/events", telemetryHandler.RecordEvents)
r.Post("/crash", telemetryHandler.RecordCrash)
})
})
@@ -145,6 +167,7 @@ func NewRouter(cfg *config.Config, db *database.DB) http.Handler {
log.Printf("Warning: Failed to initialize web handler: %v", err)
} else {
webHandler.SetStorage(store)
webHandler.SetTelemetry(telemetrySvc)
sessionMW := web.NewSessionMiddleware(db, cfg.JWTSecret)
// Public web pages
@@ -163,6 +186,8 @@ func NewRouter(cfg *config.Config, db *database.DB) http.Handler {
r.Get("/dashboard", webHandler.Dashboard)
r.Get("/apps/new", webHandler.AppNew)
r.Get("/apps/{appID}", webHandler.AppDetail)
r.Get("/apps/{appID}/analytics", webHandler.AppAnalytics)
r.Get("/apps/{appID}/crashes", webHandler.AppCrashes)
// htmx partials
r.Get("/partials/apps", webHandler.AppListPartial)

View File

@@ -0,0 +1,666 @@
// Package telemetry provides app analytics and crash reporting
package telemetry
import (
"context"
"crypto/sha256"
"database/sql"
"encoding/hex"
"encoding/json"
"fmt"
"log"
"regexp"
"strings"
"sync"
"time"
_ "modernc.org/sqlite"
)
// Event types
const (
EventAppStart = "app_start"
EventAppStop = "app_stop"
EventAppCrash = "app_crash"
EventLuaError = "lua_error"
EventPerfFrame = "perf_frame"
EventPerfMemory = "perf_memory"
EventPerfStartup = "perf_startup"
EventScreenView = "screen_view"
EventFeatureUsed = "feature_used"
)
// Event represents a telemetry event
type Event struct {
Type string `json:"type"`
Timestamp string `json:"timestamp"`
Data json.RawMessage `json:"data,omitempty"`
}
// EventBatch represents a batch of events from a device
type EventBatch struct {
AppID string `json:"app_id"`
AppVersion string `json:"app_version"`
MosisVersion string `json:"mosis_version"`
DeviceID string `json:"device_id"`
SessionID string `json:"session_id,omitempty"`
Events []Event `json:"events"`
}
// CrashReport represents a crash report from a device
type CrashReport struct {
AppID string `json:"app_id"`
AppVersion string `json:"app_version"`
MosisVersion string `json:"mosis_version"`
DeviceID string `json:"device_id"`
Timestamp string `json:"timestamp"`
Crash CrashDetails `json:"crash"`
}
// CrashDetails contains crash information
type CrashDetails struct {
Type string `json:"type"`
Message string `json:"message"`
StackTrace string `json:"stack_trace"`
Context map[string]interface{} `json:"context,omitempty"`
}
// CrashGroup represents a group of similar crashes
type CrashGroup struct {
ID string `json:"id"`
AppID string `json:"app_id"`
Fingerprint string `json:"fingerprint"`
CrashType string `json:"crash_type"`
Message string `json:"message"`
SampleStackTrace string `json:"sample_stack_trace"`
FirstSeen string `json:"first_seen"`
LastSeen string `json:"last_seen"`
OccurrenceCount int `json:"occurrence_count"`
AffectedVersions []string `json:"affected_versions"`
Status string `json:"status"` // open, resolved, ignored
}
// DailyStats represents aggregated daily statistics
type DailyStats struct {
AppID string `json:"app_id"`
Date string `json:"date"`
EventType string `json:"event_type"`
Count int `json:"count"`
UniqueDevices int `json:"unique_devices"`
}
// AnalyticsOverview represents the analytics summary for an app
type AnalyticsOverview struct {
DAU int `json:"dau"`
DAUChange float64 `json:"dau_change"`
TotalCrashes int `json:"total_crashes"`
CrashChange float64 `json:"crash_change"`
CrashFreeRate float64 `json:"crash_free_rate"`
TotalSessions int `json:"total_sessions"`
}
// Service handles telemetry operations
type Service struct {
db *sql.DB
dbPath string
mu sync.Mutex
stopCh chan struct{}
}
// New creates a new telemetry service with a separate database
func New(dbPath string) (*Service, error) {
db, err := sql.Open("sqlite", dbPath+"?_journal_mode=WAL&_busy_timeout=5000")
if err != nil {
return nil, fmt.Errorf("open telemetry db: %w", err)
}
s := &Service{
db: db,
dbPath: dbPath,
stopCh: make(chan struct{}),
}
if err := s.migrate(); err != nil {
db.Close()
return nil, fmt.Errorf("migrate telemetry db: %w", err)
}
return s, nil
}
// migrate creates the telemetry database schema
func (s *Service) migrate() error {
schema := `
-- Raw events (7-day retention)
CREATE TABLE IF NOT EXISTS events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
app_id TEXT NOT NULL,
device_id TEXT NOT NULL,
session_id TEXT,
event_type TEXT NOT NULL,
event_data TEXT,
app_version TEXT,
mosis_version TEXT,
timestamp TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_events_app_time ON events(app_id, timestamp);
CREATE INDEX IF NOT EXISTS idx_events_type ON events(event_type, timestamp);
CREATE INDEX IF NOT EXISTS idx_events_cleanup ON events(timestamp);
-- Hourly aggregates
CREATE TABLE IF NOT EXISTS hourly_stats (
app_id TEXT NOT NULL,
hour TEXT NOT NULL,
event_type TEXT NOT NULL,
count INTEGER NOT NULL,
unique_devices INTEGER NOT NULL,
PRIMARY KEY (app_id, hour, event_type)
);
-- Daily aggregates
CREATE TABLE IF NOT EXISTS daily_stats (
app_id TEXT NOT NULL,
date TEXT NOT NULL,
event_type TEXT NOT NULL,
count INTEGER NOT NULL,
unique_devices INTEGER NOT NULL,
PRIMARY KEY (app_id, date, event_type)
);
-- Crash groups (deduplicated by fingerprint)
CREATE TABLE IF NOT EXISTS crash_groups (
id TEXT PRIMARY KEY,
app_id TEXT NOT NULL,
fingerprint TEXT NOT NULL,
crash_type TEXT NOT NULL,
message TEXT,
sample_stack_trace TEXT,
first_seen TEXT NOT NULL,
last_seen TEXT NOT NULL,
occurrence_count INTEGER DEFAULT 1,
affected_versions TEXT,
status TEXT DEFAULT 'open',
UNIQUE(app_id, fingerprint)
);
CREATE INDEX IF NOT EXISTS idx_crashes_app ON crash_groups(app_id, status);
-- Individual crash occurrences (for recent list)
CREATE TABLE IF NOT EXISTS crash_occurrences (
id INTEGER PRIMARY KEY AUTOINCREMENT,
crash_group_id TEXT NOT NULL,
device_id TEXT NOT NULL,
app_version TEXT,
context TEXT,
timestamp TEXT NOT NULL,
FOREIGN KEY (crash_group_id) REFERENCES crash_groups(id)
);
CREATE INDEX IF NOT EXISTS idx_occurrences_group ON crash_occurrences(crash_group_id, timestamp);
`
_, err := s.db.Exec(schema)
return err
}
// Close closes the telemetry database
func (s *Service) Close() error {
close(s.stopCh)
return s.db.Close()
}
// RecordEvents records a batch of events
func (s *Service) RecordEvents(ctx context.Context, batch *EventBatch) (int, error) {
s.mu.Lock()
defer s.mu.Unlock()
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return 0, err
}
defer tx.Rollback()
stmt, err := tx.PrepareContext(ctx, `
INSERT INTO events (app_id, device_id, session_id, event_type, event_data, app_version, mosis_version, timestamp)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`)
if err != nil {
return 0, err
}
defer stmt.Close()
count := 0
for _, event := range batch.Events {
var eventData string
if event.Data != nil {
eventData = string(event.Data)
}
_, err := stmt.ExecContext(ctx,
batch.AppID,
batch.DeviceID,
batch.SessionID,
event.Type,
eventData,
batch.AppVersion,
batch.MosisVersion,
event.Timestamp,
)
if err != nil {
log.Printf("Failed to insert event: %v", err)
continue
}
count++
}
if err := tx.Commit(); err != nil {
return 0, err
}
return count, nil
}
// RecordCrash records a crash report
func (s *Service) RecordCrash(ctx context.Context, report *CrashReport) (string, error) {
s.mu.Lock()
defer s.mu.Unlock()
// Generate fingerprint for crash grouping
fingerprint := s.fingerprintCrash(report)
groupID := generateID()
// Try to find existing crash group
var existingID string
var affectedVersions string
err := s.db.QueryRowContext(ctx, `
SELECT id, affected_versions FROM crash_groups WHERE app_id = ? AND fingerprint = ?
`, report.AppID, fingerprint).Scan(&existingID, &affectedVersions)
if err == sql.ErrNoRows {
// Create new crash group
versions, _ := json.Marshal([]string{report.AppVersion})
_, err = s.db.ExecContext(ctx, `
INSERT INTO crash_groups (id, app_id, fingerprint, crash_type, message, sample_stack_trace, first_seen, last_seen, occurrence_count, affected_versions, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, ?, 'open')
`, groupID, report.AppID, fingerprint, report.Crash.Type, report.Crash.Message, report.Crash.StackTrace, report.Timestamp, report.Timestamp, string(versions))
if err != nil {
return "", err
}
} else if err != nil {
return "", err
} else {
// Update existing crash group
groupID = existingID
// Add version if not already in list
var versions []string
json.Unmarshal([]byte(affectedVersions), &versions)
if !contains(versions, report.AppVersion) {
versions = append(versions, report.AppVersion)
}
versionsJSON, _ := json.Marshal(versions)
_, err = s.db.ExecContext(ctx, `
UPDATE crash_groups SET
last_seen = ?,
occurrence_count = occurrence_count + 1,
affected_versions = ?,
status = CASE WHEN status = 'resolved' THEN 'open' ELSE status END
WHERE id = ?
`, report.Timestamp, string(versionsJSON), groupID)
if err != nil {
return "", err
}
}
// Record individual occurrence
contextJSON, _ := json.Marshal(report.Crash.Context)
_, err = s.db.ExecContext(ctx, `
INSERT INTO crash_occurrences (crash_group_id, device_id, app_version, context, timestamp)
VALUES (?, ?, ?, ?, ?)
`, groupID, report.DeviceID, report.AppVersion, string(contextJSON), report.Timestamp)
return groupID, err
}
// fingerprintCrash generates a unique fingerprint for crash grouping
func (s *Service) fingerprintCrash(report *CrashReport) string {
// Normalize stack trace (remove line numbers)
normalized := normalizeStackTrace(report.Crash.StackTrace)
// Create fingerprint from type, message, and normalized stack
key := fmt.Sprintf("%s:%s:%s", report.Crash.Type, report.Crash.Message, normalized)
hash := sha256.Sum256([]byte(key))
return hex.EncodeToString(hash[:8])
}
// normalizeStackTrace removes line numbers for consistent fingerprinting
func normalizeStackTrace(stack string) string {
re := regexp.MustCompile(`:\d+:`)
return re.ReplaceAllString(stack, ":?:")
}
// GetAnalyticsOverview returns analytics summary for an app
func (s *Service) GetAnalyticsOverview(ctx context.Context, appID string, days int) (*AnalyticsOverview, error) {
endDate := time.Now()
startDate := endDate.AddDate(0, 0, -days)
prevStartDate := startDate.AddDate(0, 0, -days)
// Current period DAU (average)
var currentDAU float64
err := s.db.QueryRowContext(ctx, `
SELECT COALESCE(AVG(unique_devices), 0) FROM daily_stats
WHERE app_id = ? AND event_type = 'app_start' AND date >= ? AND date <= ?
`, appID, startDate.Format("2006-01-02"), endDate.Format("2006-01-02")).Scan(&currentDAU)
if err != nil {
return nil, err
}
// Previous period DAU
var prevDAU float64
s.db.QueryRowContext(ctx, `
SELECT COALESCE(AVG(unique_devices), 0) FROM daily_stats
WHERE app_id = ? AND event_type = 'app_start' AND date >= ? AND date < ?
`, appID, prevStartDate.Format("2006-01-02"), startDate.Format("2006-01-02")).Scan(&prevDAU)
// Current crashes
var currentCrashes int
s.db.QueryRowContext(ctx, `
SELECT COALESCE(SUM(count), 0) FROM daily_stats
WHERE app_id = ? AND event_type = 'app_crash' AND date >= ? AND date <= ?
`, appID, startDate.Format("2006-01-02"), endDate.Format("2006-01-02")).Scan(&currentCrashes)
// Previous crashes
var prevCrashes int
s.db.QueryRowContext(ctx, `
SELECT COALESCE(SUM(count), 0) FROM daily_stats
WHERE app_id = ? AND event_type = 'app_crash' AND date >= ? AND date < ?
`, appID, prevStartDate.Format("2006-01-02"), startDate.Format("2006-01-02")).Scan(&prevCrashes)
// Total sessions
var totalSessions int
s.db.QueryRowContext(ctx, `
SELECT COALESCE(SUM(count), 0) FROM daily_stats
WHERE app_id = ? AND event_type = 'app_start' AND date >= ? AND date <= ?
`, appID, startDate.Format("2006-01-02"), endDate.Format("2006-01-02")).Scan(&totalSessions)
// Calculate changes
dauChange := 0.0
if prevDAU > 0 {
dauChange = ((currentDAU - prevDAU) / prevDAU) * 100
}
crashChange := 0.0
if prevCrashes > 0 {
crashChange = ((float64(currentCrashes) - float64(prevCrashes)) / float64(prevCrashes)) * 100
}
crashFreeRate := 100.0
if totalSessions > 0 {
crashFreeRate = (1 - float64(currentCrashes)/float64(totalSessions)) * 100
if crashFreeRate < 0 {
crashFreeRate = 0
}
}
return &AnalyticsOverview{
DAU: int(currentDAU),
DAUChange: dauChange,
TotalCrashes: currentCrashes,
CrashChange: crashChange,
CrashFreeRate: crashFreeRate,
TotalSessions: totalSessions,
}, nil
}
// GetDailyStats returns daily statistics for an app
func (s *Service) GetDailyStats(ctx context.Context, appID, eventType string, days int) ([]DailyStats, error) {
startDate := time.Now().AddDate(0, 0, -days).Format("2006-01-02")
query := `
SELECT app_id, date, event_type, count, unique_devices
FROM daily_stats
WHERE app_id = ? AND date >= ?
`
args := []interface{}{appID, startDate}
if eventType != "" {
query += " AND event_type = ?"
args = append(args, eventType)
}
query += " ORDER BY date ASC"
rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var stats []DailyStats
for rows.Next() {
var s DailyStats
if err := rows.Scan(&s.AppID, &s.Date, &s.EventType, &s.Count, &s.UniqueDevices); err != nil {
return nil, err
}
stats = append(stats, s)
}
return stats, rows.Err()
}
// GetCrashGroups returns crash groups for an app
func (s *Service) GetCrashGroups(ctx context.Context, appID, status string, limit, offset int) ([]CrashGroup, int, error) {
// Count total
countQuery := "SELECT COUNT(*) FROM crash_groups WHERE app_id = ?"
args := []interface{}{appID}
if status != "" {
countQuery += " AND status = ?"
args = append(args, status)
}
var total int
if err := s.db.QueryRowContext(ctx, countQuery, args...).Scan(&total); err != nil {
return nil, 0, err
}
// Fetch groups
query := `
SELECT id, app_id, fingerprint, crash_type, message, sample_stack_trace,
first_seen, last_seen, occurrence_count, affected_versions, status
FROM crash_groups WHERE app_id = ?
`
args = []interface{}{appID}
if status != "" {
query += " AND status = ?"
args = append(args, status)
}
query += " ORDER BY last_seen DESC LIMIT ? OFFSET ?"
args = append(args, limit, offset)
rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, 0, err
}
defer rows.Close()
var groups []CrashGroup
for rows.Next() {
var g CrashGroup
var versionsJSON string
if err := rows.Scan(&g.ID, &g.AppID, &g.Fingerprint, &g.CrashType, &g.Message,
&g.SampleStackTrace, &g.FirstSeen, &g.LastSeen, &g.OccurrenceCount,
&versionsJSON, &g.Status); err != nil {
return nil, 0, err
}
json.Unmarshal([]byte(versionsJSON), &g.AffectedVersions)
groups = append(groups, g)
}
return groups, total, rows.Err()
}
// GetCrashGroup returns a single crash group with recent occurrences
func (s *Service) GetCrashGroup(ctx context.Context, appID, groupID string) (*CrashGroup, error) {
var g CrashGroup
var versionsJSON string
err := s.db.QueryRowContext(ctx, `
SELECT id, app_id, fingerprint, crash_type, message, sample_stack_trace,
first_seen, last_seen, occurrence_count, affected_versions, status
FROM crash_groups WHERE app_id = ? AND id = ?
`, appID, groupID).Scan(&g.ID, &g.AppID, &g.Fingerprint, &g.CrashType, &g.Message,
&g.SampleStackTrace, &g.FirstSeen, &g.LastSeen, &g.OccurrenceCount,
&versionsJSON, &g.Status)
if err != nil {
return nil, err
}
json.Unmarshal([]byte(versionsJSON), &g.AffectedVersions)
return &g, nil
}
// UpdateCrashGroupStatus updates the status of a crash group
func (s *Service) UpdateCrashGroupStatus(ctx context.Context, appID, groupID, status string) error {
_, err := s.db.ExecContext(ctx, `
UPDATE crash_groups SET status = ? WHERE app_id = ? AND id = ?
`, status, appID, groupID)
return err
}
// StartBackgroundWorkers starts the aggregation and cleanup workers
func (s *Service) StartBackgroundWorkers(ctx context.Context) {
// Hourly aggregation
go s.runPeriodic(ctx, time.Hour, "hourly aggregation", s.aggregateHourly)
// Daily aggregation at 2am
go s.runDaily(ctx, 2, "daily aggregation", s.aggregateDaily)
// Cleanup old events at 3am
go s.runDaily(ctx, 3, "event cleanup", s.cleanupOldEvents)
log.Println("Telemetry background workers started")
}
func (s *Service) runPeriodic(ctx context.Context, interval time.Duration, name string, fn func(context.Context) error) {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-s.stopCh:
return
case <-ticker.C:
if err := fn(ctx); err != nil {
log.Printf("Telemetry %s error: %v", name, err)
}
}
}
}
func (s *Service) runDaily(ctx context.Context, hour int, name string, fn func(context.Context) error) {
for {
now := time.Now()
next := time.Date(now.Year(), now.Month(), now.Day(), hour, 0, 0, 0, now.Location())
if next.Before(now) {
next = next.Add(24 * time.Hour)
}
wait := next.Sub(now)
select {
case <-ctx.Done():
return
case <-s.stopCh:
return
case <-time.After(wait):
if err := fn(ctx); err != nil {
log.Printf("Telemetry %s error: %v", name, err)
}
}
}
}
func (s *Service) aggregateHourly(ctx context.Context) error {
hour := time.Now().Add(-time.Hour).Format("2006-01-02T15")
_, err := s.db.ExecContext(ctx, `
INSERT OR REPLACE INTO hourly_stats (app_id, hour, event_type, count, unique_devices)
SELECT
app_id,
strftime('%Y-%m-%dT%H', timestamp) as hour,
event_type,
COUNT(*) as count,
COUNT(DISTINCT device_id) as unique_devices
FROM events
WHERE strftime('%Y-%m-%dT%H', timestamp) = ?
GROUP BY app_id, hour, event_type
`, hour)
if err == nil {
log.Printf("Telemetry: hourly aggregation completed for %s", hour)
}
return err
}
func (s *Service) aggregateDaily(ctx context.Context) error {
yesterday := time.Now().AddDate(0, 0, -1).Format("2006-01-02")
_, err := s.db.ExecContext(ctx, `
INSERT OR REPLACE INTO daily_stats (app_id, date, event_type, count, unique_devices)
SELECT
app_id,
? as date,
event_type,
SUM(count) as count,
SUM(unique_devices) as unique_devices
FROM hourly_stats
WHERE hour LIKE ? || 'T%'
GROUP BY app_id, event_type
`, yesterday, yesterday)
if err == nil {
log.Printf("Telemetry: daily aggregation completed for %s", yesterday)
}
return err
}
func (s *Service) cleanupOldEvents(ctx context.Context) error {
cutoff := time.Now().AddDate(0, 0, -7).Format(time.RFC3339)
result, err := s.db.ExecContext(ctx, "DELETE FROM events WHERE timestamp < ?", cutoff)
if err != nil {
return err
}
deleted, _ := result.RowsAffected()
log.Printf("Telemetry: cleaned up %d old events", deleted)
// Also clean old crash occurrences (90 days)
crashCutoff := time.Now().AddDate(0, 0, -90).Format(time.RFC3339)
s.db.ExecContext(ctx, "DELETE FROM crash_occurrences WHERE timestamp < ?", crashCutoff)
return nil
}
// Helper functions
func generateID() string {
hash := sha256.Sum256([]byte(fmt.Sprintf("%d", time.Now().UnixNano())))
return hex.EncodeToString(hash[:8])
}
func contains(slice []string, item string) bool {
for _, s := range slice {
if s == item {
return true
}
}
return false
}
// TriggerAggregation manually triggers aggregation (useful for testing)
func (s *Service) TriggerAggregation(ctx context.Context) error {
if err := s.aggregateHourly(ctx); err != nil {
return err
}
return s.aggregateDaily(ctx)
}

View File

@@ -8,6 +8,7 @@ import (
"github.com/omixlab/mosis-portal/internal/database"
"github.com/omixlab/mosis-portal/internal/review"
"github.com/omixlab/mosis-portal/internal/storage"
"github.com/omixlab/mosis-portal/internal/telemetry"
)
// Handler handles web page requests
@@ -16,6 +17,7 @@ type Handler struct {
templates *Templates
store *storage.Storage
review *review.Service
telemetry *telemetry.Service
}
// NewHandler creates a new web handler
@@ -36,6 +38,11 @@ func (h *Handler) SetStorage(store *storage.Storage) {
h.store = store
}
// SetTelemetry sets the telemetry service for the handler
func (h *Handler) SetTelemetry(ts *telemetry.Service) {
h.telemetry = ts
}
// PageData is the base data structure for all pages
type PageData struct {
Title string
@@ -436,3 +443,141 @@ func (h *Handler) AdminValidate(w http.ResponseWriter, r *http.Request) {
// Render validation results partial
h.renderPartial(w, "validation_results", result)
}
// AppAnalytics renders the app analytics page
func (h *Handler) AppAnalytics(w http.ResponseWriter, r *http.Request) {
developer := getDeveloperFromContext(r)
if developer == nil {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
appID := chi.URLParam(r, "appID")
app, err := h.db.GetApp(r.Context(), appID)
if err != nil {
http.Error(w, "App not found", http.StatusNotFound)
return
}
// Verify ownership
if app.DeveloperID != developer.ID {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
// Get days parameter
days := 30
if d := r.URL.Query().Get("days"); d != "" {
if parsed, err := strconv.Atoi(d); err == nil && parsed > 0 && parsed <= 365 {
days = parsed
}
}
// Get analytics data
var overview *telemetry.AnalyticsOverview
var eventStats []telemetry.DailyStats
var crashes []*telemetry.CrashGroup
if h.telemetry != nil {
overview, _ = h.telemetry.GetAnalyticsOverview(r.Context(), appID, days)
eventStats, _ = h.telemetry.GetDailyStats(r.Context(), appID, "", days)
crashes, _, _ = h.telemetry.GetCrashGroups(r.Context(), appID, "open", 5, 0)
}
if overview == nil {
overview = &telemetry.AnalyticsOverview{}
}
data := struct {
PageData
App *database.App
Days int
Overview *telemetry.AnalyticsOverview
EventStats []telemetry.DailyStats
Crashes []*telemetry.CrashGroup
}{
PageData: PageData{
Title: app.Name + " - Analytics",
ActiveNav: "apps",
Developer: developer,
},
App: app,
Days: days,
Overview: overview,
EventStats: eventStats,
Crashes: crashes,
}
h.render(w, "app_analytics", data)
}
// AppCrashes renders the app crashes page
func (h *Handler) AppCrashes(w http.ResponseWriter, r *http.Request) {
developer := getDeveloperFromContext(r)
if developer == nil {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
appID := chi.URLParam(r, "appID")
app, err := h.db.GetApp(r.Context(), appID)
if err != nil {
http.Error(w, "App not found", http.StatusNotFound)
return
}
// Verify ownership
if app.DeveloperID != developer.ID {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
// Get status filter
status := r.URL.Query().Get("status")
if status == "" {
status = "open"
}
// Pagination
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
if page < 1 {
page = 1
}
limit := 20
offset := (page - 1) * limit
// Get crashes
var crashes []*telemetry.CrashGroup
var total int
if h.telemetry != nil {
crashes, total, _ = h.telemetry.GetCrashGroups(r.Context(), appID, status, limit, offset)
}
data := struct {
PageData
App *database.App
Status string
Crashes []*telemetry.CrashGroup
Pagination struct {
Page int
Limit int
Total int
TotalPages int
}
}{
PageData: PageData{
Title: app.Name + " - Crashes",
ActiveNav: "apps",
Developer: developer,
},
App: app,
Status: status,
Crashes: crashes,
}
data.Pagination.Page = page
data.Pagination.Limit = limit
data.Pagination.Total = total
data.Pagination.TotalPages = (total + limit - 1) / limit
h.render(w, "app_crashes", data)
}

View File

@@ -0,0 +1,228 @@
{{define "content"}}
<div class="mb-6">
<a href="/dashboard" class="inline-flex items-center text-sm text-gray-500 hover:text-gray-700">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
</svg>
Back to Dashboard
</a>
</div>
<!-- App Header -->
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6 mb-6">
<div class="flex items-start justify-between">
<div class="flex items-center space-x-4">
<div class="w-16 h-16 bg-gray-100 rounded-xl flex items-center justify-center">
<svg class="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/>
</svg>
</div>
<div>
<h1 class="text-2xl font-bold text-gray-900">{{.App.Name}}</h1>
<p class="text-gray-500">{{.App.PackageID}}</p>
</div>
</div>
<div class="flex items-center space-x-2">
<select id="days-select" class="px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-indigo-500 focus:border-indigo-500"
hx-get="/apps/{{.App.ID}}/analytics"
hx-trigger="change"
hx-target="#analytics-content"
hx-select="#analytics-content"
hx-include="this">
<option value="7" {{if eq .Days 7}}selected{{end}}>Last 7 days</option>
<option value="30" {{if eq .Days 30}}selected{{end}}>Last 30 days</option>
<option value="90" {{if eq .Days 90}}selected{{end}}>Last 90 days</option>
</select>
</div>
</div>
</div>
<!-- Tabs -->
<div class="border-b border-gray-200 mb-6">
<nav class="-mb-px flex space-x-8">
<a href="/apps/{{.App.ID}}" class="border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm">
Overview
</a>
<a href="/apps/{{.App.ID}}/versions" class="border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm">
Versions
</a>
<a href="/apps/{{.App.ID}}/analytics" class="border-indigo-500 text-indigo-600 whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm">
Analytics
</a>
<a href="/apps/{{.App.ID}}/settings" class="border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm">
Settings
</a>
</nav>
</div>
<!-- Analytics Content -->
<div id="analytics-content">
<!-- Overview Stats -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<div class="flex items-center">
<div class="flex-1">
<p class="text-sm font-medium text-gray-500">Daily Active Users</p>
<p class="mt-1 text-3xl font-semibold text-gray-900">{{.Overview.DAU}}</p>
</div>
<div class="{{if ge .Overview.DAUChange 0.0}}text-green-500{{else}}text-red-500{{end}}">
{{if ge .Overview.DAUChange 0.0}}
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"/>
</svg>
{{else}}
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 17h8m0 0V9m0 8l-8-8-4 4-6-6"/>
</svg>
{{end}}
</div>
</div>
<p class="mt-2 text-sm {{if ge .Overview.DAUChange 0.0}}text-green-600{{else}}text-red-600{{end}}">
{{if ge .Overview.DAUChange 0.0}}+{{end}}{{printf "%.1f" .Overview.DAUChange}}% from previous period
</p>
</div>
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<div class="flex items-center">
<div class="flex-1">
<p class="text-sm font-medium text-gray-500">Total Sessions</p>
<p class="mt-1 text-3xl font-semibold text-gray-900">{{.Overview.TotalSessions}}</p>
</div>
<div class="text-indigo-500">
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
</div>
</div>
</div>
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<div class="flex items-center">
<div class="flex-1">
<p class="text-sm font-medium text-gray-500">Crash-Free Rate</p>
<p class="mt-1 text-3xl font-semibold text-gray-900">{{printf "%.1f" .Overview.CrashFreeRate}}%</p>
</div>
<div class="{{if ge .Overview.CrashFreeRate 99.0}}text-green-500{{else if ge .Overview.CrashFreeRate 95.0}}text-yellow-500{{else}}text-red-500{{end}}">
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
</div>
</div>
</div>
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<div class="flex items-center">
<div class="flex-1">
<p class="text-sm font-medium text-gray-500">Total Crashes</p>
<p class="mt-1 text-3xl font-semibold text-gray-900">{{.Overview.TotalCrashes}}</p>
</div>
<div class="{{if eq .Overview.TotalCrashes 0}}text-green-500{{else}}text-red-500{{end}}">
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
</svg>
</div>
</div>
{{if gt .Overview.TotalCrashes 0}}
<a href="/apps/{{.App.ID}}/crashes" class="mt-2 text-sm text-indigo-600 hover:text-indigo-700">
View crash reports →
</a>
{{end}}
</div>
</div>
<!-- Charts Section -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
<!-- DAU Chart -->
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<h3 class="text-lg font-semibold text-gray-900 mb-4">Daily Active Users</h3>
<div id="dau-chart" class="h-64"
hx-get="/apps/{{.App.ID}}/partials/chart-dau?days={{.Days}}"
hx-trigger="load">
<div class="flex items-center justify-center h-full text-gray-400">
<svg class="animate-spin h-8 w-8" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</div>
</div>
</div>
<!-- Sessions Chart -->
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<h3 class="text-lg font-semibold text-gray-900 mb-4">Sessions</h3>
<div id="sessions-chart" class="h-64"
hx-get="/apps/{{.App.ID}}/partials/chart-sessions?days={{.Days}}"
hx-trigger="load">
<div class="flex items-center justify-center h-full text-gray-400">
<svg class="animate-spin h-8 w-8" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</div>
</div>
</div>
</div>
<!-- Event Types -->
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6 mb-8">
<h3 class="text-lg font-semibold text-gray-900 mb-4">Event Distribution</h3>
{{if .EventStats}}
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead>
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Event Type</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Count</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Unique Devices</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
{{range .EventStats}}
<tr class="hover:bg-gray-50">
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">{{.EventType}}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 text-right">{{.Count}}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 text-right">{{.UniqueDevices}}</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{else}}
<p class="text-gray-500 text-center py-8">No events recorded yet.</p>
{{end}}
</div>
<!-- Recent Crashes -->
{{if .Crashes}}
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-900">Recent Crashes</h3>
<a href="/apps/{{.App.ID}}/crashes" class="text-sm text-indigo-600 hover:text-indigo-700">
View all →
</a>
</div>
<div class="space-y-4">
{{range .Crashes}}
<div class="flex items-start p-4 bg-red-50 border border-red-100 rounded-lg">
<div class="flex-shrink-0">
<svg class="w-5 h-5 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
</svg>
</div>
<div class="ml-3 flex-1">
<p class="text-sm font-medium text-red-800">{{.CrashType}}: {{.Message}}</p>
<div class="mt-1 flex items-center space-x-4 text-xs text-red-600">
<span>{{.OccurrenceCount}} occurrences</span>
<span>Last seen: {{.LastSeen}}</span>
</div>
</div>
<span class="ml-4 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium {{if eq .Status "open"}}bg-red-100 text-red-800{{else if eq .Status "resolved"}}bg-green-100 text-green-800{{else}}bg-gray-100 text-gray-800{{end}}">
{{.Status}}
</span>
</div>
{{end}}
</div>
</div>
{{end}}
</div>
{{end}}

View File

@@ -0,0 +1,148 @@
{{define "content"}}
<div class="mb-6">
<a href="/apps/{{.App.ID}}/analytics" class="inline-flex items-center text-sm text-gray-500 hover:text-gray-700">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
</svg>
Back to Analytics
</a>
</div>
<!-- Header -->
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6 mb-6">
<div class="flex items-center justify-between">
<div class="flex items-center space-x-4">
<div class="w-12 h-12 bg-red-100 rounded-xl flex items-center justify-center">
<svg class="w-6 h-6 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
</svg>
</div>
<div>
<h1 class="text-2xl font-bold text-gray-900">Crash Reports</h1>
<p class="text-gray-500">{{.App.Name}}</p>
</div>
</div>
<!-- Status Filter -->
<div class="flex items-center space-x-2">
<a href="/apps/{{.App.ID}}/crashes?status=open"
class="px-4 py-2 text-sm font-medium rounded-lg {{if eq .Status "open"}}bg-red-100 text-red-700{{else}}text-gray-500 hover:bg-gray-100{{end}}">
Open
</a>
<a href="/apps/{{.App.ID}}/crashes?status=resolved"
class="px-4 py-2 text-sm font-medium rounded-lg {{if eq .Status "resolved"}}bg-green-100 text-green-700{{else}}text-gray-500 hover:bg-gray-100{{end}}">
Resolved
</a>
<a href="/apps/{{.App.ID}}/crashes?status=ignored"
class="px-4 py-2 text-sm font-medium rounded-lg {{if eq .Status "ignored"}}bg-gray-200 text-gray-700{{else}}text-gray-500 hover:bg-gray-100{{end}}">
Ignored
</a>
</div>
</div>
</div>
<!-- Crashes List -->
{{if .Crashes}}
<div class="space-y-4">
{{range .Crashes}}
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-6 hover:border-gray-300 transition-colors">
<div class="flex items-start justify-between">
<div class="flex-1">
<div class="flex items-center space-x-2 mb-2">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">
{{.CrashType}}
</span>
<span class="text-xs text-gray-500">{{.OccurrenceCount}} occurrences</span>
</div>
<h3 class="text-lg font-medium text-gray-900 mb-2">{{.Message}}</h3>
{{if .SampleStackTrace}}
<pre class="mt-3 p-3 bg-gray-50 rounded-lg text-xs text-gray-600 overflow-x-auto max-h-32">{{.SampleStackTrace}}</pre>
{{end}}
<div class="mt-3 flex items-center space-x-4 text-xs text-gray-500">
<span>First seen: {{.FirstSeen}}</span>
<span>Last seen: {{.LastSeen}}</span>
{{if .AffectedVersions}}
<span>Versions: {{range $i, $v := .AffectedVersions}}{{if $i}}, {{end}}{{$v}}{{end}}</span>
{{end}}
</div>
</div>
<div class="ml-6 flex flex-col items-end space-y-2">
<span class="inline-flex items-center px-2.5 py-0.5 rounded text-xs font-medium {{if eq .Status "open"}}bg-red-100 text-red-800{{else if eq .Status "resolved"}}bg-green-100 text-green-800{{else}}bg-gray-100 text-gray-800{{end}}">
{{.Status}}
</span>
<div class="flex space-x-2">
{{if eq .Status "open"}}
<button hx-post="/apps/{{$.App.ID}}/crashes/{{.ID}}/resolve"
hx-swap="outerHTML"
hx-target="closest div.bg-white"
class="text-xs text-green-600 hover:text-green-700 font-medium">
Mark Resolved
</button>
<button hx-post="/apps/{{$.App.ID}}/crashes/{{.ID}}/ignore"
hx-swap="outerHTML"
hx-target="closest div.bg-white"
class="text-xs text-gray-500 hover:text-gray-700 font-medium">
Ignore
</button>
{{else if eq .Status "resolved"}}
<button hx-post="/apps/{{$.App.ID}}/crashes/{{.ID}}/reopen"
hx-swap="outerHTML"
hx-target="closest div.bg-white"
class="text-xs text-red-600 hover:text-red-700 font-medium">
Reopen
</button>
{{else}}
<button hx-post="/apps/{{$.App.ID}}/crashes/{{.ID}}/reopen"
hx-swap="outerHTML"
hx-target="closest div.bg-white"
class="text-xs text-red-600 hover:text-red-700 font-medium">
Reopen
</button>
{{end}}
</div>
</div>
</div>
</div>
{{end}}
</div>
<!-- Pagination -->
{{if gt .Pagination.TotalPages 1}}
<div class="mt-6 flex items-center justify-between">
<div class="text-sm text-gray-500">
Showing {{add (mul (sub .Pagination.Page 1) .Pagination.Limit) 1}} to {{min (mul .Pagination.Page .Pagination.Limit) .Pagination.Total}} of {{.Pagination.Total}} crashes
</div>
<nav class="flex space-x-2">
{{if gt .Pagination.Page 1}}
<a href="/apps/{{.App.ID}}/crashes?status={{.Status}}&page={{sub .Pagination.Page 1}}"
class="px-3 py-2 text-sm font-medium text-gray-500 bg-white border border-gray-300 rounded-md hover:bg-gray-50">
Previous
</a>
{{end}}
{{if lt .Pagination.Page .Pagination.TotalPages}}
<a href="/apps/{{.App.ID}}/crashes?status={{.Status}}&page={{add .Pagination.Page 1}}"
class="px-3 py-2 text-sm font-medium text-gray-500 bg-white border border-gray-300 rounded-md hover:bg-gray-50">
Next
</a>
{{end}}
</nav>
</div>
{{end}}
{{else}}
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-12 text-center">
<svg class="mx-auto h-12 w-12 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<h3 class="mt-4 text-lg font-medium text-gray-900">No crashes</h3>
<p class="mt-2 text-sm text-gray-500">
{{if eq .Status "open"}}
No open crash reports. Your app is running smoothly!
{{else if eq .Status "resolved"}}
No resolved crashes to show.
{{else}}
No ignored crashes.
{{end}}
</p>
</div>
{{end}}
{{end}}