diff --git a/hub/CHANGELOG.md b/hub/CHANGELOG.md index 4a24f03..4c3ad59 100644 --- a/hub/CHANGELOG.md +++ b/hub/CHANGELOG.md @@ -1,5 +1,20 @@ # Felhom Hub — Changelog +## v0.5.0 (2026-02-25) + +### Added +- **Configuration page** (`GET /configuration`) — New "Configuration" tab in the web UI with asset management controls. Displays asset file count, manifest generation timestamp, and a "Refresh Assets from Image" button. +- **Manual asset re-seed** (`POST /configuration`, action=`refresh_assets`) — Re-reads the baked-in seed directory, compares SHA-256 checksums with PVC assets, and updates changed files. Rebuilds the manifest afterward. Controllers pick up changes on their next daily sync. +- **`ReSeed()` method** (`internal/assets/assets.go`) — Public method for triggering asset re-seed + manifest rebuild from the web UI. + +### Changed +- **Asset seeding: `seedIfEmpty()` → `seedOrUpdate()`** (`internal/assets/assets.go`) — On startup the Hub now compares SHA-256 checksums between the image seed directory and the PVC, updating any changed files instead of only seeding into an empty directory. This means redeploying the Hub image with updated assets automatically propagates them without PVC deletion. +- **`isAssetFile()` expanded** — Now also matches `*-favicon.svg` and `*-favicon.ico` patterns, allowing branding assets like `felhom-favicon.svg` in the manifest. +- **`RebuildManifest()` refactored** — Internal logic extracted to `rebuildManifestLocked()` for reuse by `ReSeed()`. +- **Web Server struct** — Added `assetsMgr` field and `SetAssetManager()` method. Wired in `main.go`. +- **All templates translated to English** — The "Alkalmazások" nav link and telemetry pages (apps.html, app_detail.html, customer_unified.html telemetry section) are now in English, consistent with the rest of the Hub UI. +- **Navigation updated** — All templates now show four tabs: Dashboard, Customers, Apps, Configuration. + ## v0.4.1 (2026-02-23) ### Added diff --git a/hub/README.md b/hub/README.md index c0914a0..d220ee8 100644 --- a/hub/README.md +++ b/hub/README.md @@ -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.4.0** +**Current version: v0.5.0** --- @@ -237,7 +237,9 @@ Retention: configurable (default 90 days), daily prune at 04:30 Budapest time. ### PVC Asset Storage -App assets (logos, screenshots) are stored on the PVC at `/assets/`. On first run (empty directory), assets are seeded from `/usr/share/felhom/assets-seed/` (baked into the Docker image during build). This means assets survive container rebuilds but fresh deploys get a full set from the image seed. +App assets (logos, screenshots, branding) are stored on the PVC at `/assets/`. On every startup, the Hub compares SHA-256 checksums between the image seed (`/usr/share/felhom/assets-seed/`) and the PVC, updating any changed files. This means redeploying the Hub image with updated assets automatically propagates changes without PVC deletion. + +A manual "Refresh Assets from Image" button is available on the **Configuration** page (`/configuration`) for triggering a re-seed + manifest rebuild on demand. ## Configuration @@ -308,7 +310,7 @@ The Dockerfile includes `COPY assets/ /usr/share/felhom/assets-seed/` which bake | **Report/event prune** | Daily 04:30 Budapest | Deletes reports and events older than retention period (default 90 days) | | **Registry version check** | Every 30min | Checks Gitea registry for new controller image tags | | **Template refresh** | Every 1h | Fetches latest `controller.yaml.example` from Gitea | -| **Asset seeding** | On startup | Seeds PVC assets from Docker image if `/assets/` is empty | +| **Asset seeding** | On startup | Compares SHA-256 checksums and updates changed assets from Docker image seed | ## Internal Packages diff --git a/hub/cmd/hub/main.go b/hub/cmd/hub/main.go index df1ed68..258594e 100644 --- a/hub/cmd/hub/main.go +++ b/hub/cmd/hub/main.go @@ -143,6 +143,7 @@ func main() { webServer := web.New(dataStore, cfg.Auth.PasswordHash, cfg.API.ReportAPIKey, Version, staleThreshold, logger) webServer.SetTemplateFetcher(templateFetcher) + webServer.SetAssetManager(assetsMgr) // Build HTTP mux mux := http.NewServeMux() diff --git a/hub/internal/assets/assets.go b/hub/internal/assets/assets.go index 420ae1e..7b28a12 100644 --- a/hub/internal/assets/assets.go +++ b/hub/internal/assets/assets.go @@ -39,7 +39,7 @@ type Manager struct { logger *log.Logger } -// New creates a Manager, seeds assets from the image if the PVC dir is empty, +// New creates a Manager, syncs assets from the image seed directory, // and builds the initial manifest. func New(assetsDir, seedDir string, logger *log.Logger) *Manager { m := &Manager{ @@ -52,7 +52,7 @@ func New(assetsDir, seedDir string, logger *log.Logger) *Manager { logger.Printf("[ERROR] Failed to create assets dir %s: %v", assetsDir, err) } - m.seedIfEmpty() + m.seedOrUpdate() if err := m.RebuildManifest(); err != nil { logger.Printf("[ERROR] Failed to build asset manifest: %v", err) @@ -61,42 +61,47 @@ func New(assetsDir, seedDir string, logger *log.Logger) *Manager { return m } -// seedIfEmpty copies assets from the seed directory to the PVC directory -// if the PVC directory has no asset files. -func (m *Manager) seedIfEmpty() { - entries, err := os.ReadDir(m.assetsDir) - if err != nil { - m.logger.Printf("[WARN] Cannot read assets dir: %v", err) - return - } - - // Check if there are any asset files (not just directories or manifest.json) - hasAssets := false - for _, e := range entries { - if !e.IsDir() && isAssetFile(e.Name()) { - hasAssets = true - break - } - } - - if hasAssets { - m.logger.Printf("[INFO] Assets directory has files, skipping seed") - return - } - - // Seed from image +// seedOrUpdate copies new or changed assets from the seed directory to the PVC directory. +// On first run (empty PVC) this seeds all files. On subsequent runs it updates only files +// whose SHA-256 checksums differ, ensuring redeployed images propagate asset changes. +func (m *Manager) seedOrUpdate() { seedEntries, err := os.ReadDir(m.seedDir) if err != nil { m.logger.Printf("[WARN] Cannot read seed dir %s: %v (no assets will be available until uploaded)", m.seedDir, err) return } - copied := 0 + // Build hash map of existing PVC assets for comparison + existingHashes := make(map[string]string) + pvcEntries, _ := os.ReadDir(m.assetsDir) + for _, e := range pvcEntries { + if e.IsDir() || !isAssetFile(e.Name()) { + continue + } + h, err := fileSHA256(filepath.Join(m.assetsDir, e.Name())) + if err == nil { + existingHashes[e.Name()] = h + } + } + + copied, skipped := 0, 0 for _, e := range seedEntries { if e.IsDir() || !isAssetFile(e.Name()) { continue } src := filepath.Join(m.seedDir, e.Name()) + + // Compare checksums — skip if unchanged + seedHash, err := fileSHA256(src) + if err != nil { + m.logger.Printf("[WARN] Cannot hash seed asset %s: %v", e.Name(), err) + continue + } + if existingHashes[e.Name()] == seedHash { + skipped++ + continue + } + dst := filepath.Join(m.assetsDir, e.Name()) if err := copyFile(src, dst); err != nil { m.logger.Printf("[WARN] Failed to seed asset %s: %v", e.Name(), err) @@ -105,11 +110,32 @@ func (m *Manager) seedIfEmpty() { copied++ } - m.logger.Printf("[INFO] Seeded %d assets from image to PVC", copied) + if copied > 0 { + m.logger.Printf("[INFO] Asset seed: %d updated, %d unchanged", copied, skipped) + } else { + m.logger.Printf("[INFO] Asset seed: all %d files up-to-date", skipped) + } +} + +// ReSeed re-runs the seed-or-update process and rebuilds the manifest. +// Used by the web UI "Refresh Assets" button. +func (m *Manager) ReSeed() error { + m.mu.Lock() + defer m.mu.Unlock() + + m.seedOrUpdate() + return m.rebuildManifestLocked() } // RebuildManifest rescans the assets directory and recomputes SHA-256 hashes. func (m *Manager) RebuildManifest() error { + m.mu.Lock() + defer m.mu.Unlock() + return m.rebuildManifestLocked() +} + +// rebuildManifestLocked does the actual manifest rebuild. Caller must hold m.mu. +func (m *Manager) rebuildManifestLocked() error { entries, err := os.ReadDir(m.assetsDir) if err != nil { return fmt.Errorf("reading assets dir: %w", err) @@ -147,9 +173,7 @@ func (m *Manager) RebuildManifest() error { Files: files, } - m.mu.Lock() m.manifest = manifest - m.mu.Unlock() m.logger.Printf("[INFO] Asset manifest built: %d files", len(files)) return nil @@ -201,7 +225,8 @@ func (m *Manager) FileCount() int { return len(m.manifest.Files) } -// isAssetFile returns true if the filename matches the app asset naming convention. +// isAssetFile returns true if the filename matches the asset naming convention. +// Matches: *-logo.svg, *-logo.png, *-screenshot-*.webp, felhom-logo.svg, felhom-favicon.svg func isAssetFile(name string) bool { if strings.Contains(name, "-logo.") { return strings.HasSuffix(name, ".svg") || strings.HasSuffix(name, ".png") @@ -209,6 +234,9 @@ func isAssetFile(name string) bool { if strings.Contains(name, "-screenshot-") { return strings.HasSuffix(name, ".webp") } + if strings.Contains(name, "-favicon.") { + return strings.HasSuffix(name, ".svg") || strings.HasSuffix(name, ".ico") + } return false } diff --git a/hub/internal/web/server.go b/hub/internal/web/server.go index faed699..3c448c6 100644 --- a/hub/internal/web/server.go +++ b/hub/internal/web/server.go @@ -17,6 +17,7 @@ import ( "sync" "time" + "gitea.dooplex.hu/admin/felhom-hub/internal/assets" "gitea.dooplex.hu/admin/felhom-hub/internal/store" "golang.org/x/crypto/bcrypt" ) @@ -38,6 +39,7 @@ type Server struct { staleThreshold time.Duration versionChecker *VersionChecker templateFetcher *TemplateFetcher + assetsMgr *assets.Manager sessions map[string]*hubSession sessionsMu sync.RWMutex @@ -113,6 +115,11 @@ func (s *Server) SetTemplateFetcher(tf *TemplateFetcher) { s.templateFetcher = tf } +// SetAssetManager sets the asset manager for the Configuration page (optional). +func (s *Server) SetAssetManager(am *assets.Manager) { + s.assetsMgr = am +} + // ServeHTTP routes web requests. func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { path := r.URL.Path @@ -138,6 +145,12 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/javascript") w.Header().Set("Cache-Control", "public, max-age=86400") w.Write(chartJS) + case path == "/configuration": + if r.Method == http.MethodPost { + s.handleConfigurationAction(w, r) + } else { + s.handleConfiguration(w, r) + } case path == "/apps" || path == "/apps/": s.handleApps(w, r) case strings.HasPrefix(path, "/apps/") && strings.HasSuffix(path, "/reset-telemetry"): @@ -606,3 +619,48 @@ func statusColor(status string) string { func statusIcon(status string) string { return "●" } + +// handleConfiguration renders the Configuration page. +func (s *Server) handleConfiguration(w http.ResponseWriter, r *http.Request) { + csrfToken := s.getCSRFToken(r) + + assetCount := 0 + assetLastSync := "" + if s.assetsMgr != nil { + assetCount = s.assetsMgr.FileCount() + if m := s.assetsMgr.GetManifest(); m != nil { + assetLastSync = m.Generated + } + } + + data := map[string]interface{}{ + "CSRFToken": csrfToken, + "AssetCount": assetCount, + "AssetLastSync": assetLastSync, + "Flash": r.URL.Query().Get("flash"), + } + if err := s.templates.ExecuteTemplate(w, "configuration.html", data); err != nil { + s.logger.Printf("[ERROR] configuration.html template: %v", err) + } +} + +// handleConfigurationAction handles POST actions on the Configuration page. +func (s *Server) handleConfigurationAction(w http.ResponseWriter, r *http.Request) { + action := r.FormValue("action") + switch action { + case "refresh_assets": + if s.assetsMgr == nil { + http.Redirect(w, r, "/configuration?flash=assets_not_configured", http.StatusSeeOther) + return + } + if err := s.assetsMgr.ReSeed(); err != nil { + s.logger.Printf("[ERROR] Asset re-seed failed: %v", err) + http.Redirect(w, r, "/configuration?flash=assets_error", http.StatusSeeOther) + return + } + s.logger.Printf("[INFO] Manual asset re-seed completed") + http.Redirect(w, r, "/configuration?flash=assets_refreshed", http.StatusSeeOther) + default: + http.Redirect(w, r, "/configuration", http.StatusSeeOther) + } +} diff --git a/hub/internal/web/templates/app_detail.html b/hub/internal/web/templates/app_detail.html index 9bf3c25..88d6064 100644 --- a/hub/internal/web/templates/app_detail.html +++ b/hub/internal/web/templates/app_detail.html @@ -1,5 +1,5 @@ - + @@ -14,21 +14,22 @@ - ← Alkalmazások + ← Apps
- 24 óra - 7 nap - 30 nap + 24h + 7d + 30d
{{if eq .Flash "telemetry_reset"}} -
Telemetria sikeresen törölve.
+
Telemetry data deleted successfully.
{{end}} @@ -36,45 +37,45 @@

