add telemetry system with analytics and crash reporting (M08)
This commit is contained in:
269
portal/internal/api/handlers/telemetry.go
Normal file
269
portal/internal/api/handlers/telemetry.go
Normal 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)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
666
portal/internal/telemetry/telemetry.go
Normal file
666
portal/internal/telemetry/telemetry.go
Normal 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(¤tDAU)
|
||||
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(¤tCrashes)
|
||||
|
||||
// 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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
228
portal/internal/web/templates/pages/app_analytics.html
Normal file
228
portal/internal/web/templates/pages/app_analytics.html
Normal 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}}
|
||||
148
portal/internal/web/templates/pages/app_crashes.html
Normal file
148
portal/internal/web/templates/pages/app_crashes.html
Normal 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}}
|
||||
Reference in New Issue
Block a user