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:
2026-02-23 10:46:50 +01:00
parent 8bed5ec339
commit a757bee07a
20 changed files with 1323 additions and 2 deletions
+261
View File
@@ -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 ""
}
+9
View File
@@ -233,6 +233,8 @@ func (s *Server) handleCustomerUnified(w http.ResponseWriter, r *http.Request, c
var events []store.Event
var eventCounts map[string]int
var appTelemetry []store.CustomerAppSummary
if customer != nil {
history, _ = s.store.GetCustomerHistory(customerID, 24*time.Hour)
notifPrefs, _ = s.store.GetNotificationPrefs(customerID)
@@ -243,6 +245,7 @@ func (s *Server) handleCustomerUnified(w http.ResponseWriter, r *http.Request, c
}
events, _ = s.store.GetRecentEvents(customerID, 50)
eventCounts, _ = s.store.CountEventsBySeverity(customerID, time.Now().Add(-24*time.Hour))
appTelemetry, _ = s.store.GetCustomerAppSummary(customerID, time.Now().Add(-7*24*time.Hour))
}
type pageData struct {
@@ -277,6 +280,9 @@ func (s *Server) handleCustomerUnified(w http.ResponseWriter, r *http.Request, c
Events []store.Event
EventCounts map[string]int // severity → count (last 24h)
AppTelemetry []store.CustomerAppSummary
HasAppTelemetry bool
Flash string
ActiveNav string
CSRFField template.HTML
@@ -315,6 +321,9 @@ func (s *Server) handleCustomerUnified(w http.ResponseWriter, r *http.Request, c
Events: events,
EventCounts: eventCounts,
AppTelemetry: appTelemetry,
HasAppTelemetry: len(appTelemetry) > 0,
Flash: r.URL.Query().Get("flash"),
ActiveNav: "configs",
CSRFField: s.csrfField(r),
+3
View File
@@ -7,3 +7,6 @@ var templateFS embed.FS
//go:embed controller.yaml.default
var defaultControllerTemplate string
//go:embed static/chart.min.js
var chartJS []byte
+12
View File
@@ -63,6 +63,9 @@ func New(store *store.Store, passwordHash, apiKey, version string, staleThreshol
}
return m[key]
},
"memoryColor": memoryColor,
"accuracyClass": accuracyClass,
"gt": func(a, b int) bool { return a > b },
}
tmpl := template.Must(template.New("").Funcs(funcMap).ParseFS(templateFS, "templates/*.html"))
@@ -131,6 +134,15 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
s.handleDashboard(w, r)
case path == "/style.css":
s.handleCSS(w, r)
case path == "/static/chart.min.js":
w.Header().Set("Content-Type", "application/javascript")
w.Header().Set("Cache-Control", "public, max-age=86400")
w.Write(chartJS)
case path == "/apps" || path == "/apps/":
s.handleApps(w, r)
case strings.HasPrefix(path, "/apps/"):
appName := strings.TrimPrefix(path, "/apps/")
s.handleAppDetail(w, r, appName)
case path == "/login":
s.handleLogin(w, r)
case strings.HasPrefix(path, "/customers/") && strings.HasSuffix(path, "/trigger-update"):
File diff suppressed because one or more lines are too long
+209
View File
@@ -0,0 +1,209 @@
<!DOCTYPE html>
<html lang="hu">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.AppName}} — Felhom Hub</title>
<link rel="stylesheet" href="/style.css">
<script src="/static/chart.min.js"></script>
</head>
<body>
<div class="container">
<header>
<h1>Felhom Hub</h1>
<nav class="nav-links">
<a href="/" class="nav-link">Dashboard</a>
<a href="/configs" class="nav-link">Customers</a>
<a href="/apps" class="nav-link active">Alkalmazások</a>
</nav>
</header>
<a href="/apps{{if .Period}}?period={{.Period}}{{end}}" class="back-link">&larr; Alkalmazások</a>
<!-- Period selector -->
<div class="period-selector" style="margin-top: 1rem;">
<a href="?period=24h" class="period-btn{{if eq .Period "24h"}} active{{end}}">24 óra</a>
<a href="?period=7d" class="period-btn{{if or (eq .Period "7d") (eq .Period "")}} active{{end}}">7 nap</a>
<a href="?period=30d" class="period-btn{{if eq .Period "30d"}} active{{end}}">30 nap</a>
</div>
<!-- Overview card -->
<section class="card">
<h2>{{if .Summary}}{{if .Summary.DisplayName}}{{.Summary.DisplayName}}{{else}}{{.AppName}}{{end}}{{else}}{{.AppName}}{{end}}</h2>
<div class="info-grid">
<div class="info-item">
<span class="label">App neve</span>
<span class="value" style="font-family: var(--font-mono)">{{.AppName}}</span>
</div>
{{if .Summary}}
<div class="info-item">
<span class="label">Telepítések</span>
<span class="value">{{.Summary.DeploymentCount}}</span>
</div>
<div class="info-item">
<span class="label">Katalógus becslés</span>
<span class="value">{{if .Summary.CatalogEstimate}}{{.Summary.CatalogEstimate}}{{else}}—{{end}}</span>
</div>
<div class="info-item">
<span class="label">Katalógus limit</span>
<span class="value">{{if .Summary.CatalogLimit}}{{.Summary.CatalogLimit}}{{else}}—{{end}}</span>
</div>
{{if .SuggestedLimit}}
<div class="info-item">
<span class="label">Javasolt limit (P95×1.2)</span>
<span class="value" style="color: var(--yellow)">{{.SuggestedLimit}} MB</span>
</div>
{{end}}
<div class="info-item">
<span class="label">Átl. memória</span>
<span class="value">{{formatFloat .Summary.AvgMemoryMB}} MB</span>
</div>
<div class="info-item">
<span class="label">P95 memória</span>
<span class="value {{accuracyClass .Summary.P95MemoryMB .Summary.CatalogLimit}}">{{formatFloat .Summary.P95MemoryMB}} MB</span>
</div>
<div class="info-item">
<span class="label">Átl. CPU</span>
<span class="value">{{formatFloat .Summary.AvgCPU}}%</span>
</div>
{{end}}
</div>
</section>
<!-- Memory trend chart -->
<section class="card">
<h2>Memória trend</h2>
<div class="chart-container">
<canvas id="memoryChart"></canvas>
</div>
<script>
(function() {
var chartData = {{json .ChartData}};
if (!chartData || !chartData.labels || chartData.labels.length === 0) {
document.getElementById('memoryChart').parentElement.innerHTML = '<p class="text-muted">Nincs elegendő adat a grafikonhoz.</p>';
return;
}
var ctx = document.getElementById('memoryChart').getContext('2d');
var datasets = [
{
label: 'Átl. memória (MB)',
data: chartData.avg_memory,
borderColor: '#60a5fa',
backgroundColor: 'rgba(96,165,250,0.1)',
fill: true,
tension: 0.3,
pointRadius: 2
},
{
label: 'Csúcs memória (MB)',
data: chartData.peak_memory,
borderColor: '#f87171',
backgroundColor: 'transparent',
fill: false,
tension: 0.3,
pointRadius: 2,
borderDash: [4, 2]
}
];
if (chartData.catalog_limit > 0) {
datasets.push({
label: 'Katalógus limit',
data: chartData.labels.map(function() { return chartData.catalog_limit; }),
borderColor: '#4ade80',
backgroundColor: 'transparent',
fill: false,
pointRadius: 0,
borderWidth: 1,
borderDash: [6, 4]
});
}
new Chart(ctx, {
type: 'line',
data: { labels: chartData.labels, datasets: datasets },
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { labels: { color: '#94a3b8', font: { size: 11 } } }
},
scales: {
x: { ticks: { color: '#64748b', font: { size: 10 }, maxTicksLimit: 10 }, grid: { color: 'rgba(100,116,139,0.15)' } },
y: { ticks: { color: '#64748b', font: { size: 10 } }, grid: { color: 'rgba(100,116,139,0.15)' }, title: { display: true, text: 'MB', color: '#64748b' } }
}
}
});
})();
</script>
</section>
<!-- Customer breakdown -->
{{if .Customers}}
<section class="card">
<h2>Ügyfél bontás</h2>
<table class="data-table">
<thead>
<tr>
<th>Ügyfél</th>
<th>Átl. memória</th>
<th>Csúcs memória</th>
<th>Átl. CPU</th>
<th>Hibák összesen</th>
<th>Utolsó riport</th>
</tr>
</thead>
<tbody>
{{range .Customers}}
<tr>
<td><a href="/customers/{{.CustomerID}}">{{.CustomerID}}</a></td>
<td>{{formatFloat .AvgMemoryMB}} MB</td>
<td>{{formatFloat .PeakMemoryMB}} MB</td>
<td>{{formatFloat .AvgCPU}}%</td>
<td>{{if gt .TotalErrors 0}}<span class="badge badge-error">{{.TotalErrors}}</span>{{else}}0{{end}}</td>
<td>{{timeAgo .LastReport}}</td>
</tr>
{{end}}
</tbody>
</table>
</section>
{{end}}
<!-- Known issues -->
{{if .Issues}}
<section class="card">
<h2>Ismert hibák</h2>
<table class="data-table">
<thead>
<tr>
<th>Súlyosság</th>
<th>Üzenet</th>
<th>Előfordulások</th>
<th>Érintett ügyfelek</th>
<th>Első észlelés</th>
<th>Utolsó észlelés</th>
</tr>
</thead>
<tbody>
{{range .Issues}}
<tr>
<td>
{{if eq .Severity "error"}}<span class="badge badge-error">error</span>
{{else}}<span class="badge badge-warn">warn</span>{{end}}
</td>
<td style="font-family: var(--font-mono); font-size: 0.8rem; max-width: 40ch; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="{{.Message}}">{{.Message}}</td>
<td>{{.OccurrenceCount}}</td>
<td>{{len .AffectedCustomers}}</td>
<td>{{timeAgo .FirstSeen}}</td>
<td>{{timeAgo .LastSeen}}</td>
</tr>
{{end}}
</tbody>
</table>
</section>
{{end}}
<footer style="margin-top: 2rem; color: var(--text-muted); font-size: 0.8rem; text-align: center;">
Felhom Hub <span style="font-family: var(--font-mono)">v{{hubVersion}}</span>
</footer>
</div>
</body>
</html>
+97
View File
@@ -0,0 +1,97 @@
<!DOCTYPE html>
<html lang="hu">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Alkalmazások — Felhom Hub</title>
<link rel="stylesheet" href="/style.css">
</head>
<body>
<div class="container">
<header>
<h1>Felhom Hub</h1>
<nav class="nav-links">
<a href="/" class="nav-link">Dashboard</a>
<a href="/configs" class="nav-link">Customers</a>
<a href="/apps" class="nav-link active">Alkalmazások</a>
</nav>
</header>
<h2 style="margin-bottom: 1rem;">Alkalmazás telemetria</h2>
<!-- Period selector -->
<div class="period-selector">
<a href="?period=24h{{if .Sort}}&sort={{.Sort}}&order={{.Order}}{{end}}" class="period-btn{{if eq .Period "24h"}} active{{end}}">24 óra</a>
<a href="?period=7d{{if .Sort}}&sort={{.Sort}}&order={{.Order}}{{end}}" class="period-btn{{if or (eq .Period "7d") (eq .Period "")}} active{{end}}">7 nap</a>
<a href="?period=30d{{if .Sort}}&sort={{.Sort}}&order={{.Order}}{{end}}" class="period-btn{{if eq .Period "30d"}} active{{end}}">30 nap</a>
</div>
<!-- Summary cards -->
<div class="summary-cards">
<div class="summary-card">
<div class="card-number">{{.TotalApps}}</div>
<div class="card-label">Alkalmazás összesen</div>
</div>
<div class="summary-card">
<div class="card-number">{{.TotalDeployments}}</div>
<div class="card-label">Telepítések száma</div>
</div>
<div class="summary-card">
<div class="card-number" {{if gt .AppsWithErrors 0}}style="color: var(--red)"{{end}}>{{.AppsWithErrors}}</div>
<div class="card-label">Hibás alkalmazások</div>
</div>
</div>
<!-- App table -->
{{if .Apps}}
<section class="card" style="padding: 0; overflow: hidden;">
<table class="data-table">
<thead>
<tr>
<th><a href="?period={{.Period}}&sort=name&order={{if eq .Sort "name"}}{{if eq .Order "asc"}}desc{{else}}asc{{end}}{{else}}asc{{end}}">Alkalmazás</a></th>
<th><a href="?period={{.Period}}&sort=deployments&order={{if eq .Sort "deployments"}}{{if eq .Order "asc"}}desc{{else}}asc{{end}}{{else}}desc{{end}}">Telepítések</a></th>
<th><a href="?period={{.Period}}&sort=memory&order={{if eq .Sort "memory"}}{{if eq .Order "asc"}}desc{{else}}asc{{end}}{{else}}desc{{end}}">Átl. memória</a></th>
<th>P95 memória</th>
<th>Katalógus becslés</th>
<th>Katalógus limit</th>
<th>Pontosság</th>
<th><a href="?period={{.Period}}&sort=errors&order={{if eq .Sort "errors"}}{{if eq .Order "asc"}}desc{{else}}asc{{end}}{{else}}desc{{end}}">Hibák</a></th>
<th>Figyelmeztetések</th>
</tr>
</thead>
<tbody>
{{range .Apps}}
<tr>
<td><a href="/apps/{{.AppName}}">{{if .DisplayName}}{{.DisplayName}}{{else}}{{.AppName}}{{end}}</a></td>
<td>{{.DeploymentCount}}</td>
<td>{{formatFloat .AvgMemoryMB}} MB</td>
<td>{{formatFloat .P95MemoryMB}} MB</td>
<td>{{if .CatalogEstimate}}{{.CatalogEstimate}}{{else}}—{{end}}</td>
<td>{{if .CatalogLimit}}{{.CatalogLimit}}{{else}}—{{end}}</td>
<td>
{{$ac := accuracyClass .P95MemoryMB .CatalogLimit}}
{{if eq $ac "ok"}}<span class="accuracy-dot accuracy-ok" title="P95 rendben"></span>
{{else if eq $ac "warn"}}<span class="accuracy-dot accuracy-warn" title="P95 &gt; limit 50%"></span>
{{else if eq $ac "danger"}}<span class="accuracy-dot accuracy-danger" title="P95 meghaladja a limitet"></span>
{{else}}—{{end}}
</td>
<td>{{if gt .TotalErrors 0}}<span class="badge badge-error">{{.TotalErrors}}</span>{{else}}0{{end}}</td>
<td>{{if gt .TotalWarnings 0}}<span class="badge badge-warn">{{.TotalWarnings}}</span>{{else}}0{{end}}</td>
</tr>
{{end}}
</tbody>
</table>
</section>
{{else}}
<div class="empty-state">
<p>Nincs telemetria adat a kiválasztott időszakra.</p>
<p class="hint">Az alkalmazás telemetria a következő riport beérkezése után jelenik meg (v0.28.0+ vezérlő szükséges).</p>
</div>
{{end}}
<footer style="margin-top: 2rem; color: var(--text-muted); font-size: 0.8rem; text-align: center;">
Felhom Hub <span style="font-family: var(--font-mono)">v{{hubVersion}}</span>
</footer>
</div>
</body>
</html>
@@ -13,6 +13,7 @@
<nav class="nav-links">
<a href="/" class="nav-link">Dashboard</a>
<a href="/configs" class="nav-link active">Customers</a>
<a href="/apps" class="nav-link">Alkalmazások</a>
</nav>
</header>
@@ -13,6 +13,7 @@
<nav class="nav-links">
<a href="/" class="nav-link">Dashboard</a>
<a href="/configs" class="nav-link active">Customers</a>
<a href="/apps" class="nav-link">Alkalmazások</a>
</nav>
</header>
+1
View File
@@ -13,6 +13,7 @@
<nav class="nav-links">
<a href="/" class="nav-link">Dashboard</a>
<a href="/configs" class="nav-link active">Customers</a>
<a href="/apps" class="nav-link">Alkalmazások</a>
</nav>
</header>
+1
View File
@@ -13,6 +13,7 @@
<nav class="nav-links" style="margin-bottom: 0.5rem;">
<a href="/" class="nav-link">Dashboard</a>
<a href="/configs" class="nav-link">Customers</a>
<a href="/apps" class="nav-link">Alkalmazások</a>
</nav>
<a href="/" class="back-link">&larr; Back to Dashboard</a>
<h1>
@@ -15,6 +15,7 @@
<nav class="nav-links" style="margin-bottom: 0.5rem;">
<a href="/" class="nav-link">Dashboard</a>
<a href="/configs" class="nav-link active">Customers</a>
<a href="/apps" class="nav-link">Alkalmazások</a>
</nav>
<a href="/configs" class="back-link">&larr; All Customers</a>
<h1>
@@ -470,6 +471,39 @@
{{end}}
</section>
<!-- Alkalmazás telemetria -->
{{if .HasAppTelemetry}}
<section class="card">
<h2>Alkalmazás telemetria <span class="text-muted" style="font-size: 0.85em; font-weight: normal;">(utolsó 7 nap)</span></h2>
<table class="data-table">
<thead>
<tr>
<th>Alkalmazás</th>
<th>Memória (jelenlegi)</th>
<th>Memória (átlag 7d)</th>
<th>Memória (csúcs 7d)</th>
<th>Katalógus limit</th>
<th>Hibák</th>
<th>Figyelmeztetések</th>
</tr>
</thead>
<tbody>
{{range .AppTelemetry}}
<tr>
<td><a href="/apps/{{.AppName}}">{{if .DisplayName}}{{.DisplayName}}{{else}}{{.AppName}}{{end}}</a></td>
<td class="{{memoryColor .MemoryCurrentMB .CatalogLimit}}">{{formatFloat .MemoryCurrentMB}} MB</td>
<td>{{formatFloat .MemoryAvgMB}} MB</td>
<td>{{formatFloat .MemoryPeakMB}} MB</td>
<td>{{if .CatalogLimit}}{{.CatalogLimit}}{{else}}—{{end}}</td>
<td>{{if gt .LogErrors 0}}<span class="badge badge-error">{{.LogErrors}}</span>{{else}}0{{end}}</td>
<td>{{if gt .LogWarnings 0}}<span class="badge badge-warn">{{.LogWarnings}}</span>{{else}}0{{end}}</td>
</tr>
{{end}}
</tbody>
</table>
</section>
{{end}}
<!-- Notifications -->
<section class="card">
<h2>Notifications</h2>
@@ -14,6 +14,7 @@
<nav class="nav-links">
<a href="/" class="nav-link active">Dashboard</a>
<a href="/configs" class="nav-link">Customers</a>
<a href="/apps" class="nav-link">Alkalmazások</a>
</nav>
</header>
+131
View File
@@ -618,6 +618,137 @@ code {
.diff-hub_only td { color: #3b82f6; }
.diff-controller_only td { color: #fb923c; }
/* Badge styles */
.badge {
display: inline-block;
padding: 0.15rem 0.5rem;
border-radius: 0.25rem;
font-size: 0.8rem;
font-weight: 600;
font-family: var(--font-mono);
}
.badge-error {
background: rgba(248, 113, 113, 0.2);
color: var(--red);
}
.badge-warn {
background: rgba(250, 204, 21, 0.2);
color: var(--yellow);
}
/* Summary cards row */
.summary-cards {
display: flex;
gap: 1rem;
margin-bottom: 1.5rem;
flex-wrap: wrap;
}
.summary-card {
flex: 1;
min-width: 160px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 0.5rem;
padding: 1rem 1.25rem;
}
.summary-card .card-number {
font-size: 2rem;
font-weight: 700;
color: var(--text-primary);
font-family: var(--font-mono);
line-height: 1.1;
}
.summary-card .card-label {
font-size: 0.8rem;
color: var(--text-muted);
margin-top: 0.25rem;
}
/* Chart container */
.chart-container {
position: relative;
width: 100%;
height: 280px;
margin: 1rem 0;
}
/* Period selector button group */
.period-selector {
display: flex;
gap: 0.25rem;
margin-bottom: 1rem;
}
.period-btn {
padding: 0.3rem 0.75rem;
border: 1px solid var(--border);
border-radius: 0.25rem;
background: var(--bg-secondary);
color: var(--text-secondary);
font-size: 0.85rem;
text-decoration: none;
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.period-btn:hover, .period-btn.active {
background: var(--accent);
color: var(--bg-primary);
border-color: var(--accent);
}
/* Accuracy dot */
.accuracy-dot {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 50%;
margin-left: 0.25rem;
}
.accuracy-ok { background: var(--green); }
.accuracy-warn { background: var(--yellow); }
.accuracy-danger { background: var(--red); }
/* Memory color classes */
.mem-ok { color: var(--green); }
.mem-warn { color: var(--yellow); }
.mem-danger { color: var(--red); }
/* Data table (apps page) */
.data-table {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
}
.data-table th {
text-align: left;
padding: 0.6rem 0.75rem;
font-weight: 600;
color: var(--text-secondary);
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.04em;
border-bottom: 1px solid var(--border);
white-space: nowrap;
}
.data-table td {
padding: 0.6rem 0.75rem;
border-bottom: 1px solid rgba(100,116,139,0.15);
color: var(--text-primary);
font-family: var(--font-mono);
font-size: 0.875rem;
}
.data-table td a {
color: var(--accent);
text-decoration: none;
font-family: var(--font-sans, sans-serif);
}
.data-table td a:hover { text-decoration: underline; }
.data-table th a {
color: var(--text-secondary);
text-decoration: none;
}
.data-table th a:hover { color: var(--text-primary); }
.data-table tr:hover td { background: rgba(96,165,250,0.04); }
/* Responsive */
@media (max-width: 768px) {
.container { padding: 1rem; }