Files
cc-backend/web/web.go
T
moebiusbandandClaude Opus 5 d0d9d49012 feat(frontend): add display smoothing filter for metric plots
cc-lib's resamplers cannot smooth: validateFrequency bails out when the new
frequency is not coarser than the old one, and every algorithm produces an
output strictly shorter than its input. They consolidate for transport,
which is a separate job from filtering for display, and they only act at all
once a series exceeds the target point count - shorter jobs arrive raw.

Add a centered, NaN-aware moving average in the plot layer, applied after
whatever downsampling the backend performed. It preserves length and index
alignment, so the X array, the visible point count and the zoom-resampling
hook are unaffected. NaN and null samples are skipped, an all-NaN window
stays NaN so gaps keep rendering, and the window shrinks at the series edges
rather than introducing new gaps there.

The window is a user setting in data points, defaulting to 3, and is
orthogonal to the resample algorithm - it stacks on top of average
consolidation rather than replacing it. All series of a stats plot are
smoothed together, since smoothing only some would break the
min <= mid <= max invariant the plot bands rely on. Smoothing is display
only: reported statistics and job footprints come from JobMetric.Statistics
and are untouched. The uPlot cursor readout does show the smoothed value.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 12:18:14 +02:00

336 lines
12 KiB
Go

// Copyright (C) NHR@FAU, University Erlangen-Nuremberg.
// All rights reserved. This file is part of cc-backend.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
// Package web implements the HTML templating and web frontend configuration
package web
import (
"embed"
"encoding/json"
"html/template"
"io/fs"
"net/http"
"strings"
"github.com/ClusterCockpit/cc-backend/internal/config"
"github.com/ClusterCockpit/cc-backend/pkg/archive"
cclog "github.com/ClusterCockpit/cc-lib/v2/ccLogger"
"github.com/ClusterCockpit/cc-lib/v2/schema"
"github.com/ClusterCockpit/cc-lib/v2/util"
)
type WebConfig struct {
JobList JobListConfig `json:"job-list"`
NodeList NodeListConfig `json:"node-list"`
JobView JobViewConfig `json:"job-view"`
MetricConfig MetricConfig `json:"metric-config"`
PlotConfiguration PlotConfiguration `json:"plot-configuration"`
}
type JobListConfig struct {
UsePaging bool `json:"use-paging"`
ShowFootprint bool `json:"show-footprint"`
}
type NodeListConfig struct {
UsePaging bool `json:"use-paging"`
}
type JobViewConfig struct {
ShowPolarPlot bool `json:"show-polar-plot"`
ShowFootprint bool `json:"show-footprint"`
ShowRoofline bool `json:"show-roofline"`
ShowStatTable bool `json:"show-stat-table"`
}
type MetricConfig struct {
JobListMetrics []string `json:"job-list-metrics"`
JobViewPlotMetrics []string `json:"job-view-plot-metrics"`
JobViewTableMetrics []string `json:"job-view-table-metrics"`
Clusters []ClusterConfig `json:"clusters"`
}
type ClusterConfig struct {
Name string `json:"name"`
JobListMetrics []string `json:"job-list-metrics"`
JobViewPlotMetrics []string `json:"job-view-plot-metrics"`
JobViewTableMetrics []string `json:"job-view-table-metrics"`
SubClusters []SubClusterConfig `json:"sub-clusters"`
}
type SubClusterConfig struct {
Name string `json:"name"`
JobListMetrics []string `json:"job-list-metrics"`
JobViewPlotMetrics []string `json:"job-view-plot-metrics"`
JobViewTableMetrics []string `json:"job-view-table-metrics"`
}
type PlotConfiguration struct {
ColorBackground bool `json:"color-background"`
PlotsPerRow int `json:"plots-per-row"`
LineWidth int `json:"line-width"`
ColorScheme []string `json:"color-scheme"`
ResampleAlgo string `json:"resample-algo"`
ResamplePolicy string `json:"resample-policy"`
SmoothingWindow int `json:"smoothing-window"`
}
const (
defaultImprintLink = "/imprint"
defaultPrivacyLink = "/privacy"
)
// FooterLink is the render-time representation of a single footer legal link.
type FooterLink struct {
URL string // Resolved target: internal path or external URL.
External bool // True if the target is an external URL (opened in a new tab).
}
// FooterLinks holds the resolved legal links shown in the site footer.
type FooterLinks struct {
Imprint FooterLink
Privacy FooterLink
}
// resolveFooterLink falls back to def when v is empty and flags external URLs.
func resolveFooterLink(v, def string) FooterLink {
if v == "" {
v = def
}
external := strings.HasPrefix(v, "http://") || strings.HasPrefix(v, "https://")
return FooterLink{URL: v, External: external}
}
var UIDefaults = WebConfig{
JobList: JobListConfig{
UsePaging: false,
ShowFootprint: false,
},
NodeList: NodeListConfig{
UsePaging: false,
},
JobView: JobViewConfig{
ShowPolarPlot: true,
ShowFootprint: false,
ShowRoofline: true,
ShowStatTable: true,
},
MetricConfig: MetricConfig{
JobListMetrics: []string{"cpu_load", "flops_any", "mem_bw", "mem_used"},
JobViewPlotMetrics: []string{"cpu_load", "flops_any", "mem_bw", "mem_used"},
JobViewTableMetrics: []string{"flops_any", "mem_bw", "mem_used"},
},
PlotConfiguration: PlotConfiguration{
ColorBackground: true,
PlotsPerRow: 3,
LineWidth: 3,
ColorScheme: []string{"#00bfff", "#0000ff", "#ff00ff", "#ff0000", "#ff8000", "#ffff00", "#80ff00"},
SmoothingWindow: 3,
},
}
var UIDefaultsMap map[string]any
//
// map[string]any{
// "analysis_view_histogramMetrics": []string{"flops_any", "mem_bw", "mem_used"},
// "analysis_view_scatterPlotMetrics": [][]string{{"flops_any", "mem_bw"}, {"flops_any", "cpu_load"}, {"cpu_load", "mem_bw"}},
// "job_view_nodestats_selectedMetrics": []string{"flops_any", "mem_bw", "mem_used"},
// "plot_list_jobsPerPage": 50,
// "analysis_view_selectedTopEntity": "user",
// "analysis_view_selectedTopCategory": "totalWalltime",
// "status_view_selectedTopUserCategory": "totalJobs",
// "status_view_selectedTopProjectCategory": "totalJobs",
// }
func Init(rawConfig json.RawMessage) error {
var err error
if rawConfig != nil {
config.Validate(configSchema, rawConfig)
if err = json.Unmarshal(rawConfig, &UIDefaults); err != nil {
cclog.Warn("Error while unmarshaling raw config json")
return err
}
}
UIDefaultsMap = make(map[string]any)
UIDefaultsMap["jobList_usePaging"] = UIDefaults.JobList.UsePaging
UIDefaultsMap["jobList_showFootprint"] = UIDefaults.JobList.ShowFootprint
UIDefaultsMap["nodeList_usePaging"] = UIDefaults.NodeList.UsePaging
UIDefaultsMap["jobView_showPolarPlot"] = UIDefaults.JobView.ShowPolarPlot
UIDefaultsMap["jobView_showFootprint"] = UIDefaults.JobView.ShowFootprint
UIDefaultsMap["jobView_showRoofline"] = UIDefaults.JobView.ShowRoofline
UIDefaultsMap["jobView_showStatTable"] = UIDefaults.JobView.ShowStatTable
UIDefaultsMap["metricConfig_jobListMetrics"] = UIDefaults.MetricConfig.JobListMetrics
UIDefaultsMap["metricConfig_jobViewPlotMetrics"] = UIDefaults.MetricConfig.JobViewPlotMetrics
UIDefaultsMap["metricConfig_jobViewTableMetrics"] = UIDefaults.MetricConfig.JobViewTableMetrics
UIDefaultsMap["plotConfiguration_colorBackground"] = UIDefaults.PlotConfiguration.ColorBackground
UIDefaultsMap["plotConfiguration_plotsPerRow"] = UIDefaults.PlotConfiguration.PlotsPerRow
UIDefaultsMap["plotConfiguration_lineWidth"] = UIDefaults.PlotConfiguration.LineWidth
UIDefaultsMap["plotConfiguration_colorScheme"] = UIDefaults.PlotConfiguration.ColorScheme
UIDefaultsMap["plotConfiguration_resampleAlgo"] = UIDefaults.PlotConfiguration.ResampleAlgo
UIDefaultsMap["plotConfiguration_resamplePolicy"] = UIDefaults.PlotConfiguration.ResamplePolicy
UIDefaultsMap["plotConfiguration_smoothingWindow"] = UIDefaults.PlotConfiguration.SmoothingWindow
for _, c := range UIDefaults.MetricConfig.Clusters {
if c.JobListMetrics != nil {
UIDefaultsMap["metricConfig_jobListMetrics:"+c.Name] = c.JobListMetrics
}
if c.JobViewPlotMetrics != nil {
UIDefaultsMap["metricConfig_jobViewPlotMetrics:"+c.Name] = c.JobViewPlotMetrics
}
if c.JobViewTableMetrics != nil {
UIDefaultsMap["metricConfig_jobViewTableMetrics:"+c.Name] = c.JobViewTableMetrics
}
for _, sc := range c.SubClusters {
suffix := strings.Join([]string{c.Name, sc.Name}, ":")
if sc.JobListMetrics != nil {
UIDefaultsMap["metricConfig_jobListMetrics:"+suffix] = sc.JobListMetrics
}
if sc.JobViewPlotMetrics != nil {
UIDefaultsMap["metricConfig_jobViewPlotMetrics:"+suffix] = sc.JobViewPlotMetrics
}
if sc.JobViewTableMetrics != nil {
UIDefaultsMap["metricConfig_jobViewTableMetrics:"+suffix] = sc.JobViewTableMetrics
}
}
}
return err
}
// / Go's embed is only allowed to embed files in a subdirectory of the embedding package ([see here](https://github.com/golang/go/issues/46056)).
//
//go:embed frontend/public/*
var frontendFiles embed.FS
func ServeFiles() http.Handler {
publicFiles, err := fs.Sub(frontendFiles, "frontend/public")
if err != nil {
cclog.Abortf("Serve Files: Could not find 'frontend/public' file directory.\nError: %s\n", err.Error())
}
return http.FileServer(http.FS(publicFiles))
}
// StaticFileExists checks whether a static file exists in the embedded frontend FS.
func StaticFileExists(path string) bool {
path = strings.TrimPrefix(path, "/")
if path == "" {
return false
}
_, err := fs.Stat(frontendFiles, "frontend/public/"+path)
return err == nil
}
//go:embed templates/*
var templateFiles embed.FS
var templates map[string]*template.Template = map[string]*template.Template{}
func init() {
base := template.Must(template.ParseFS(templateFiles, "templates/base.tmpl"))
if err := fs.WalkDir(templateFiles, "templates", func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() || path == "templates/base.tmpl" {
return nil
}
if path == "templates/404.tmpl" {
templates[strings.TrimPrefix(path, "templates/")] = template.Must(template.ParseFS(templateFiles, path))
return nil
}
if path == "templates/login.tmpl" {
if util.CheckFileExists("./var/login.tmpl") {
cclog.Info("overwrite login.tmpl with local file")
templates[strings.TrimPrefix(path, "templates/")] = template.Must(template.Must(base.Clone()).ParseFiles("./var/login.tmpl"))
return nil
}
}
if path == "templates/imprint.tmpl" {
if util.CheckFileExists("./var/imprint.tmpl") {
cclog.Info("overwrite imprint.tmpl with local file")
templates[strings.TrimPrefix(path, "templates/")] = template.Must(template.Must(base.Clone()).ParseFiles("./var/imprint.tmpl"))
return nil
}
}
if path == "templates/privacy.tmpl" {
if util.CheckFileExists("./var/privacy.tmpl") {
cclog.Info("overwrite privacy.tmpl with local file")
templates[strings.TrimPrefix(path, "templates/")] = template.Must(template.Must(base.Clone()).ParseFiles("./var/privacy.tmpl"))
return nil
}
}
templates[strings.TrimPrefix(path, "templates/")] = template.Must(template.Must(base.Clone()).ParseFS(templateFiles, path))
return nil
}); err != nil {
cclog.Abortf("Web init(): Could not find frontend template files.\nError: %s\n", err.Error())
}
_ = base
}
type Build struct {
Version string
Hash string
Buildtime string
}
type Page struct {
Title string // Page title
MsgType string // For generic use in message boxes
Message string // For generic use in message boxes
User schema.User // Information about the currently logged in user (Full User Info)
Roles map[string]schema.Role // Available roles for frontend render checks
Build Build // Latest information about the application
Clusters []string // List of all cluster names
SubClusters map[string][]string // Map per cluster of all subClusters for use in the Header
FilterPresets map[string]any // For pages with the Filter component, this can be used to set initial filters.
Infos map[string]any // For generic use (e.g. username for /monitoring/user/<id>, job id for /monitoring/job/<id>)
Config map[string]any // UI settings for the currently logged in user (e.g. line width, ...)
Resampling *config.ResampleConfig // If not nil, defines the target point count for zoom resampling
Redirect string // The originally requested URL, for intermediate login handling
FooterLinks FooterLinks // Resolved legal links for the site footer
}
func RenderTemplate(rw http.ResponseWriter, file string, page *Page) {
t, ok := templates[file]
if !ok {
cclog.Errorf("WEB/WEB > template '%s' not found", file)
}
if page.Clusters == nil {
page.Clusters = make([]string, 0)
}
if page.SubClusters == nil {
page.SubClusters = make(map[string][]string)
for _, cluster := range archive.Clusters {
page.Clusters = append(page.Clusters, cluster.Name)
for _, sc := range cluster.SubClusters {
page.SubClusters[cluster.Name] = append(page.SubClusters[cluster.Name], sc.Name)
}
}
}
page.FooterLinks = FooterLinks{
Imprint: resolveFooterLink(config.Keys.FooterLinks.Imprint, defaultImprintLink),
Privacy: resolveFooterLink(config.Keys.FooterLinks.Privacy, defaultPrivacyLink),
}
cclog.Debugf("Page config : %v\n", page.Config)
if err := t.Execute(rw, page); err != nil {
cclog.Errorf("Template error: %s", err.Error())
}
}