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:
@@ -1,5 +1,26 @@
|
||||
# Felhom Hub — Changelog
|
||||
|
||||
## v0.4.0 (2026-02-23)
|
||||
|
||||
**App Telemetry & Analytics Dashboard**
|
||||
|
||||
### Added
|
||||
- **`app_telemetry` and `app_log_issues` SQLite tables** (`store/store.go`) — store per-app resource metrics and deduplicated log issues reported by v0.28.0+ controllers.
|
||||
- **`internal/store/telemetry.go`** — New store methods: `SaveAppTelemetry`, `GetFleetAppSummary` (with P95 memory calculation), `GetAppTelemetryHistory`, `GetAppCustomerBreakdown`, `GetCustomerAppSummary`, `GetAppIssues`, `GetRecentIssuesAllApps`, `PruneAppTelemetry`, `PruneStaleIssues`. New types: `AppTelemetryRecord`, `FleetAppSummary`, `AppTelemetryPoint`, `AppCustomerStats`, `CustomerAppSummary`, `AppIssue`.
|
||||
- **`/api/v1/report` handler update** (`api/handler.go`) — After saving the standard report, parses the optional `app_telemetry` JSON field and persists it. Backward-compatible: old controllers (no `app_telemetry` key) are unaffected.
|
||||
- **Fleet app list page** (`GET /apps`) — Hungarian-language dashboard showing all deployed apps fleet-wide with deployment count, avg/P95 memory, catalog estimate/limit accuracy, error/warning badges. Sortable columns, 24h/7d/30d period selector.
|
||||
- **Per-app detail page** (`GET /apps/{name}`) — Memory trend Chart.js chart (avg + peak, with catalog limit line), per-customer breakdown table, known log issues table (severity, message, occurrence count, affected customers). Includes suggested mem_limit from P95×1.2 rounded to 32M.
|
||||
- **Customer detail page telemetry section** (`customer_unified.html`) — New "Alkalmazás telemetria" card with per-app memory (current/avg/peak) and log error/warning counts linking to /apps/{name}.
|
||||
- **Chart.js** (`static/chart.min.js`) — Embedded from controller build, served at `/static/chart.min.js`.
|
||||
- **"Alkalmazások" nav link** — Added to header navigation across all templates.
|
||||
- **New CSS** (`style.css`) — `.badge`, `.badge-error`, `.badge-warn`, `.summary-cards`, `.summary-card`, `.chart-container`, `.period-selector`, `.period-btn`, `.accuracy-dot`, `.mem-ok/warn/danger`, `.data-table` styles.
|
||||
- **Telemetry pruning** (`cmd/hub/main.go`) — `pruneAll()` now also prunes app_telemetry rows older than 90 days and stale log issues not seen in 30 days.
|
||||
|
||||
### Changed
|
||||
- **`internal/web/apps.go`** (new file) — `handleApps`, `handleAppDetail`, `parsePeriod`, `sortFleetSummary`, `aggregateHistoryForChart`, `parseLimitMB`, `memoryColor`, `accuracyClass`, `getCSRFToken` helper functions.
|
||||
- **`internal/web/server.go`** — Added routes for `/apps`, `/apps/{name}`, `/static/chart.min.js`. Added `memoryColor`, `accuracyClass`, `gt` template functions.
|
||||
- **`internal/web/embed.go`** — Added `//go:embed static/chart.min.js` directive.
|
||||
|
||||
## v0.3.7 (2026-02-21)
|
||||
|
||||
**Asset management API**
|
||||
|
||||
+6
-2
@@ -4,7 +4,7 @@
|
||||
|
||||
A lightweight Go service that receives periodic reports and structured events from felhom-controller instances, stores them in SQLite, and provides a web dashboard for fleet monitoring. Also serves as the infrastructure backup store for disaster recovery, event-based dead man's switch monitoring, and notification dispatch.
|
||||
|
||||
**Current version: v0.3.8**
|
||||
**Current version: v0.4.0**
|
||||
|
||||
---
|
||||
|
||||
@@ -55,11 +55,13 @@ All API endpoints require `Authorization: Bearer <api_key>` (except `/healthz` a
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| `POST` | `/api/v1/report` | Controller pushes periodic status report |
|
||||
| `POST` | `/api/v1/report` | Controller pushes periodic status report (v0.28.0+ includes `app_telemetry` field) |
|
||||
| `GET` | `/api/v1/customers` | List all customers with latest report summary |
|
||||
| `GET` | `/api/v1/customers/{id}` | Get latest full report for a customer |
|
||||
| `GET` | `/api/v1/customers/{id}/history?period=7d` | Get report history |
|
||||
|
||||
The `POST /api/v1/report` handler (v0.4.0+) automatically parses the optional `app_telemetry` JSON array from the request body and stores it in `app_telemetry` / `app_log_issues` tables. Old controllers (no `app_telemetry` key) continue to work unchanged.
|
||||
|
||||
### Infrastructure Backup (Disaster Recovery)
|
||||
|
||||
| Method | Path | Description |
|
||||
@@ -180,6 +182,8 @@ Synchronizer-token CSRF protection on all browser POST/DELETE/PATCH operations:
|
||||
|
||||
- **Dashboard (`/`)** — Fleet overview table showing all customers with live status and event count badges (error+warning in last 24h). Config-only customers (no reports yet) appear as "PENDING" with gray badge. Blocked customers are hidden. Auto-refreshes every 60 seconds.
|
||||
- **Customers (`/configs`)** — Customer management list. Shows all customers (both managed and manual), their status, controller version, and config type (MANAGED/MANUAL). Blocked customers shown grayed-out with BLOCKED badge.
|
||||
- **Fleet App Analytics (`/apps`)** — Fleet-wide app telemetry overview (v0.4.0+). Shows all deployed apps across all customers with deployment count, avg/P95 memory, catalog estimate/limit accuracy indicators, and 24h error/warning badge counts. Sortable columns (deployments/memory/errors), 24h/7d/30d time period selector.
|
||||
- **App Detail (`/apps/{name}`)** — Per-app drill-down page with Chart.js memory trend (avg + peak lines, catalog limit dashed line), per-customer breakdown table, and known log issues table (severity, message, occurrence count, affected customers, first/last seen). Shows suggested mem_limit from P95×1.2 rounded to 32 MB.
|
||||
- **Unified Customer Detail (`/customers/{id}`)** — Single page per customer combining config management and live monitoring. Adapts content based on available data:
|
||||
- **Managed + reporting:** Full view — config info, system metrics, storage, containers, backup status, events timeline (last 50, severity filter), credentials, setup commands, YAML preview, controller update, notifications (with channel column), history
|
||||
- **Managed + no reports yet:** Config info, credentials, setup commands, "Waiting for first report" indicator
|
||||
|
||||
@@ -346,4 +346,14 @@ func pruneAll(s *store.Store, maxDays int, logger *log.Logger) {
|
||||
} else if deleted > 0 {
|
||||
logger.Printf("[INFO] Pruned %d old event rows", deleted)
|
||||
}
|
||||
if n, err := s.PruneAppTelemetry(time.Now().Add(-90 * 24 * time.Hour)); err != nil {
|
||||
logger.Printf("[ERROR] Prune app telemetry: %v", err)
|
||||
} else if n > 0 {
|
||||
logger.Printf("[INFO] Pruned %d old app telemetry rows", n)
|
||||
}
|
||||
if n, err := s.PruneStaleIssues(time.Now().Add(-30 * 24 * time.Hour)); err != nil {
|
||||
logger.Printf("[ERROR] Prune stale issues: %v", err)
|
||||
} else if n > 0 {
|
||||
logger.Printf("[INFO] Pruned %d stale app issues", n)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,6 +166,16 @@ func (h *Handler) handleReport(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Parse and save app telemetry (backward-compatible — old controllers won't have this field)
|
||||
var telemetryPayload struct {
|
||||
AppTelemetry []store.AppTelemetryRecord `json:"app_telemetry"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &telemetryPayload); err == nil && len(telemetryPayload.AppTelemetry) > 0 {
|
||||
if err := h.store.SaveAppTelemetry(payload.CustomerID, time.Now(), telemetryPayload.AppTelemetry); err != nil {
|
||||
h.logger.Printf("[WARN] Failed to save app telemetry for %s: %v", payload.CustomerID, err)
|
||||
}
|
||||
}
|
||||
|
||||
h.logger.Printf("[INFO] Received report from %s (%d bytes)", payload.CustomerID, len(body))
|
||||
|
||||
// Build response with optional customer_blocked flag
|
||||
|
||||
@@ -148,6 +148,53 @@ func (s *Store) migrate() error {
|
||||
// v0.3.0: add channel column to notification_log (idempotent)
|
||||
s.db.Exec("ALTER TABLE notification_log ADD COLUMN channel TEXT NOT NULL DEFAULT 'customer'")
|
||||
|
||||
// v0.4.0: app telemetry tables
|
||||
_, err = s.db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS app_telemetry (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
customer_id TEXT NOT NULL,
|
||||
app_name TEXT NOT NULL,
|
||||
display_name TEXT NOT NULL DEFAULT '',
|
||||
reported_at DATETIME NOT NULL,
|
||||
memory_current_mb REAL DEFAULT 0,
|
||||
memory_avg_mb REAL DEFAULT 0,
|
||||
memory_peak_mb REAL DEFAULT 0,
|
||||
cpu_avg_percent REAL DEFAULT 0,
|
||||
catalog_estimate TEXT DEFAULT '',
|
||||
catalog_limit TEXT DEFAULT '',
|
||||
log_errors INTEGER DEFAULT 0,
|
||||
log_warnings INTEGER DEFAULT 0,
|
||||
containers_json TEXT DEFAULT '[]',
|
||||
issues_json TEXT DEFAULT '[]'
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_app_telemetry_lookup
|
||||
ON app_telemetry(app_name, reported_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_app_telemetry_customer
|
||||
ON app_telemetry(customer_id, app_name, reported_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_app_telemetry_prune
|
||||
ON app_telemetry(reported_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app_log_issues (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
app_name TEXT NOT NULL,
|
||||
fingerprint TEXT NOT NULL,
|
||||
severity TEXT NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
first_seen DATETIME NOT NULL,
|
||||
last_seen DATETIME NOT NULL,
|
||||
occurrence_count INTEGER DEFAULT 1,
|
||||
affected_customers TEXT DEFAULT '[]',
|
||||
UNIQUE(app_name, fingerprint)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_app_log_issues_app
|
||||
ON app_log_issues(app_name, last_seen DESC);
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,448 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
// AppTelemetryRecord holds per-app telemetry received from a controller report.
|
||||
type AppTelemetryRecord struct {
|
||||
AppName string `json:"app_name"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Containers []string `json:"containers"`
|
||||
MemoryCurrentMB float64 `json:"memory_current_mb"`
|
||||
MemoryAvgMB float64 `json:"memory_avg_mb"`
|
||||
MemoryPeakMB float64 `json:"memory_peak_mb"`
|
||||
CPUAvgPercent float64 `json:"cpu_avg_percent"`
|
||||
CatalogEstimate string `json:"catalog_estimate"`
|
||||
CatalogLimit string `json:"catalog_limit"`
|
||||
LogErrors int `json:"log_errors"`
|
||||
LogWarnings int `json:"log_warnings"`
|
||||
Issues []struct {
|
||||
Severity string `json:"severity"`
|
||||
Message string `json:"message"`
|
||||
Count int `json:"count"`
|
||||
LastSeen time.Time `json:"last_seen"`
|
||||
} `json:"issues,omitempty"`
|
||||
}
|
||||
|
||||
// FleetAppSummary holds fleet-wide aggregate stats for one app.
|
||||
type FleetAppSummary struct {
|
||||
AppName string
|
||||
DisplayName string
|
||||
DeploymentCount int
|
||||
AvgMemoryMB float64
|
||||
PeakMemoryMB float64
|
||||
P95MemoryMB float64
|
||||
AvgCPU float64
|
||||
TotalErrors int
|
||||
TotalWarnings int
|
||||
CatalogEstimate string
|
||||
CatalogLimit string
|
||||
}
|
||||
|
||||
// AppTelemetryPoint holds one time-series data point for chart rendering.
|
||||
type AppTelemetryPoint struct {
|
||||
ReportedAt time.Time
|
||||
CustomerID string
|
||||
MemoryAvgMB float64
|
||||
MemoryPeakMB float64
|
||||
CPUAvgPercent float64
|
||||
LogErrors int
|
||||
LogWarnings int
|
||||
}
|
||||
|
||||
// AppCustomerStats holds per-customer resource stats for an app.
|
||||
type AppCustomerStats struct {
|
||||
CustomerID string
|
||||
AvgMemoryMB float64
|
||||
PeakMemoryMB float64
|
||||
AvgCPU float64
|
||||
TotalErrors int
|
||||
LastReport time.Time
|
||||
}
|
||||
|
||||
// CustomerAppSummary holds per-app stats for a specific customer.
|
||||
type CustomerAppSummary struct {
|
||||
AppName string
|
||||
DisplayName string
|
||||
MemoryCurrentMB float64
|
||||
MemoryAvgMB float64
|
||||
MemoryPeakMB float64
|
||||
CatalogLimit string
|
||||
LogErrors int
|
||||
LogWarnings int
|
||||
}
|
||||
|
||||
// AppIssue holds a known log issue for an app across the fleet.
|
||||
type AppIssue struct {
|
||||
ID int
|
||||
AppName string
|
||||
Fingerprint string
|
||||
Severity string
|
||||
Message string
|
||||
FirstSeen time.Time
|
||||
LastSeen time.Time
|
||||
OccurrenceCount int
|
||||
AffectedCustomers []string
|
||||
}
|
||||
|
||||
// SaveAppTelemetry inserts telemetry records for a customer report into the database.
|
||||
func (s *Store) SaveAppTelemetry(customerID string, reportedAt time.Time, records []AppTelemetryRecord) error {
|
||||
if len(records) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
stmt, err := tx.Prepare(`
|
||||
INSERT INTO app_telemetry
|
||||
(customer_id, app_name, display_name, reported_at,
|
||||
memory_current_mb, memory_avg_mb, memory_peak_mb, cpu_avg_percent,
|
||||
catalog_estimate, catalog_limit, log_errors, log_warnings,
|
||||
containers_json, issues_json)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer stmt.Close()
|
||||
|
||||
for _, r := range records {
|
||||
containersJSON, _ := json.Marshal(r.Containers)
|
||||
issuesJSON, _ := json.Marshal(r.Issues)
|
||||
|
||||
if _, err := stmt.Exec(
|
||||
customerID, r.AppName, r.DisplayName, reportedAt,
|
||||
r.MemoryCurrentMB, r.MemoryAvgMB, r.MemoryPeakMB, r.CPUAvgPercent,
|
||||
r.CatalogEstimate, r.CatalogLimit, r.LogErrors, r.LogWarnings,
|
||||
string(containersJSON), string(issuesJSON),
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Upsert log issues
|
||||
for _, issue := range r.Issues {
|
||||
fp := fingerprintIssue(issue.Message)
|
||||
if err := upsertAppIssue(tx, r.AppName, fp, issue.Severity, issue.Message, customerID, issue.LastSeen); err != nil {
|
||||
s.logger.Printf("[WARN] upsertAppIssue %s/%s: %v", r.AppName, fp[:min(len(fp), 20)], err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// upsertAppIssue inserts or updates a known log issue record.
|
||||
func upsertAppIssue(tx *sql.Tx, appName, fingerprint, severity, message, customerID string, lastSeen time.Time) error {
|
||||
// Try insert first
|
||||
_, err := tx.Exec(`
|
||||
INSERT INTO app_log_issues (app_name, fingerprint, severity, message, first_seen, last_seen, occurrence_count, affected_customers)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 1, ?)
|
||||
ON CONFLICT(app_name, fingerprint) DO UPDATE SET
|
||||
last_seen = CASE WHEN excluded.last_seen > last_seen THEN excluded.last_seen ELSE last_seen END,
|
||||
occurrence_count = occurrence_count + 1`,
|
||||
appName, fingerprint, severity, message, lastSeen, lastSeen, `[]`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Update affected_customers JSON — read current, add if missing, write back
|
||||
var affectedJSON string
|
||||
err = tx.QueryRow(`SELECT affected_customers FROM app_log_issues WHERE app_name = ? AND fingerprint = ?`,
|
||||
appName, fingerprint).Scan(&affectedJSON)
|
||||
if err != nil {
|
||||
return nil // non-critical
|
||||
}
|
||||
|
||||
var customers []string
|
||||
json.Unmarshal([]byte(affectedJSON), &customers)
|
||||
found := false
|
||||
for _, c := range customers {
|
||||
if c == customerID {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
customers = append(customers, customerID)
|
||||
newJSON, _ := json.Marshal(customers)
|
||||
tx.Exec(`UPDATE app_log_issues SET affected_customers = ? WHERE app_name = ? AND fingerprint = ?`,
|
||||
string(newJSON), appName, fingerprint)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// fingerprintIssue creates a short fingerprint key from a message string.
|
||||
func fingerprintIssue(msg string) string {
|
||||
if len(msg) > 100 {
|
||||
msg = msg[:100]
|
||||
}
|
||||
return msg
|
||||
}
|
||||
|
||||
// min returns the smaller of two ints.
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// GetFleetAppSummary returns fleet-wide aggregate stats for all apps since the given time.
|
||||
func (s *Store) GetFleetAppSummary(since time.Time) ([]FleetAppSummary, error) {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT app_name,
|
||||
MAX(display_name) as display_name,
|
||||
COUNT(DISTINCT customer_id) as deployment_count,
|
||||
AVG(memory_avg_mb) as avg_memory_mb,
|
||||
MAX(memory_peak_mb) as peak_memory_mb,
|
||||
AVG(cpu_avg_percent) as avg_cpu,
|
||||
SUM(log_errors) as total_errors,
|
||||
SUM(log_warnings) as total_warnings,
|
||||
MAX(catalog_estimate) as catalog_estimate,
|
||||
MAX(catalog_limit) as catalog_limit
|
||||
FROM app_telemetry
|
||||
WHERE reported_at > ?
|
||||
GROUP BY app_name
|
||||
ORDER BY deployment_count DESC, avg_memory_mb DESC`, since)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var summaries []FleetAppSummary
|
||||
for rows.Next() {
|
||||
var fs FleetAppSummary
|
||||
if err := rows.Scan(
|
||||
&fs.AppName, &fs.DisplayName, &fs.DeploymentCount,
|
||||
&fs.AvgMemoryMB, &fs.PeakMemoryMB, &fs.AvgCPU,
|
||||
&fs.TotalErrors, &fs.TotalWarnings,
|
||||
&fs.CatalogEstimate, &fs.CatalogLimit,
|
||||
); err != nil {
|
||||
continue
|
||||
}
|
||||
summaries = append(summaries, fs)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Calculate P95 memory per app
|
||||
for i := range summaries {
|
||||
p95, err := s.calcP95Memory(summaries[i].AppName, since)
|
||||
if err == nil {
|
||||
summaries[i].P95MemoryMB = p95
|
||||
}
|
||||
}
|
||||
|
||||
return summaries, nil
|
||||
}
|
||||
|
||||
// calcP95Memory returns the approximate P95 memory_peak_mb for an app since the given time.
|
||||
func (s *Store) calcP95Memory(appName string, since time.Time) (float64, error) {
|
||||
// Get count first
|
||||
var count int
|
||||
err := s.db.QueryRow(`SELECT COUNT(*) FROM app_telemetry WHERE app_name = ? AND reported_at > ?`,
|
||||
appName, since).Scan(&count)
|
||||
if err != nil || count == 0 {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
offset := int(float64(count) * 0.95)
|
||||
if offset >= count {
|
||||
offset = count - 1
|
||||
}
|
||||
|
||||
var p95 float64
|
||||
err = s.db.QueryRow(`
|
||||
SELECT memory_peak_mb FROM app_telemetry
|
||||
WHERE app_name = ? AND reported_at > ?
|
||||
ORDER BY memory_peak_mb ASC
|
||||
LIMIT 1 OFFSET ?`, appName, since, offset).Scan(&p95)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return p95, nil
|
||||
}
|
||||
|
||||
// GetAppTelemetryHistory returns time-series telemetry for a specific app.
|
||||
func (s *Store) GetAppTelemetryHistory(appName string, since time.Time) ([]AppTelemetryPoint, error) {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT reported_at, customer_id, memory_avg_mb, memory_peak_mb, cpu_avg_percent, log_errors, log_warnings
|
||||
FROM app_telemetry
|
||||
WHERE app_name = ? AND reported_at > ?
|
||||
ORDER BY reported_at ASC`, appName, since)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var points []AppTelemetryPoint
|
||||
for rows.Next() {
|
||||
var p AppTelemetryPoint
|
||||
if err := rows.Scan(&p.ReportedAt, &p.CustomerID, &p.MemoryAvgMB, &p.MemoryPeakMB,
|
||||
&p.CPUAvgPercent, &p.LogErrors, &p.LogWarnings); err != nil {
|
||||
continue
|
||||
}
|
||||
points = append(points, p)
|
||||
}
|
||||
return points, rows.Err()
|
||||
}
|
||||
|
||||
// GetAppCustomerBreakdown returns per-customer resource stats for an app.
|
||||
func (s *Store) GetAppCustomerBreakdown(appName string, since time.Time) ([]AppCustomerStats, error) {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT customer_id, AVG(memory_avg_mb), MAX(memory_peak_mb), AVG(cpu_avg_percent),
|
||||
SUM(log_errors), MAX(reported_at)
|
||||
FROM app_telemetry
|
||||
WHERE app_name = ? AND reported_at > ?
|
||||
GROUP BY customer_id
|
||||
ORDER BY AVG(memory_avg_mb) DESC`, appName, since)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var stats []AppCustomerStats
|
||||
for rows.Next() {
|
||||
var cs AppCustomerStats
|
||||
if err := rows.Scan(&cs.CustomerID, &cs.AvgMemoryMB, &cs.PeakMemoryMB, &cs.AvgCPU,
|
||||
&cs.TotalErrors, &cs.LastReport); err != nil {
|
||||
continue
|
||||
}
|
||||
stats = append(stats, cs)
|
||||
}
|
||||
return stats, rows.Err()
|
||||
}
|
||||
|
||||
// GetCustomerAppSummary returns per-app telemetry summary for a specific customer.
|
||||
func (s *Store) GetCustomerAppSummary(customerID string, since time.Time) ([]CustomerAppSummary, error) {
|
||||
// 7-day averages/peaks
|
||||
rows, err := s.db.Query(`
|
||||
SELECT app_name, MAX(display_name), AVG(memory_avg_mb), MAX(memory_peak_mb),
|
||||
MAX(catalog_limit), SUM(log_errors), SUM(log_warnings)
|
||||
FROM app_telemetry
|
||||
WHERE customer_id = ? AND reported_at > ?
|
||||
GROUP BY app_name
|
||||
ORDER BY AVG(memory_avg_mb) DESC`, customerID, since)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
summaryMap := make(map[string]*CustomerAppSummary)
|
||||
var order []string
|
||||
for rows.Next() {
|
||||
var cs CustomerAppSummary
|
||||
if err := rows.Scan(&cs.AppName, &cs.DisplayName, &cs.MemoryAvgMB, &cs.MemoryPeakMB,
|
||||
&cs.CatalogLimit, &cs.LogErrors, &cs.LogWarnings); err != nil {
|
||||
continue
|
||||
}
|
||||
summaryMap[cs.AppName] = &cs
|
||||
order = append(order, cs.AppName)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Get current memory from most recent report per app
|
||||
currentRows, err := s.db.Query(`
|
||||
SELECT app_name, memory_current_mb
|
||||
FROM app_telemetry
|
||||
WHERE customer_id = ? AND reported_at = (
|
||||
SELECT MAX(reported_at) FROM app_telemetry at2
|
||||
WHERE at2.customer_id = ? AND at2.app_name = app_telemetry.app_name
|
||||
)`, customerID, customerID)
|
||||
if err == nil {
|
||||
defer currentRows.Close()
|
||||
for currentRows.Next() {
|
||||
var appName string
|
||||
var currentMB float64
|
||||
if err := currentRows.Scan(&appName, ¤tMB); err == nil {
|
||||
if s, ok := summaryMap[appName]; ok {
|
||||
s.MemoryCurrentMB = currentMB
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result := make([]CustomerAppSummary, 0, len(order))
|
||||
for _, name := range order {
|
||||
if s, ok := summaryMap[name]; ok {
|
||||
result = append(result, *s)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetAppIssues returns recent known issues for a specific app.
|
||||
func (s *Store) GetAppIssues(appName string, limit int) ([]AppIssue, error) {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT id, app_name, fingerprint, severity, message, first_seen, last_seen,
|
||||
occurrence_count, affected_customers
|
||||
FROM app_log_issues
|
||||
WHERE app_name = ?
|
||||
ORDER BY last_seen DESC
|
||||
LIMIT ?`, appName, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
return scanAppIssues(rows)
|
||||
}
|
||||
|
||||
// GetRecentIssuesAllApps returns the most recently seen issues across all apps.
|
||||
func (s *Store) GetRecentIssuesAllApps(limit int) ([]AppIssue, error) {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT id, app_name, fingerprint, severity, message, first_seen, last_seen,
|
||||
occurrence_count, affected_customers
|
||||
FROM app_log_issues
|
||||
ORDER BY last_seen DESC
|
||||
LIMIT ?`, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
return scanAppIssues(rows)
|
||||
}
|
||||
|
||||
func scanAppIssues(rows *sql.Rows) ([]AppIssue, error) {
|
||||
var issues []AppIssue
|
||||
for rows.Next() {
|
||||
var ai AppIssue
|
||||
var affectedJSON string
|
||||
if err := rows.Scan(&ai.ID, &ai.AppName, &ai.Fingerprint, &ai.Severity, &ai.Message,
|
||||
&ai.FirstSeen, &ai.LastSeen, &ai.OccurrenceCount, &affectedJSON); err != nil {
|
||||
continue
|
||||
}
|
||||
json.Unmarshal([]byte(affectedJSON), &ai.AffectedCustomers)
|
||||
issues = append(issues, ai)
|
||||
}
|
||||
return issues, rows.Err()
|
||||
}
|
||||
|
||||
// PruneAppTelemetry removes telemetry rows older than the given time.
|
||||
func (s *Store) PruneAppTelemetry(before time.Time) (int64, error) {
|
||||
res, err := s.db.Exec("DELETE FROM app_telemetry WHERE reported_at < ?", before)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.RowsAffected()
|
||||
}
|
||||
|
||||
// PruneStaleIssues removes issue records not seen since the given time.
|
||||
func (s *Store) PruneStaleIssues(notSeenSince time.Time) (int64, error) {
|
||||
res, err := s.db.Exec("DELETE FROM app_log_issues WHERE last_seen < ?", notSeenSince)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.RowsAffected()
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"):
|
||||
|
||||
Vendored
+20
File diff suppressed because one or more lines are too long
@@ -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">← 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>
|
||||
@@ -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 > 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>
|
||||
|
||||
|
||||
@@ -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" 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">← 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">← 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>
|
||||
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user