{{if .Summary}}{{if .Summary.DisplayName}}{{.Summary.DisplayName}}{{else}}{{.AppName}}{{end}}{{else}}{{.AppName}}{{end}}

+ onsubmit="return confirm('Are you sure you want to delete all telemetry data for {{.AppName}}? This cannot be undone.');"> - +
- App neve + App Name {{.AppName}}
{{if .Summary}}
- Telepítések + Deployments {{.Summary.DeploymentCount}}
- Katalógus becslés + Catalog Estimate {{if .Summary.CatalogEstimate}}{{.Summary.CatalogEstimate}}{{else}}—{{end}}
- Katalógus limit + Catalog Limit {{if .Summary.CatalogLimit}}{{.Summary.CatalogLimit}}{{else}}—{{end}}
{{if .SuggestedLimit}}
- Javasolt limit (P95×1.2) + Suggested Limit (P95×1.2) {{.SuggestedLimit}} MB
{{end}}
- Átl. memória + Avg Memory {{formatFloat .Summary.AvgMemoryMB}} MB
- P95 memória + P95 Memory {{formatFloat .Summary.P95MemoryMB}} MB
- Átl. CPU + Avg CPU {{formatFloat .Summary.AvgCPU}}%
{{end}} @@ -83,7 +84,7 @@
-

