Merge pull request #15244 from prometheus/utf8-web-ui-support
Some checks are pending
buf.build / lint and publish (push) Waiting to run
CI / Go tests (push) Waiting to run
CI / More Go tests (push) Waiting to run
CI / Go tests with previous Go version (push) Waiting to run
CI / UI tests (push) Waiting to run
CI / Go tests on Windows (push) Waiting to run
CI / Mixins tests (push) Waiting to run
CI / Build Prometheus for common architectures (0) (push) Waiting to run
CI / Build Prometheus for common architectures (1) (push) Waiting to run
CI / Build Prometheus for common architectures (2) (push) Waiting to run
CI / Build Prometheus for all architectures (0) (push) Waiting to run
CI / Build Prometheus for all architectures (1) (push) Waiting to run
CI / Build Prometheus for all architectures (10) (push) Waiting to run
CI / Build Prometheus for all architectures (11) (push) Waiting to run
CI / Build Prometheus for all architectures (2) (push) Waiting to run
CI / Build Prometheus for all architectures (3) (push) Waiting to run
CI / Build Prometheus for all architectures (4) (push) Waiting to run
CI / Build Prometheus for all architectures (5) (push) Waiting to run
CI / Build Prometheus for all architectures (6) (push) Waiting to run
CI / Build Prometheus for all architectures (7) (push) Waiting to run
CI / Build Prometheus for all architectures (8) (push) Waiting to run
CI / Build Prometheus for all architectures (9) (push) Waiting to run
CI / Report status of build Prometheus for all architectures (push) Blocked by required conditions
CI / Check generated parser (push) Waiting to run
CI / golangci-lint (push) Waiting to run
CI / fuzzing (push) Waiting to run
CI / codeql (push) Waiting to run
CI / Publish main branch artifacts (push) Blocked by required conditions
CI / Publish release artefacts (push) Blocked by required conditions
CI / Publish UI on npm Registry (push) Blocked by required conditions
Scorecards supply-chain security / Scorecards analysis (push) Waiting to run

Support UTF-8 metric names and labels in web UI
This commit is contained in:
Julius Volz 2024-11-04 12:13:41 +01:00 committed by GitHub
commit 51866b9fee
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 242 additions and 41 deletions

View file

@ -2,6 +2,7 @@ import { Badge, BadgeVariant, Group, MantineColor, Stack } from "@mantine/core";
import { FC } from "react"; import { FC } from "react";
import { escapeString } from "../lib/escapeString"; import { escapeString } from "../lib/escapeString";
import badgeClasses from "../Badge.module.css"; import badgeClasses from "../Badge.module.css";
import { maybeQuoteLabelName } from "../promql/utils";
export interface LabelBadgesProps { export interface LabelBadgesProps {
labels: Record<string, string>; labels: Record<string, string>;
@ -30,7 +31,7 @@ export const LabelBadges: FC<LabelBadgesProps> = ({
}} }}
key={k} key={k}
> >
{k}="{escapeString(v)}" {maybeQuoteLabelName(k)}="{escapeString(v)}"
</Badge> </Badge>
); );
})} })}

View file

@ -1,12 +1,24 @@
import {
maybeQuoteLabelName,
metricContainsExtendedCharset,
} from "../promql/utils";
import { escapeString } from "./escapeString"; import { escapeString } from "./escapeString";
// TODO: Maybe replace this with the new PromLens-derived serialization code in src/promql/serialize.ts?
export const formatSeries = (labels: { [key: string]: string }): string => { export const formatSeries = (labels: { [key: string]: string }): string => {
if (labels === null) { if (labels === null) {
return "scalar"; return "scalar";
} }
if (metricContainsExtendedCharset(labels.__name__ || "")) {
return `{"${escapeString(labels.__name__)}",${Object.entries(labels)
.filter(([k]) => k !== "__name__")
.map(([k, v]) => `${maybeQuoteLabelName(k)}="${escapeString(v)}"`)
.join(", ")}}`;
}
return `${labels.__name__ || ""}{${Object.entries(labels) return `${labels.__name__ || ""}{${Object.entries(labels)
.filter(([k]) => k !== "__name__") .filter(([k]) => k !== "__name__")
.map(([k, v]) => `${k}="${escapeString(v)}"`) .map(([k, v]) => `${maybeQuoteLabelName(k)}="${escapeString(v)}"`)
.join(", ")}}`; .join(", ")}}`;
}; };

