MHSanaei/3x-ui · error

invalid metric

Error message

invalid metric

What it means

NodeController.history validates c.Param("metric") against the allowlist service.NodeMetricKeys; anything not in the list returns 'invalid metric' with detail 'unknown metric'. The endpoint then aggregates a node metric over time buckets. The allowlist exists both for correctness and to prevent arbitrary keys from reaching the aggregation query.

Solutions

  1. Use one of the exact NodeMetricKeys values (inspect service.NodeMetricKeys in your build, e.g. cpu, mem, netUp, netDown).
  2. After a panel upgrade, refresh the frontend so the metric tabs match the backend allowlist.
  3. For API consumers, fetch the key list once and drive the UI/script from it rather than hardcoding.

Example fix

# before
GET /panel/api/nodes/history/1/cpuPercent/60   # invalid metric

# after
GET /panel/api/nodes/history/1/cpu/60
Defensive patterns

Strategy: type-guard

Validate before calling

// derive allowed list from the API contract, not guesses
const NODE_METRIC_KEYS = ['cpu', 'mem', 'netUp', 'netDown'] as const
type NodeMetric = (typeof NODE_METRIC_KEYS)[number]
function isNodeMetric(m: string): m is NodeMetric {
  return (NODE_METRIC_KEYS as readonly string[]).includes(m)
}

Type guard

function isNodeMetric(m: string): m is NodeMetric {
  return (NODE_METRIC_KEYS as readonly string[]).includes(m)
}

Prevention

When it happens

Trigger: GET /panel/api/nodes/history/:id/:metric/:bucket with a metric segment not in NodeMetricKeys — e.g. 'cpuPercent' when the key is 'cpu', or a newly added metric the running binary predates.

Common situations: Frontend and backend versions drift (new metric tabs shipped before the backend); typo in a manual API call; a script guessing metric names; metric renamed in a release.

Related errors


AI-assisted analysis of MHSanaei/3x-ui@ad32144c42 (2026-08-15). Data as JSON: /api/errors/d54fdad4a79a825e. Report an issue: GitHub.

Appendix: source

Thrown at internal/web/controller/node.go:349

		return
	}
	if len(req.Ids) == 0 {
		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), fmt.Errorf("no nodes selected"))
		return
	}
	results, err := a.nodeService.UpdatePanels(req.Ids, req.Dev)
	jsonMsgObj(c, I18nWeb(c, "pages.nodes.toasts.updateStarted"), results, err)
}

func (a *NodeController) history(c *gin.Context) {
	id, err := strconv.Atoi(c.Param("id"))
	if err != nil {
		jsonMsg(c, I18nWeb(c, "get"), err)
		return
	}
	metric := c.Param("metric")
	if !slices.Contains(service.NodeMetricKeys, metric) {
		jsonMsg(c, "invalid metric", fmt.Errorf("unknown metric"))
		return
	}
	bucket, err := strconv.Atoi(c.Param("bucket"))
	if err != nil || bucket <= 0 || !service.IsAllowedHistoryBucket(bucket) {
		jsonMsg(c, "invalid bucket", fmt.Errorf("unsupported bucket"))
		return
	}
	jsonObj(c, a.nodeService.AggregateNodeMetric(id, metric, bucket, 60), nil)
}

View on GitHub (pinned to ad32144c42)