feat(hub): app telemetry analytics dashboard (v0.4.0)
- store/telemetry.go: new app_telemetry + app_log_issues tables with
SaveAppTelemetry, GetFleetAppSummary (with P95), GetAppTelemetryHistory,
GetAppCustomerBreakdown, GetCustomerAppSummary, GetAppIssues, prune methods
- api/handler.go: parse and save optional app_telemetry from report body,
backward-compatible with old controllers
- cmd/hub/main.go: prune app_telemetry (90d) and stale issues (30d)
- web/apps.go: handleApps + handleAppDetail + chart data aggregation helpers
- web/server.go: routes for /apps, /apps/{name}, /static/chart.min.js;
added memoryColor/accuracyClass/gt template functions
- web/embed.go: embed static/chart.min.js
- web/configs.go: add app telemetry section to handleCustomerUnified
- templates/apps.html: fleet-wide app list with summary cards and sortable table
- templates/app_detail.html: per-app page with Chart.js memory trend,
customer breakdown, and known issues table
- templates/customer_unified.html: new Alkalmazás telemetria card
- templates/style.css: badge, summary-card, chart, period-selector,
accuracy-dot, mem-color, data-table styles
- All templates: added Alkalmazások nav link
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,261 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
|
||||
)
|
||||
|
||||
// ChartData holds aggregated time-series data for Chart.js.
|
||||
type ChartData struct {
|
||||
Labels []string `json:"labels"`
|
||||
AvgMemory []float64 `json:"avg_memory"`
|
||||
PeakMemory []float64 `json:"peak_memory"`
|
||||
CatalogLimit float64 `json:"catalog_limit"`
|
||||
}
|
||||
|
||||
// handleApps renders the fleet-wide app list page.
|
||||
func (s *Server) handleApps(w http.ResponseWriter, r *http.Request) {
|
||||
period := r.URL.Query().Get("period")
|
||||
since := parsePeriod(period, 7*24*time.Hour)
|
||||
|
||||
sortBy := r.URL.Query().Get("sort")
|
||||
order := r.URL.Query().Get("order")
|
||||
|
||||
summary, err := s.store.GetFleetAppSummary(since)
|
||||
if err != nil {
|
||||
s.logger.Printf("[ERROR] GetFleetAppSummary: %v", err)
|
||||
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
sortFleetSummary(summary, sortBy, order)
|
||||
|
||||
totalApps := len(summary)
|
||||
totalDeployments := 0
|
||||
appsWithErrors := 0
|
||||
for _, app := range summary {
|
||||
totalDeployments += app.DeploymentCount
|
||||
if app.TotalErrors > 0 {
|
||||
appsWithErrors++
|
||||
}
|
||||
}
|
||||
|
||||
csrfToken := s.getCSRFToken(r)
|
||||
data := map[string]interface{}{
|
||||
"Apps": summary,
|
||||
"Period": period,
|
||||
"TotalApps": totalApps,
|
||||
"TotalDeployments": totalDeployments,
|
||||
"AppsWithErrors": appsWithErrors,
|
||||
"Sort": sortBy,
|
||||
"Order": order,
|
||||
"CSRFToken": csrfToken,
|
||||
}
|
||||
if err := s.templates.ExecuteTemplate(w, "apps.html", data); err != nil {
|
||||
s.logger.Printf("[ERROR] apps.html template: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// handleAppDetail renders the per-app detail page.
|
||||
func (s *Server) handleAppDetail(w http.ResponseWriter, r *http.Request, appName string) {
|
||||
period := r.URL.Query().Get("period")
|
||||
since := parsePeriod(period, 7*24*time.Hour)
|
||||
|
||||
customers, _ := s.store.GetAppCustomerBreakdown(appName, since)
|
||||
history, _ := s.store.GetAppTelemetryHistory(appName, since)
|
||||
issues, _ := s.store.GetAppIssues(appName, 20)
|
||||
|
||||
// Get fleet summary to find this app's summary
|
||||
fleetAll, _ := s.store.GetFleetAppSummary(since)
|
||||
var appSummary *store.FleetAppSummary
|
||||
for i := range fleetAll {
|
||||
if fleetAll[i].AppName == appName {
|
||||
appSummary = &fleetAll[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Suggested mem_limit: ceil(P95 * 1.2), rounded up to nearest 32M
|
||||
var suggestedLimit int
|
||||
if appSummary != nil && appSummary.P95MemoryMB > 0 {
|
||||
raw := appSummary.P95MemoryMB * 1.2
|
||||
suggestedLimit = ((int(raw) + 31) / 32) * 32
|
||||
}
|
||||
|
||||
chartData := aggregateHistoryForChart(history, appSummary)
|
||||
|
||||
csrfToken := s.getCSRFToken(r)
|
||||
data := map[string]interface{}{
|
||||
"AppName": appName,
|
||||
"Summary": appSummary,
|
||||
"Customers": customers,
|
||||
"Issues": issues,
|
||||
"ChartData": chartData,
|
||||
"SuggestedLimit": suggestedLimit,
|
||||
"Period": period,
|
||||
"CSRFToken": csrfToken,
|
||||
}
|
||||
if err := s.templates.ExecuteTemplate(w, "app_detail.html", data); err != nil {
|
||||
s.logger.Printf("[ERROR] app_detail.html template: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// parsePeriod converts a period string to a time.Time cutoff.
|
||||
func parsePeriod(s string, defaultDur time.Duration) time.Time {
|
||||
switch s {
|
||||
case "24h":
|
||||
return time.Now().Add(-24 * time.Hour)
|
||||
case "7d":
|
||||
return time.Now().Add(-7 * 24 * time.Hour)
|
||||
case "30d":
|
||||
return time.Now().Add(-30 * 24 * time.Hour)
|
||||
default:
|
||||
return time.Now().Add(-defaultDur)
|
||||
}
|
||||
}
|
||||
|
||||
// sortFleetSummary sorts the fleet summary slice in place.
|
||||
func sortFleetSummary(summary []store.FleetAppSummary, sortBy, order string) {
|
||||
desc := order != "asc"
|
||||
sort.Slice(summary, func(i, j int) bool {
|
||||
var less bool
|
||||
switch sortBy {
|
||||
case "memory":
|
||||
less = summary[i].AvgMemoryMB < summary[j].AvgMemoryMB
|
||||
case "errors":
|
||||
less = summary[i].TotalErrors < summary[j].TotalErrors
|
||||
default: // deployments
|
||||
less = summary[i].DeploymentCount < summary[j].DeploymentCount
|
||||
}
|
||||
if desc {
|
||||
return !less
|
||||
}
|
||||
return less
|
||||
})
|
||||
}
|
||||
|
||||
// aggregateHistoryForChart groups history points into hourly buckets for Chart.js.
|
||||
func aggregateHistoryForChart(history []store.AppTelemetryPoint, summary *store.FleetAppSummary) ChartData {
|
||||
type bucket struct {
|
||||
avgMemSum float64
|
||||
peakMemMax float64
|
||||
count int
|
||||
}
|
||||
|
||||
buckets := make(map[string]*bucket)
|
||||
var bucketOrder []string
|
||||
|
||||
for _, p := range history {
|
||||
key := p.ReportedAt.UTC().Format("2006-01-02 15:00")
|
||||
if _, ok := buckets[key]; !ok {
|
||||
buckets[key] = &bucket{}
|
||||
bucketOrder = append(bucketOrder, key)
|
||||
}
|
||||
b := buckets[key]
|
||||
b.avgMemSum += p.MemoryAvgMB
|
||||
if p.MemoryPeakMB > b.peakMemMax {
|
||||
b.peakMemMax = p.MemoryPeakMB
|
||||
}
|
||||
b.count++
|
||||
}
|
||||
|
||||
cd := ChartData{
|
||||
Labels: make([]string, 0, len(bucketOrder)),
|
||||
AvgMemory: make([]float64, 0, len(bucketOrder)),
|
||||
PeakMemory: make([]float64, 0, len(bucketOrder)),
|
||||
}
|
||||
|
||||
for _, key := range bucketOrder {
|
||||
b := buckets[key]
|
||||
cd.Labels = append(cd.Labels, key)
|
||||
avgMem := 0.0
|
||||
if b.count > 0 {
|
||||
avgMem = b.avgMemSum / float64(b.count)
|
||||
}
|
||||
cd.AvgMemory = append(cd.AvgMemory, round2(avgMem))
|
||||
cd.PeakMemory = append(cd.PeakMemory, round2(b.peakMemMax))
|
||||
}
|
||||
|
||||
if summary != nil {
|
||||
cd.CatalogLimit = parseLimitMB(summary.CatalogLimit)
|
||||
}
|
||||
|
||||
return cd
|
||||
}
|
||||
|
||||
// parseLimitMB parses a memory limit string like "512M" or "2G" to MB float64.
|
||||
func parseLimitMB(s string) float64 {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return 0
|
||||
}
|
||||
s = strings.ToUpper(s)
|
||||
if strings.HasSuffix(s, "G") {
|
||||
v, _ := strconv.ParseFloat(s[:len(s)-1], 64)
|
||||
return v * 1024
|
||||
}
|
||||
if strings.HasSuffix(s, "M") {
|
||||
v, _ := strconv.ParseFloat(s[:len(s)-1], 64)
|
||||
return v
|
||||
}
|
||||
v, _ := strconv.ParseFloat(s, 64)
|
||||
return v
|
||||
}
|
||||
|
||||
// round2 rounds a float64 to 2 decimal places.
|
||||
func round2(v float64) float64 {
|
||||
return float64(int(v*100+0.5)) / 100
|
||||
}
|
||||
|
||||
// memoryColor returns a CSS class based on current memory vs catalog limit.
|
||||
func memoryColor(currentMB float64, limitStr string) string {
|
||||
limit := parseLimitMB(limitStr)
|
||||
if limit <= 0 {
|
||||
return ""
|
||||
}
|
||||
ratio := currentMB / limit
|
||||
if ratio >= 1.0 {
|
||||
return "mem-danger"
|
||||
}
|
||||
if ratio >= 0.5 {
|
||||
return "mem-warn"
|
||||
}
|
||||
return "mem-ok"
|
||||
}
|
||||
|
||||
// accuracyClass returns accuracy indicator CSS class for P95 vs catalog limit.
|
||||
func accuracyClass(p95MB float64, limitStr string) string {
|
||||
if p95MB <= 0 || limitStr == "" {
|
||||
return ""
|
||||
}
|
||||
limit := parseLimitMB(limitStr)
|
||||
if limit <= 0 {
|
||||
return ""
|
||||
}
|
||||
if p95MB > limit {
|
||||
return "danger"
|
||||
}
|
||||
if p95MB*2 > limit {
|
||||
return "warn"
|
||||
}
|
||||
return "ok"
|
||||
}
|
||||
|
||||
// getCSRFToken retrieves the CSRF token from the session cookie.
|
||||
func (s *Server) getCSRFToken(r *http.Request) string {
|
||||
cookie, err := r.Cookie("hub_session")
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
s.sessionsMu.RLock()
|
||||
defer s.sessionsMu.RUnlock()
|
||||
if sess, ok := s.sessions[cookie.Value]; ok {
|
||||
return sess.csrfToken
|
||||
}
|
||||
return ""
|
||||
}
|
||||
Reference in New Issue
Block a user