Memória trend

+

Memory Trend

@@ -91,13 +92,13 @@ (function() { var chartData = {{json .ChartData}}; if (!chartData || !chartData.labels || chartData.labels.length === 0) { - document.getElementById('memoryChart').parentElement.innerHTML = '

Nincs elegendő adat a grafikonhoz.

'; + document.getElementById('memoryChart').parentElement.innerHTML = '

Not enough data for the chart.

'; return; } var ctx = document.getElementById('memoryChart').getContext('2d'); var datasets = [ { - label: 'Átl. memória (MB)', + label: 'Avg Memory (MB)', data: chartData.avg_memory, borderColor: '#60a5fa', backgroundColor: 'rgba(96,165,250,0.1)', @@ -106,7 +107,7 @@ pointRadius: 2 }, { - label: 'Csúcs memória (MB)', + label: 'Peak Memory (MB)', data: chartData.peak_memory, borderColor: '#f87171', backgroundColor: 'transparent', @@ -118,7 +119,7 @@ ]; if (chartData.catalog_limit > 0) { datasets.push({ - label: 'Katalógus limit', + label: 'Catalog Limit', data: chartData.labels.map(function() { return chartData.catalog_limit; }), borderColor: '#4ade80', backgroundColor: 'transparent', @@ -150,16 +151,16 @@ {{if .Customers}}
-

Ügyfél bontás

+

Customer Breakdown

- - - - - - + + + + + + @@ -181,16 +182,16 @@ {{if .Issues}}
-

Ismert hibák

+

Known Issues

ÜgyfélÁtl. memóriaCsúcs memóriaÁtl. CPUHibák összesenUtolsó riportCustomerAvg MemoryPeak MemoryAvg CPUTotal ErrorsLast Report
- - - - - - + + + + + + diff --git a/hub/internal/web/templates/apps.html b/hub/internal/web/templates/apps.html index e6a4acc..d3a2aac 100644 --- a/hub/internal/web/templates/apps.html +++ b/hub/internal/web/templates/apps.html @@ -1,9 +1,9 @@ - + - Alkalmazások — Felhom Hub + Apps — Felhom Hub @@ -13,32 +13,33 @@ -

Alkalmazás telemetria

+

App Telemetry

- 24 óra - 7 nap - 30 nap + 24h + 7d + 30d
{{.TotalApps}}
-
Alkalmazás összesen
+
Total Apps
{{.TotalDeployments}}
-
Telepítések száma
+
Deployments
{{.AppsWithErrors}}
-
Hibás alkalmazások
+
Apps with Errors
@@ -48,15 +49,15 @@
SúlyosságÜzenetElőfordulásokÉrintett ügyfelekElső észlelésUtolsó észlelésSeverityMessageOccurrencesAffected CustomersFirst SeenLast Seen
- - - - - - - - - + + + + + + + + + @@ -70,9 +71,9 @@ @@ -84,8 +85,8 @@ {{else}}
-

Nincs telemetria adat a kiválasztott időszakra.

-

Az alkalmazás telemetria a következő riport beérkezése után jelenik meg (v0.28.0+ vezérlő szükséges).

+

No telemetry data for the selected period.

+

App telemetry will appear after the next report is received (requires controller v0.28.0+).

{{end}} diff --git a/hub/internal/web/templates/config_detail.html b/hub/internal/web/templates/config_detail.html index 2a28bbb..4f09e1c 100644 --- a/hub/internal/web/templates/config_detail.html +++ b/hub/internal/web/templates/config_detail.html @@ -13,7 +13,8 @@ diff --git a/hub/internal/web/templates/config_form.html b/hub/internal/web/templates/config_form.html index 3bca79a..71429b6 100644 --- a/hub/internal/web/templates/config_form.html +++ b/hub/internal/web/templates/config_form.html @@ -13,7 +13,8 @@ diff --git a/hub/internal/web/templates/configs.html b/hub/internal/web/templates/configs.html index 854f969..95fd3b7 100644 --- a/hub/internal/web/templates/configs.html +++ b/hub/internal/web/templates/configs.html @@ -13,7 +13,8 @@ diff --git a/hub/internal/web/templates/configuration.html b/hub/internal/web/templates/configuration.html new file mode 100644 index 0000000..3bdb46b --- /dev/null +++ b/hub/internal/web/templates/configuration.html @@ -0,0 +1,66 @@ + + + + + + Configuration — Felhom Hub + + + +
+
+

Felhom Hub

+ +
+ +

Configuration

+ + {{if eq .Flash "assets_refreshed"}} +
Assets refreshed successfully from image seed.
+ {{end}} + {{if eq .Flash "assets_error"}} +
Asset refresh failed. Check server logs for details.
+ {{end}} + {{if eq .Flash "assets_not_configured"}} +
Asset manager is not configured.
+ {{end}} + + +
+

Assets

+

+ App logos and screenshots served to controllers. Assets are seeded from the Docker image + and synced to controllers daily via the asset manifest API. +

+
+
+ Files in manifest + {{.AssetCount}} +
+
+ Manifest generated + {{if .AssetLastSync}}{{.AssetLastSync}}{{else}}—{{end}} +
+
+
+ + + + +

+ Re-reads the baked-in asset seed directory and updates changed files. + Controllers will pick up changes on their next daily sync or manual trigger. +

+
+ +
+ Felhom Hub v{{hubVersion}} +
+
+ + diff --git a/hub/internal/web/templates/customer.html b/hub/internal/web/templates/customer.html index bd47a13..72ca01c 100644 --- a/hub/internal/web/templates/customer.html +++ b/hub/internal/web/templates/customer.html @@ -13,7 +13,8 @@ ← Back to Dashboard

diff --git a/hub/internal/web/templates/customer_unified.html b/hub/internal/web/templates/customer_unified.html index 914f329..a2e77c5 100644 --- a/hub/internal/web/templates/customer_unified.html +++ b/hub/internal/web/templates/customer_unified.html @@ -15,7 +15,8 @@ ← All Customers

@@ -471,20 +472,20 @@ {{end}} - + {{if .HasAppTelemetry}}
-

Alkalmazás telemetria (utolsó 7 nap)

+

App Telemetry (last 7 days)

AlkalmazásTelepítésekÁtl. memóriaP95 memóriaKatalógus becslésKatalógus limitPontosságHibákFigyelmeztetésekAppDeploymentsAvg MemoryP95 MemoryCatalog EstimateCatalog LimitAccuracyErrorsWarnings
{{if .CatalogLimit}}{{.CatalogLimit}}{{else}}—{{end}} {{$ac := accuracyClass .P95MemoryMB .CatalogLimit}} - {{if eq $ac "ok"}} - {{else if eq $ac "warn"}} - {{else if eq $ac "danger"}} + {{if eq $ac "ok"}} + {{else if eq $ac "warn"}} + {{else if eq $ac "danger"}} {{else}}—{{end}} {{if gt .TotalErrors 0}}{{.TotalErrors}}{{else}}0{{end}}
- - - - - - - + + + + + + + diff --git a/hub/internal/web/templates/dashboard.html b/hub/internal/web/templates/dashboard.html index 876d4f0..e2efbfd 100644 --- a/hub/internal/web/templates/dashboard.html +++ b/hub/internal/web/templates/dashboard.html @@ -14,7 +14,8 @@ diff --git a/website/assets/felhom-logo.svg b/website/assets/felhom-logo.svg new file mode 100644 index 0000000..9286288 --- /dev/null +++ b/website/assets/felhom-logo.svg @@ -0,0 +1,255 @@ + +felhomeu
AlkalmazásMemória (jelenlegi)Memória (átlag 7d)Memória (csúcs 7d)Katalógus limitHibákFigyelmeztetésekAppMemory (current)Memory (avg 7d)Memory (peak 7d)Catalog LimitErrorsWarnings