View file

@ -5,6 +5,10 @@ import classes from "./SeriesName.module.css";
import { escapeString } from "../../lib/escapeString"; import { escapeString } from "../../lib/escapeString";
import { useClipboard } from "@mantine/hooks"; import { useClipboard } from "@mantine/hooks";
import { notifications } from "@mantine/notifications"; import { notifications } from "@mantine/notifications";
import {
maybeQuoteLabelName,
metricContainsExtendedCharset,
} from "../../promql/utils";
interface SeriesNameProps { interface SeriesNameProps {
labels: { [key: string]: string } | null; labels: { [key: string]: string } | null;
@ -15,8 +19,26 @@ const SeriesName: FC<SeriesNameProps> = ({ labels, format }) => {
const clipboard = useClipboard(); const clipboard = useClipboard();
const renderFormatted = (): React.ReactElement => { const renderFormatted = (): React.ReactElement => {
const metricExtendedCharset =
labels && metricContainsExtendedCharset(labels.__name__ || "");
const labelNodes: React.ReactElement[] = []; const labelNodes: React.ReactElement[] = [];
let first = true; let first = true;
// If the metric name uses the extended new charset, we need to escape it,
// put it into the label matcher list, and make sure it's the first item.
if (metricExtendedCharset) {
labelNodes.push(
<span key="__name__">
<span className={classes.labelValue}>
"{escapeString(labels.__name__)}"
</span>
</span>
);
first = false;
}
for (const label in labels) { for (const label in labels) {
if (label === "__name__") { if (label === "__name__") {
continue; continue;
@ -37,7 +59,10 @@ const SeriesName: FC<SeriesNameProps> = ({ labels, format }) => {
}} }}
title="Click to copy label matcher" title="Click to copy label matcher"
> >
<span className={classes.labelName}>{label}</span>= <span className={classes.labelName}>
{maybeQuoteLabelName(label)}
</span>
=
<span className={classes.labelValue}> <span className={classes.labelValue}>
"{escapeString(labels[label])}" "{escapeString(labels[label])}"
</span> </span>
@ -52,9 +77,11 @@ const SeriesName: FC<SeriesNameProps> = ({ labels, format }) => {
return ( return (
<span> <span>
<span className={classes.metricName}> {!metricExtendedCharset && (
{labels ? labels.__name__ : ""} <span className={classes.metricName}>
</span> {labels ? labels.__name__ : ""}
</span>
)}
{"{"} {"{"}
{labelNodes} {labelNodes}
{"}"} {"}"}

View file

@ -8,14 +8,21 @@ import ASTNode, {
MatrixSelector, MatrixSelector,
} from "./ast"; } from "./ast";
import { formatPrometheusDuration } from "../lib/formatTime"; import { formatPrometheusDuration } from "../lib/formatTime";
import { maybeParenthesizeBinopChild, escapeString } from "./utils"; import {
maybeParenthesizeBinopChild,
escapeString,
maybeQuoteLabelName,
metricContainsExtendedCharset,
} from "./utils";
export const labelNameList = (labels: string[]): React.ReactNode[] => { export const labelNameList = (labels: string[]): React.ReactNode[] => {
return labels.map((l, i) => { return labels.map((l, i) => {
return ( return (
<span key={i}> <span key={i}>
{i !== 0 && ", "} {i !== 0 && ", "}
<span className="promql-code promql-label-name">{l}</span> <span className="promql-code promql-label-name">
{maybeQuoteLabelName(l)}
</span>
</span> </span>
); );
}); });
@ -69,27 +76,45 @@ const formatAtAndOffset = (
const formatSelector = ( const formatSelector = (
node: VectorSelector | MatrixSelector node: VectorSelector | MatrixSelector
): ReactElement => { ): ReactElement => {
const matchLabels = node.matchers const matchLabels: JSX.Element[] = [];
.filter(
(m) => // If the metric name contains the new extended charset, we need to escape it
!( // and add it at the beginning of the matchers list in the curly braces.
m.name === "__name__" && const metricName =
m.type === matchType.equal && node.name ||
m.value === node.name node.matchers.find(
) (m) => m.name === "__name__" && m.type === matchType.equal
) )?.value ||
.map((m, i) => ( "";
<span key={i}> const metricExtendedCharset = metricContainsExtendedCharset(metricName);
{i !== 0 && ","} if (metricExtendedCharset) {
<span className="promql-label-name">{m.name}</span> matchLabels.push(
{m.type} <span key="__name__">
<span className="promql-string">"{escapeString(m.value)}"</span> <span className="promql-string">"{escapeString(metricName)}"</span>
</span> </span>
)); );
}
matchLabels.push(
...node.matchers
.filter((m) => !(m.name === "__name__" && m.type === matchType.equal))
.map((m, i) => (
<span key={i}>
{(i !== 0 || metricExtendedCharset) && ","}
<span className="promql-label-name">
{maybeQuoteLabelName(m.name)}
</span>
{m.type}
<span className="promql-string">"{escapeString(m.value)}"</span>
</span>
))
);
return ( return (
<> <>
<span className="promql-metric-name">{node.name}</span> {!metricExtendedCharset && (
<span className="promql-metric-name">{metricName}</span>
)}
{matchLabels.length > 0 && ( {matchLabels.length > 0 && (
<> <>
{"{"} {"{"}

View file

@ -11,8 +11,14 @@ import {
aggregatorsWithParam, aggregatorsWithParam,
maybeParenthesizeBinopChild, maybeParenthesizeBinopChild,
escapeString, escapeString,
metricContainsExtendedCharset,
maybeQuoteLabelName,
} from "./utils"; } from "./utils";
const labelNameList = (labels: string[]): string => {
return labels.map((ln) => maybeQuoteLabelName(ln)).join(", ");
};
const serializeAtAndOffset = ( const serializeAtAndOffset = (
timestamp: number | null, timestamp: number | null,
startOrEnd: StartOrEnd, startOrEnd: StartOrEnd,
@ -28,15 +34,23 @@ const serializeAtAndOffset = (
const serializeSelector = (node: VectorSelector | MatrixSelector): string => { const serializeSelector = (node: VectorSelector | MatrixSelector): string => {
const matchers = node.matchers const matchers = node.matchers
.filter( .filter((m) => !(m.name === "__name__" && m.type === matchType.equal))
(m) => .map(
!( (m) => `${maybeQuoteLabelName(m.name)}${m.type}"${escapeString(m.value)}"`
m.name === "__name__" && );
m.type === matchType.equal &&
m.value === node.name // If the metric name contains the new extended charset, we need to escape it
) // and add it at the beginning of the matchers list in the curly braces.
) const metricName =
.map((m) => `${m.name}${m.type}"${escapeString(m.value)}"`); node.name ||
node.matchers.find(
(m) => m.name === "__name__" && m.type === matchType.equal
)?.value ||
"";
const metricExtendedCharset = metricContainsExtendedCharset(metricName);
if (metricExtendedCharset) {
matchers.unshift(`"${escapeString(metricName)}"`);
}
const range = const range =
node.type === nodeType.matrixSelector node.type === nodeType.matrixSelector
@ -48,7 +62,7 @@ const serializeSelector = (node: VectorSelector | MatrixSelector): string => {
node.offset node.offset
); );
return `${node.name}${matchers.length > 0 ? `{${matchers.join(",")}}` : ""}${range}${atAndOffset}`; return `${!metricExtendedCharset ? metricName : ""}${matchers.length > 0 ? `{${matchers.join(",")}}` : ""}${range}${atAndOffset}`;
}; };
const serializeNode = ( const serializeNode = (
@ -68,9 +82,9 @@ const serializeNode = (
case nodeType.aggregation: case nodeType.aggregation:
return `${initialInd}${node.op}${ return `${initialInd}${node.op}${
node.without node.without
? ` without(${node.grouping.join(", ")}) ` ? ` without(${labelNameList(node.grouping)}) `
: node.grouping.length > 0 : node.grouping.length > 0
? ` by(${node.grouping.join(", ")}) ` ? ` by(${labelNameList(node.grouping)}) `
: "" : ""
}(${childListSeparator}${ }(${childListSeparator}${
aggregatorsWithParam.includes(node.op) && node.param !== null aggregatorsWithParam.includes(node.op) && node.param !== null
@ -119,16 +133,16 @@ const serializeNode = (
const vm = node.matching; const vm = node.matching;
if (vm !== null && (vm.labels.length > 0 || vm.on)) { if (vm !== null && (vm.labels.length > 0 || vm.on)) {
if (vm.on) { if (vm.on) {
matching = ` on(${vm.labels.join(", ")})`; matching = ` on(${labelNameList(vm.labels)})`;
} else { } else {
matching = ` ignoring(${vm.labels.join(", ")})`; matching = ` ignoring(${labelNameList(vm.labels)})`;
} }
if ( if (
vm.card === vectorMatchCardinality.manyToOne || vm.card === vectorMatchCardinality.manyToOne ||
vm.card === vectorMatchCardinality.oneToMany vm.card === vectorMatchCardinality.oneToMany
) { ) {
grouping = ` group_${vm.card === vectorMatchCardinality.manyToOne ? "left" : "right"}(${vm.include.join(",")})`; grouping = ` group_${vm.card === vectorMatchCardinality.manyToOne ? "left" : "right"}(${labelNameList(vm.include)})`;
} }
} }

View file

@ -99,7 +99,7 @@ describe("serializeNode and formatNode", () => {
timestamp: null, timestamp: null,
startOrEnd: null, startOrEnd: null,
}, },
output: '{__name__="metric_name"} offset 1m', output: "metric_name offset 1m",
}, },
{ {
// Escaping in label values. // Escaping in label values.
@ -642,6 +642,113 @@ describe("serializeNode and formatNode", () => {
== bool on(label1, label2) group_right(label3) == bool on(label1, label2) group_right(label3)
`, `,
}, },
// Test new Prometheus 3.0 UTF-8 support.
{
node: {
bool: false,
lhs: {
bool: false,
lhs: {
expr: {
matchers: [
{
name: "__name__",
type: matchType.equal,
value: "metric_ä",
},
{
name: "foo",
type: matchType.equal,
value: "bar",
},
],
name: "",
offset: 0,
startOrEnd: null,
timestamp: null,
type: nodeType.vectorSelector,
},
grouping: ["a", "ä"],
op: aggregationType.sum,
param: null,
type: nodeType.aggregation,
without: false,
},
matching: {
card: vectorMatchCardinality.manyToOne,
include: ["c", "ü"],
labels: ["b", "ö"],
on: true,
},
op: binaryOperatorType.div,
rhs: {
expr: {
matchers: [
{
name: "__name__",
type: matchType.equal,
value: "metric_ö",
},
{
name: "bar",
type: matchType.equal,
value: "foo",
},
],
name: "",
offset: 0,
startOrEnd: null,
timestamp: null,
type: nodeType.vectorSelector,
},
grouping: ["d", "ä"],
op: aggregationType.sum,
param: null,
type: nodeType.aggregation,
without: true,
},
type: nodeType.binaryExpr,
},
matching: {
card: vectorMatchCardinality.oneToOne,
include: [],
labels: ["e", "ö"],
on: false,
},
op: binaryOperatorType.add,
rhs: {
expr: {
matchers: [
{
name: "__name__",
type: matchType.equal,
value: "metric_ü",
},
],
name: "",
offset: 0,
startOrEnd: null,
timestamp: null,
type: nodeType.vectorSelector,
},
type: nodeType.parenExpr,
},
type: nodeType.binaryExpr,
},
output:
'sum by(a, "ä") ({"metric_ä",foo="bar"}) / on(b, "ö") group_left(c, "ü") sum without(d, "ä") ({"metric_ö",bar="foo"}) + ignoring(e, "ö") ({"metric_ü"})',
prettyOutput: ` sum by(a, "ä") (
{"metric_ä",foo="bar"}
)
/ on(b, "ö") group_left(c, "ü")
sum without(d, "ä") (
{"metric_ö",bar="foo"}
)
+ ignoring(e, "ö")
(
{"metric_ü"}
)`,
},
]; ];
tests.forEach((t) => { tests.forEach((t) => {

View file

@ -267,6 +267,21 @@ export const humanizedValueType: Record<valueType, string> = {
[valueType.matrix]: "range vector", [valueType.matrix]: "range vector",
}; };
const metricNameRe = /^[a-zA-Z_:][a-zA-Z0-9_:]*$/;
const labelNameCharsetRe = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
export const metricContainsExtendedCharset = (str: string) => {
return !metricNameRe.test(str);
};
export const labelNameContainsExtendedCharset = (str: string) => {
return !labelNameCharsetRe.test(str);
};
export const escapeString = (str: string) => { export const escapeString = (str: string) => {
return str.replace(/([\\"])/g, "\\$1"); return str.replace(/([\\"])/g, "\\$1");
}; };
export const maybeQuoteLabelName = (str: string) => {
return labelNameContainsExtendedCharset(str) ? `"${escapeString(str)}"` : str;
};