diff --git a/cmd/metrics/resources/base.html b/cmd/metrics/resources/base.html index 14e6b8bc..b63a9909 100644 --- a/cmd/metrics/resources/base.html +++ b/cmd/metrics/resources/base.html @@ -169,7 +169,31 @@ const description = <<.DESCRIPTION>> const metadata = <<.METADATA>> const system_summary = <<.SYSTEMSUMMARY>> - + // per-group (per-socket, per-CPU, or per-cgroup) mean metric values. These are + // empty when metrics were collected at system scope and system granularity. + const group_by_field = <<.GROUPBYFIELD>> + const groups = <<.GROUPS>> + const group_means = <<.GROUPMEANS>> + const hasGroups = groups.length > 0 && group_means.length > 0; + // labels for the per-group metrics tab, by the field the metrics are grouped by + const groupLabels = { + SKT: { tab: "Per-Socket Metrics", plural: "sockets" }, + CPU: { tab: "Per-CPU Metrics", plural: "CPUs" }, + CID: { tab: "Per-Cgroup Metrics", plural: "cgroups" }, + }[group_by_field] || { tab: "Per-Group Metrics", plural: "groups" }; + // Tab indexes. The per-group metrics tab is present only when there is per-group + // data to present, so the tabs that follow it shift accordingly. + const tabIndex = { + tmam: 0, + cpu: 1, + memory: 2, + power: 3, + allMetrics: 4, + groupMetrics: hasGroups ? 5 : -1, + systemSummary: hasGroups ? 6 : 5, + metadata: hasGroups ? 7 : 6, + }; + // Check for highlighted metrics whenever current_metrics changes React.useEffect(() => { // Look for any metrics that exceed their thresholds @@ -177,8 +201,59 @@ setHasHighlightedMetrics(hasHighlighted); }, [current_metrics]); + // the common style for informational tooltips + const infoTooltipProps = { + tooltip: { + sx: { + fontSize: '1.0rem', + padding: '8px 12px', + bgcolor: 'rgba(97, 97, 97, 0.92)', + color: '#ffffff', + fontWeight: 400, + boxShadow: '0px 2px 6px rgba(0, 0, 0, 0.2)' + } + } + }; + + // MetricName component - the metric's name, preceded by an info button carrying the + // metric's description and, for TMA metrics, indentation indicating the metric's level + const MetricName = ({ name, level }) => { + const indentationLevel = level > 1 ? level - 1 : 0; + return ( + + + + info + + + {indentationLevel > 0 && + + + subdirectory_arrow_right + + + } + {name} + + ); + }; + // MetricsTable component - const MetricsTable = ({ + const MetricsTable = ({ data, filterPrefix = null, showComparison = false, @@ -251,54 +326,7 @@ }} > - - - info - - - {(() => { - const level = row[7] ? Number(row[7]) : 1; - const indentationLevel = level > 1 ? level - 1 : 0; - - const indentation = indentationLevel > 0 ? - 0 ? '1px dotted #aaa' : 'none', - marginLeft: indentationLevel > 0 ? '4px' : '0', - }}> - {indentationLevel > 0 && - subdirectory_arrow_right - } - : null; - - return ( - - {indentation} - {row[0]} - - ); - })()} + ); }; + // GroupMeansTable component - one row per metric and one column per group, e.g., per + // cgroup, holding the mean value of the metric for that group over the collection period + const GroupMeansTable = () => { + // group values can be long, e.g., cgroup paths, so shorten them for the column + // headers. The full value is available in the header's tooltip. + const shortGroupName = (value) => { + const leaf = value.split("/").filter(Boolean).pop() || value; + return leaf.length > 28 ? leaf.slice(0, 25) + "..." : leaf; + }; + // keep the metric name visible while scrolling the group columns horizontally + const stickyCell = { + position: "sticky", + left: 0, + backgroundColor: "background.paper", + }; + return ( + + + + + Metric + {groups.map(([value, sampleCount]) => ( + + + + {shortGroupName(value)} + + + + ))} + + + + {group_means.map((row) => ( + + + + + {groups.map(([value], idx) => ( + + {row[idx + 2] === "" ? "—" : Number(row[idx + 2]).toFixed(4)} + + ))} + + ))} + +
+
+ ); + }; // Define consistent colors for TMA categories with child variations const tmaColors = { // Blue family for Frontend @@ -701,6 +790,7 @@ + {hasGroups && } @@ -708,7 +798,7 @@
@@ -790,7 +880,7 @@ @@ -847,7 +937,7 @@ @@ -891,7 +981,7 @@ @@ -935,7 +1025,7 @@ + {hasGroups && + + + {`Mean value of each metric for each of the ${groups.length} ${groupLabels.plural} monitored during the collection period. Hover over a column heading for its full name and the number of samples collected for it. A dash indicates that no valid samples were collected for the metric.`} + + + + } @@ -983,7 +1084,7 @@
diff --git a/cmd/metrics/summary.go b/cmd/metrics/summary.go index fafbd686..c99c51dc 100644 --- a/cmd/metrics/summary.go +++ b/cmd/metrics/summary.go @@ -383,18 +383,33 @@ func (mc MetricCollection) aggregate() (m *MetricGroup, err error) { return } -// getHTML - generate a string containing HTML representing the metrics -func (mg *MetricGroup) getHTML(metadata Metadata, metricDefinitions []MetricDefinition) (out string, err error) { - var htmlTemplateBytes []byte - if htmlTemplateBytes, err = resources.ReadFile("resources/base.html"); err != nil { - slog.Error("failed to read base.html template", slog.String("error", err.Error())) +// getHTML - generate a string containing HTML representing the metrics. The report's +// charts and All Metrics tab present the aggregate of all metric groups. When the +// collection holds more than one group, e.g., one per cgroup, the report additionally +// presents the mean value of each metric for each group. +func (mc MetricCollection) getHTML(metadata Metadata, metricDefinitions []MetricDefinition) (out string, err error) { + if len(mc) == 0 { + err = fmt.Errorf("no metrics to summarize") return } - templateVals, err := mg.loadHTMLTemplateValues(metadata, metricDefinitions) + aggregated, err := mc.aggregate() + if err != nil { + return + } + templateVals, err := aggregated.loadHTMLTemplateValues(metadata, metricDefinitions) if err != nil { slog.Error("failed to load template values", slog.String("error", err.Error())) return } + if err = mc.addGroupTemplateValues(templateVals, metricDefinitions); err != nil { + slog.Error("failed to load per-group template values", slog.String("error", err.Error())) + return + } + var htmlTemplateBytes []byte + if htmlTemplateBytes, err = resources.ReadFile("resources/base.html"); err != nil { + slog.Error("failed to read base.html template", slog.String("error", err.Error())) + return + } fg := texttemplate.Must(texttemplate.New("metricsSummaryTemplate").Delims("<<", ">>").Parse(string(htmlTemplateBytes))) buf := new(bytes.Buffer) if err = fg.Execute(buf, templateVals); err != nil { @@ -404,20 +419,85 @@ func (mg *MetricGroup) getHTML(metadata Metadata, metricDefinitions []MetricDefi return buf.String(), nil } -func (mc MetricCollection) getHTML(metadata Metadata, metricDefinitions []MetricDefinition) (out string, err error) { - if len(mc) == 0 { - err = fmt.Errorf("no metrics to summarize") - return +// addGroupTemplateValues adds the template values that describe the mean value of each +// metric for each group in the collection, i.e., for each socket, CPU, or cgroup. These +// drive the report's per-group metrics tab. +// +// When the collection holds a single group, i.e., system scope and system granularity, +// the values are empty and the report does not present the per-group tab. The values are +// always set, since an unset template value renders as nothing, which would produce +// invalid JavaScript in the report. +func (mc MetricCollection) addGroupTemplateValues(templateVals map[string]string, metricDefinitions []MetricDefinition) error { + // defaults, used when there is no per-group data to present + templateVals["GROUPBYFIELD"] = `""` + templateVals["GROUPS"] = "[]" + templateVals["GROUPMEANS"] = "[]" + if len(mc) < 2 { + return nil + } + // all groups must present the same metrics, in the same order, for the values to line + // up with the metric names in the report's table + metricNames := mc[0].names + for idx, mg := range mc[1:] { + if !slices.Equal(mg.names, metricNames) { + slog.Warn("metric groups have different metric names or order, omitting per-group metrics from report", + slog.Int("group", idx+1), slog.String("groupByValue", mg.groupByValue)) + return nil + } + } + // the group values, e.g., the cgroup IDs, along with the number of samples collected + // for each. Groups can come and go during collection, e.g., when the list of "hot" + // cgroups is refreshed, so the sample counts provide context for the mean values. + groups := make([][]string, 0, len(mc)) + allStats := make([]map[string]metricStats, 0, len(mc)) + for idx, mg := range mc { + if mg.groupByValue == "" { + slog.Warn("metric group has no group value, omitting per-group metrics from report", slog.Int("group", idx)) + return nil + } + groups = append(groups, []string{mg.groupByValue, fmt.Sprintf("%d", len(mg.rows))}) + stats, err := mg.getStats() + if err != nil { + return fmt.Errorf("failed to get stats for metric group %d: %w", idx, err) + } + allStats = append(allStats, stats) } - if len(mc) == 1 { - return mc[0].getHTML(metadata, metricDefinitions) + // one row per metric: the metric name, the metric level, then the mean value from + // each group, in the same order as the groups + groupMeans := make([][]string, 0, len(metricNames)) + for _, name := range metricNames { + level := 1 + if metricDef := findMetricDefinitionByName(name, metricDefinitions); metricDef != nil { + level = max(metricDef.Level, 1) + } + metricVals := []string{name, fmt.Sprintf("%d", level)} + for _, stats := range allStats { + mean := stats[name].mean + if math.IsNaN(mean) || math.IsInf(mean, 0) { + // no valid samples for this metric in this group + metricVals = append(metricVals, "") + } else { + metricVals = append(metricVals, fmt.Sprintf("%f", mean)) + } + } + groupMeans = append(groupMeans, metricVals) } - metrics, err := mc.aggregate() + groupByFieldBytes, err := json.Marshal(mc[0].groupByField) if err != nil { - return + return err } - out, err = metrics.getHTML(metadata, metricDefinitions) - return + groupsBytes, err := json.Marshal(groups) + if err != nil { + return err + } + groupMeansBytes, err := json.Marshal(groupMeans) + if err != nil { + return err + } + templateVals["GROUPBYFIELD"] = string(groupByFieldBytes) + templateVals["GROUPS"] = string(groupsBytes) + templateVals["GROUPMEANS"] = string(groupMeansBytes) + return nil } type tmaTip struct {