44.0 KiB · text History 35c3541
.pragma library

// Everything the widget needs to turn a Grafana dashboard into pictures,
// kept free of QML so it can be exercised from node against a real
// instance (see tools/check.mjs): time ranges, template variables, the
// /api/ds/query body, data frames → series, reducers, units, colours and
// thresholds. The panel components only draw what this hands them.

// ── time ──────────────────────────────────────────────────────────────

var UNIT_MS = { s: 1000, m: 60000, h: 3600000, d: 86400000, w: 604800000, M: 2592000000, y: 31536000000 }

// "now", "now-6h", "now-1d/d", an epoch in ms, or an ISO date → epoch ms
function parseTime(text, now, roundUp) {
    if (text === undefined || text === null || text === "") return now
    if (typeof text === "number") return text
    var s = String(text).trim()
    if (/^\d+$/.test(s)) return parseInt(s, 10)
    var m = /^now(?:([+-])(\d+)([smhdwMy]))?(?:\/([smhdwMy]))?$/.exec(s)
    if (!m) {
        var t = Date.parse(s)
        return isNaN(t) ? now : t
    }
    var t2 = now
    if (m[1]) t2 += (m[1] === "-" ? -1 : 1) * parseInt(m[2], 10) * UNIT_MS[m[3]]
    if (m[4]) t2 = roundTo(t2, m[4], roundUp)
    return t2
}

function roundTo(t, unit, up) {
    var d = new Date(t)
    switch (unit) {
    case "y": d.setMonth(0, 1); d.setHours(0, 0, 0, 0); if (up) d.setFullYear(d.getFullYear() + 1); break
    case "M": d.setDate(1); d.setHours(0, 0, 0, 0); if (up) d.setMonth(d.getMonth() + 1); break
    case "w": d.setDate(d.getDate() - d.getDay()); d.setHours(0, 0, 0, 0); if (up) d.setDate(d.getDate() + 7); break
    case "d": d.setHours(0, 0, 0, 0); if (up) d.setDate(d.getDate() + 1); break
    case "h": d.setMinutes(0, 0, 0); if (up) d.setHours(d.getHours() + 1); break
    case "m": d.setSeconds(0, 0); if (up) d.setMinutes(d.getMinutes() + 1); break
    case "s": d.setMilliseconds(0); if (up) d.setSeconds(d.getSeconds() + 1); break
    }
    return up ? d.getTime() - 1 : d.getTime()
}

function resolveRange(range, now) {
    var from = parseTime(range.from || "now-6h", now, false)
    var to = parseTime(range.to || "now", now, true)
    return { from: from, to: Math.max(to, from + 1000) }
}

var RANGE_LABELS = {
    "now-5m": "Last 5 minutes", "now-15m": "Last 15 minutes", "now-30m": "Last 30 minutes",
    "now-1h": "Last 1 hour", "now-3h": "Last 3 hours", "now-6h": "Last 6 hours",
    "now-12h": "Last 12 hours", "now-24h": "Last 24 hours", "now-2d": "Last 2 days",
    "now-7d": "Last 7 days", "now-30d": "Last 30 days", "now-90d": "Last 90 days",
    "now/d": "Today so far", "now-1d/d": "Yesterday"
}

function rangeLabel(range) {
    if ((range.to || "now") === "now" && RANGE_LABELS[range.from]) return RANGE_LABELS[range.from]
    return (range.from || "") + " → " + (range.to || "now")
}

// Grafana's own interval rounding, so step sizes land where it puts them
function roundInterval(ms) {
    var steps = [
        [15, 10], [35, 20], [75, 50], [150, 100], [350, 200], [750, 500], [1500, 1000],
        [3500, 2000], [7500, 5000], [12500, 10000], [17500, 15000], [25000, 20000],
        [45000, 30000], [90000, 60000], [210000, 120000], [450000, 300000], [750000, 600000],
        [1050000, 900000], [1500000, 1200000], [2700000, 1800000], [5400000, 3600000],
        [9000000, 7200000], [16200000, 10800000], [32400000, 21600000], [86400000, 43200000],
        [604800000, 86400000], [1814400000, 604800000], [3628800000, 2592000000]
    ]
    for (var i = 0; i < steps.length; i++) if (ms < steps[i][0]) return steps[i][1]
    return 31536000000
}

function parseDuration(text) {
    var m = /^(\d+(?:\.\d+)?)(ms|s|m|h|d|w|M|y)$/.exec(String(text || "").trim())
    if (!m) return 0
    return parseFloat(m[1]) * (m[2] === "ms" ? 1 : UNIT_MS[m[2]])
}

function intervalFor(range, maxDataPoints, minInterval) {
    var raw = (range.to - range.from) / Math.max(1, maxDataPoints)
    return Math.max(roundInterval(raw), parseDuration(minInterval) || 1)
}

// ── template variables & datasources ──────────────────────────────────

// {name: {value, selected, options, all, raw}} from the dashboard's
// templating list. `selected` is what the person picked (`selections`,
// else what the dashboard was saved with); `value` is what goes into a
// query, with "All" expanded. Datasource variables resolve to a uid
// through `datasources` (from /api/frontend/settings); query variables
// take their options from `fetched` (see varRequest/varOptions).
function variables(dashboard, datasources, selections, fetched) {
    var out = {}
    var list = (dashboard.templating && dashboard.templating.list) || []
    for (var i = 0; i < list.length; i++) {
        var v = list[i]
        var cur = v.current || {}
        var selected = selections && selections[v.name] !== undefined ? selections[v.name] : cur.value
        if (v.type === "constant") selected = v.query
        var options = optionsFor(v, datasources, fetched && fetched[v.name])
        if (selected === undefined || selected === null || (Array.isArray(selected) && selected.length === 0)) {
            selected = options.length ? options[0].value : ""
        }
        var value = selected
        var all = value === "$__all" || (Array.isArray(value) && value.indexOf("$__all") !== -1)
        if (all) {
            var vals = options.map(function(o) { return o.value }).filter(function(x) { return x !== "$__all" })
            value = v.allValue ? v.allValue : vals.length ? vals : ".*"
        }
        if (v.type === "datasource") {
            var ds = findDatasource(datasources, Array.isArray(value) ? value[0] : value, v.query)
            value = ds ? ds.uid : value
        }
        out[v.name] = { value: value, selected: selected, options: options, all: all, raw: v }
    }
    return out
}

// [{text, value}] a variable can be set to, "All" first when it has one
function optionsFor(v, datasources, fetched) {
    var opts = []
    if (v.type === "datasource") {
        var re = regexOf(v.regex)
        for (var name in datasources || {}) {
            var ds = datasources[name]
            if (ds.type === v.query && (!re || re.test(name))) opts.push({ text: name, value: name })
        }
    } else if (v.type === "custom" && !(v.options && v.options.length)) {
        // "a, b, c" or "label : value"; a backslash keeps a comma literal
        opts = String(v.query || "").replace(/\\,/g, "\u0000").split(",").map(function(s) {
            s = s.replace(/\u0000/g, ",").trim()
            var kv = /^(.+?)\s:\s(.+)$/.exec(s)
            return kv ? { text: kv[1], value: kv[2] } : { text: s, value: s }
        }).filter(function(o) { return o.value !== "" })
    } else if (v.type === "interval") {
        opts = String(v.query || "").split(",").map(function(s) { s = s.trim(); return { text: s, value: s } })
    } else if (v.type === "query" && fetched) {
        opts = fetched.slice()
    } else {
        opts = (v.options || []).filter(function(o) { return o.value !== "$__all" })
            .map(function(o) { return { text: String(o.text), value: o.value } })
    }
    if (v.type === "query" && opts.length === 0 && v.current && v.current.value !== undefined && !fetched) {
        // not fetched yet: offer what was saved so the picker isn't empty
        var saved = [].concat(v.current.value).filter(function(x) { return x !== "$__all" })
        var texts = [].concat(v.current.text)
        opts = saved.map(function(x, i) { return { text: String(texts[i] !== undefined ? texts[i] : x), value: x } })
    }
    if (v.includeAll) opts.unshift({ text: "All", value: "$__all" })
    return opts
}

function regexOf(text) {
    if (!text) return null
    var m = /^\/(.*)\/([gimsuy]*)$/.exec(text)
    try { return m ? new RegExp(m[1], m[2].replace("g", "")) : new RegExp(text) } catch (e) { return null }
}

// The datasource resource request that lists a Prometheus query variable's
// options, or null for anything this widget can't evaluate. The query may
// reference other variables, so `vars` holds the ones before it.
function varRequest(v, vars, datasources, range) {
    if (v.type !== "query") return null
    var ref = v.datasource
    var refUid = interpolate(typeof ref === "string" ? ref : (ref && ref.uid) || "", vars, false)
    var ds = findDatasource(datasources, refUid || null, ref && ref.type)
    if (!ds || ds.type !== "prometheus") return null
    var q = interpolate(typeof v.query === "string" ? v.query : (v.query && v.query.query) || "", vars, true).trim()
    var base = "/api/datasources/uid/" + encodeURIComponent(ds.uid) + "/resources/api/v1/"
    var span = "start=" + Math.floor(range.from / 1000) + "&end=" + Math.floor(range.to / 1000)
    var m
    if ((m = /^label_values\((.+),\s*([\w.]+)\s*\)$/.exec(q))) {
        return { kind: "values", path: base + "label/" + encodeURIComponent(m[2]) + "/values?match[]=" + encodeURIComponent(m[1]) + "&" + span }
    }
    if ((m = /^label_values\(\s*([\w.]+)\s*\)$/.exec(q))) {
        return { kind: "values", path: base + "label/" + encodeURIComponent(m[1]) + "/values?" + span }
    }
    if ((m = /^label_names\((.*)\)$/.exec(q))) {
        return { kind: "values", path: base + "labels?" + (m[1].trim() ? "match[]=" + encodeURIComponent(m[1]) + "&" : "") + span }
    }
    if ((m = /^metrics\((.*)\)$/.exec(q))) {
        return { kind: "metrics", filter: m[1].trim(), path: base + "label/__name__/values?" + span }
    }
    if ((m = /^query_result\((.+)\)$/.exec(q))) {
        return { kind: "query", path: base + "query?query=" + encodeURIComponent(m[1]) + "&time=" + Math.floor(range.to / 1000) }
    }
    return null
}

// [{text, value}] from the answer to varRequest, after the variable's own
// regex (with a capture group picking the value) and sort order.
function varOptions(v, req, response) {
    var raw = []
    var data = response && response.data
    if (req.kind === "values" || req.kind === "metrics") {
        raw = (data || []).map(String)
        if (req.kind === "metrics" && req.filter) {
            var fre = regexOf(req.filter)
            if (fre) raw = raw.filter(function(x) { return fre.test(x) })
        }
    } else if (req.kind === "query") {
        raw = ((data && data.result) || []).map(function(r) {
            var labels = Object.keys(r.metric || {}).filter(function(k) { return k !== "__name__" })
            return (r.metric.__name__ || "") + "{" + labels.map(function(k) { return k + "=\"" + r.metric[k] + "\"" }).join(",")
                + "} " + (r.value ? r.value[1] : "") + " " + (r.value ? Math.round(r.value[0] * 1000) : "")
        })
    }
    var re = regexOf(v.regex)
    var seen = {}
    var out = []
    for (var i = 0; i < raw.length; i++) {
        var text = raw[i]
        if (re) {
            var m = re.exec(text)
            if (!m) continue
            text = m[1] !== undefined ? m[1] : m[0]
        }
        if (seen[text]) continue
        seen[text] = true
        out.push({ text: text, value: text })
    }
    var sort = v.sort || 0
    if (sort === 1 || sort === 2) out.sort(function(a, b) { return a.text.localeCompare(b.text) })
    if (sort === 3 || sort === 4) out.sort(function(a, b) { return (parseFloat(a.text) || 0) - (parseFloat(b.text) || 0) })
    if (sort === 5 || sort === 6) out.sort(function(a, b) { return a.text.toLowerCase().localeCompare(b.text.toLowerCase()) })
    if (sort === 2 || sort === 4 || sort === 6) out.reverse()
    return out
}

// the label a picker shows for its current selection
function selectionText(variable) {
    var sel = [].concat(variable.selected)
    if (sel.indexOf("$__all") !== -1) return "All"
    var names = sel.map(function(x) {
        for (var i = 0; i < variable.options.length; i++) if (variable.options[i].value === x) return variable.options[i].text
        return String(x)
    })
    return names.length > 2 ? names.slice(0, 2).join(", ") + " +" + (names.length - 2) : names.join(", ")
}

// by uid, then by name; `type` narrows a default when nothing matches
function findDatasource(datasources, ref, type) {
    if (!datasources) return null
    var list = []
    for (var name in datasources) list.push(Object.assign({ name: name }, datasources[name]))
    if (ref && ref !== "default") {
        for (var i = 0; i < list.length; i++) if (list[i].uid === ref) return list[i]
        for (var j = 0; j < list.length; j++) if (list[j].name === ref) return list[j]
    }
    for (var k = 0; k < list.length; k++) {
        if (list[k].isDefault && (!type || list[k].type === type)) return list[k]
    }
    if (type) for (var n = 0; n < list.length; n++) if (list[n].type === type && list[n].uid) return list[n]
    return null
}

function formatValue(value, format, regexDefault) {
    if (Array.isArray(value)) {
        if (value.length === 1) return formatValue(value[0], format, regexDefault)
        switch (format) {
        case "csv": return value.join(",")
        case "pipe": return value.join("|")
        case "json": return JSON.stringify(value)
        case "raw": return value.join(",")
        default:
            return regexDefault ? "(" + value.map(regexEscape).join("|") + ")" : "{" + value.join(",") + "}"
        }
    }
    if (format === "regex" && regexDefault) return regexEscape(String(value))
    return value === undefined || value === null ? "" : String(value)
}

// Escaped for a regex inside a PromQL string literal, the way Grafana's
// Prometheus datasource does it: the backslashes themselves need escaping
// for the string, so each one is doubled.
function regexEscape(s) {
    return String(s).replace(/\\/g, "\\\\\\\\").replace(/[$^*{}\[\]'+?.()|]/g, "\\\\$&")
}

// $var, ${var}, ${var:format}, [[var]]. Grafana's own $__ macros are left
// for the datasource backend, which interpolates them itself.
function interpolate(text, vars, regexDefault) {
    if (typeof text !== "string") return text
    return text.replace(/\$\{(\w+)(?::(\w+))?\}|\[\[(\w+)\]\]|\$(\w+)/g, function(whole, a, fmt, b, c) {
        var name = a || b || c
        if (name.indexOf("__") === 0) return whole
        var v = vars[name]
        if (!v) return whole
        return formatValue(v.value, fmt, regexDefault)
    })
}

function interpolateDeep(obj, vars, regexDefault) {
    if (typeof obj === "string") return interpolate(obj, vars, regexDefault)
    if (Array.isArray(obj)) return obj.map(function(x) { return interpolateDeep(x, vars, regexDefault) })
    if (obj && typeof obj === "object") {
        var out = {}
        for (var k in obj) out[k] = interpolateDeep(obj[k], vars, regexDefault)
        return out
    }
    return obj
}

// The /api/ds/query body for one panel, or {error} when it can't be built.
// Prometheus and Loki take a regex-joined multi value, which is what
// their own frontends do.
function buildQuery(panel, dashboard, vars, datasources, range, maxDataPoints) {
    var targets = (panel.targets || []).filter(function(t) { return !t.hide })
    if (targets.length === 0) return { error: "no queries" }
    if (panel.scopedVars) vars = Object.assign({}, vars, panel.scopedVars)
    var panelDs = panel.datasource
    var interval = intervalFor(range, maxDataPoints, panel.interval)
    var queries = []

    for (var i = 0; i < targets.length; i++) {
        var t = targets[i]
        var ref = t.datasource || panelDs
        if (ref && ref.uid === "-- Mixed --") ref = t.datasource
        var refUid = typeof ref === "string" ? ref : ref && ref.uid
        var refType = ref && typeof ref === "object" ? ref.type : undefined
        refUid = interpolate(refUid || "", vars, false)
        var ds = findDatasource(datasources, refUid || null, refType)
        if (!ds || !ds.uid) return { error: "can't resolve datasource " + (refUid || "default") }
        if (ds.uid === "-- Dashboard --" || ds.type === "datasource") return { error: "panels that reuse another panel's query aren't supported" }

        var regexDefault = ds.type === "prometheus" || ds.type === "loki"
        var q = interpolateDeep(t, vars, regexDefault)
        q.datasource = { uid: ds.uid, type: ds.type }
        q.refId = t.refId || String.fromCharCode(65 + i)
        q.intervalMs = interval
        q.maxDataPoints = maxDataPoints
        queries.push(q)
    }
    return { body: { queries: queries, from: String(range.from), to: String(range.to) }, interval: interval }
}

// ── frames → series ───────────────────────────────────────────────────

// Prometheus' legend template, for frames that came back without one
function legend(format, labels) {
    if (!format || format === "__auto") return null
    return format.replace(/\{\{\s*(.+?)\s*\}\}/g, function(_, k) { return labels && labels[k] !== undefined ? labels[k] : "" })
}

function labelsText(labels) {
    var keys = Object.keys(labels || {}).filter(function(k) { return k !== "__name__" })
    var name = labels && labels.__name__ ? labels.__name__ : ""
    if (keys.length === 0) return name
    return name + "{" + keys.map(function(k) { return k + "=\"" + labels[k] + "\"" }).join(", ") + "}"
}

// [{name, refId, labels, times: [ms], values: [number|null], config}]
// One per number field. Frames without a time field (tables) give their
// number fields with no times, so reducers still work on them.
function seriesFromResults(results, panel) {
    var out = []
    var targetsByRef = {}
    var targets = panel.targets || []
    for (var i = 0; i < targets.length; i++) targetsByRef[targets[i].refId] = targets[i]

    var refs = Object.keys(results || {}).sort()
    for (var r = 0; r < refs.length; r++) {
        var res = results[refs[r]]
        var frames = (res && res.frames) || []
        for (var f = 0; f < frames.length; f++) {
            var frame = frames[f]
            var fields = (frame.schema && frame.schema.fields) || []
            var values = (frame.data && frame.data.values) || []
            var timeIdx = -1
            for (var k = 0; k < fields.length; k++) if (fields[k].type === "time") { timeIdx = k; break }
            var times = timeIdx >= 0 ? values[timeIdx] || [] : []
            for (var n = 0; n < fields.length; n++) {
                var fld = fields[n]
                if (fld.type !== "number") continue
                var cfg = fld.config || {}
                var t = targetsByRef[refs[r]] || {}
                var name = cfg.displayNameFromDS || cfg.displayName
                    || legend(t.legendFormat, fld.labels)
                    || (fld.labels && Object.keys(fld.labels).length ? labelsText(fld.labels) : null)
                    || (frame.schema.name && fields.length === 2 ? frame.schema.name : null)
                    || fld.name
                out.push({
                    name: name,
                    fieldName: fld.name,
                    refId: refs[r],
                    labels: fld.labels || {},
                    times: times,
                    values: values[n] || [],
                    config: cfg
                })
            }
        }
    }
    return out
}

function resultErrors(results) {
    var errs = []
    for (var r in results || {}) if (results[r] && results[r].error) errs.push(results[r].error)
    return errs
}

// ── reducers ──────────────────────────────────────────────────────────

function numbers(values) {
    var out = []
    for (var i = 0; i < values.length; i++) {
        var v = values[i]
        if (v !== null && v !== undefined && !isNaN(v)) out.push(v)
    }
    return out
}

function reduce(values, calc) {
    var nums = numbers(values || [])
    var i
    switch (calc) {
    case "last": return values.length ? values[values.length - 1] : null
    case "first": return values.length ? values[0] : null
    case "firstNotNull": return nums.length ? nums[0] : null
    case "mean": if (!nums.length) return null; var s = 0; for (i = 0; i < nums.length; i++) s += nums[i]; return s / nums.length
    case "sum": var t = 0; for (i = 0; i < nums.length; i++) t += nums[i]; return t
    case "max": return nums.length ? Math.max.apply(null, nums) : null
    case "min": return nums.length ? Math.min.apply(null, nums) : null
    case "count": return values.length
    case "range": return nums.length ? Math.max.apply(null, nums) - Math.min.apply(null, nums) : null
    case "delta":
        var d = 0
        for (i = 1; i < nums.length; i++) d += nums[i] >= nums[i - 1] ? nums[i] - nums[i - 1] : nums[i]
        return d
    case "diff": return nums.length ? nums[nums.length - 1] - nums[0] : null
    case "lastNotNull":
    default: return nums.length ? nums[nums.length - 1] : null
    }
}

var CALC_LABELS = {
    last: "Last", lastNotNull: "Last *", first: "First", firstNotNull: "First *", mean: "Mean",
    sum: "Total", max: "Max", min: "Min", count: "Count", range: "Range", delta: "Delta", diff: "Difference"
}

function calcLabel(c) { return CALC_LABELS[c] || c }

// ── field config ──────────────────────────────────────────────────────

// The panel's defaults with any matching overrides applied, for one field.
// `index` is the series' position, used by palette colours.
function fieldConfig(panel, series) {
    var fc = panel.fieldConfig || {}
    var cfg = Object.assign({}, fc.defaults || {})
    cfg.custom = Object.assign({}, (fc.defaults && fc.defaults.custom) || {})
    var overrides = fc.overrides || []
    for (var i = 0; i < overrides.length; i++) {
        var o = overrides[i]
        if (!matches(o.matcher, series)) continue
        var props = o.properties || []
        for (var p = 0; p < props.length; p++) {
            var id = props[p].id
            if (id.indexOf("custom.") === 0) cfg.custom[id.substring(7)] = props[p].value
            else cfg[id] = props[p].value
        }
    }
    return cfg
}

function matches(matcher, series) {
    if (!matcher) return false
    var opt = matcher.options
    switch (matcher.id) {
    case "byName": return opt === series.name || opt === series.fieldName || opt === series.column
    case "byRegexp":
        try { return new RegExp(opt).test(series.name) } catch (e) { return false }
    case "byFrameRefID": return opt === series.refId
    case "byType": return opt === "number"
    default: return false
    }
}

function displayName(series, cfg) {
    return cfg.displayName ? interpolate(cfg.displayName, { __series: { value: series.name } }) : series.name
}

// ── colours ───────────────────────────────────────────────────────────

var CLASSIC = ["#7EB26D", "#EAB839", "#6ED0E0", "#EF843C", "#E24D42", "#1F78C1", "#BA43A9", "#705DA0",
               "#508642", "#CCA300", "#447EBC", "#C15C17", "#890F02", "#0A437C", "#6D1F62", "#584477",
               "#B7DBAB", "#F4D598", "#70DBED", "#F9BA8F", "#F29191", "#82B5D8", "#E5A8E2", "#AEA2E0",
               "#629E51", "#E5AC0E", "#64B0C8", "#E0752D", "#BF1B00", "#0A50A1", "#962D82", "#614D93"]

// super-light, light, base, semi-dark, dark
var HUES = {
    red: ["#FFA6B0", "#FF7383", "#F2495C", "#E02F44", "#C4162A"],
    orange: ["#FFCB7D", "#FFB357", "#FF9830", "#FF780A", "#FA6400"],
    yellow: ["#FFF899", "#FFEE52", "#FADE2A", "#F2CC0C", "#E0B400"],
    green: ["#C8F2C2", "#96D98D", "#73BF69", "#56A64B", "#37872D"],
    blue: ["#C0D8FF", "#8AB8FF", "#5794F2", "#3274D9", "#1F60C4"],
    purple: ["#DEB6F2", "#CA95E5", "#B877D9", "#A352CC", "#8F3BB8"]
}
var SHADES = { "super-light-": 0, "light-": 1, "": 2, "semi-dark-": 3, "dark-": 4 }

// "text" means the theme's text colour, which only the caller knows
function namedColor(name, textColor) {
    if (!name) return textColor
    if (name === "text") return textColor
    if (name === "transparent") return "transparent"
    if (name.charAt(0) === "#" || name.indexOf("rgb") === 0) return name
    var m = /^(super-light-|light-|semi-dark-|dark-)?(red|orange|yellow|green|blue|purple)$/.exec(name)
    if (m) return HUES[m[2]][SHADES[m[1] || ""]]
    return name
}

var SCHEMES = {
    "continuous-GrYlRd": ["#73BF69", "#FADE2A", "#F2495C"],
    "continuous-RdYlGr": ["#F2495C", "#FADE2A", "#73BF69"],
    "continuous-BlYlRd": ["#5794F2", "#FADE2A", "#F2495C"],
    "continuous-YlRd": ["#FADE2A", "#F2495C"],
    "continuous-BlPu": ["#5794F2", "#B877D9"],
    "continuous-YlBl": ["#FADE2A", "#5794F2"],
    "continuous-blues": ["#C0D8FF", "#1F60C4"],
    "continuous-reds": ["#FFA6B0", "#C4162A"],
    "continuous-greens": ["#C8F2C2", "#37872D"],
    "continuous-purples": ["#DEB6F2", "#8F3BB8"]
}

function hexToRgb(hex) {
    var h = hex.replace("#", "")
    if (h.length === 3) h = h.replace(/(.)/g, "$1$1")
    return [parseInt(h.substring(0, 2), 16), parseInt(h.substring(2, 4), 16), parseInt(h.substring(4, 6), 16)]
}

function mix(a, b, t) {
    var x = hexToRgb(a), y = hexToRgb(b)
    function ch(i) { var v = Math.round(x[i] + (y[i] - x[i]) * t).toString(16); return v.length < 2 ? "0" + v : v }
    return "#" + ch(0) + ch(1) + ch(2)
}

function schemeColor(stops, t) {
    t = Math.max(0, Math.min(1, isNaN(t) ? 0 : t))
    if (stops.length === 1) return stops[0]
    var pos = t * (stops.length - 1)
    var i = Math.min(stops.length - 2, Math.floor(pos))
    return mix(stops[i], stops[i + 1], pos - i)
}

function thresholdStep(thresholds, value, min, max) {
    var steps = (thresholds && thresholds.steps) || []
    if (steps.length === 0) return null
    var v = value
    if (thresholds.mode === "percentage" && min !== undefined && max !== undefined && max > min) {
        v = (value - min) / (max - min) * 100
    }
    var hit = steps[0]
    for (var i = 1; i < steps.length; i++) {
        if (steps[i].value !== null && v >= steps[i].value) hit = steps[i]
    }
    return hit
}

// The colour a series (or one of its values) is drawn in.
function seriesColor(cfg, index, value, range, textColor) {
    var c = cfg.color || { mode: "palette-classic" }
    var mode = c.mode || "palette-classic"
    if (mode === "fixed" || mode === "shades") return namedColor(c.fixedColor, textColor)
    if (mode === "thresholds") {
        var step = thresholdStep(cfg.thresholds, value === null || value === undefined ? -Infinity : value,
                                 range ? range.min : undefined, range ? range.max : undefined)
        return namedColor(step ? step.color : "green", textColor)
    }
    if (SCHEMES[mode]) {
        if (!range || range.max === range.min) return SCHEMES[mode][SCHEMES[mode].length - 1]
        return schemeColor(SCHEMES[mode], (value - range.min) / (range.max - range.min))
    }
    return CLASSIC[index % CLASSIC.length]
}

// a CSS colour with alpha, for fills
function withAlpha(color, alpha) {
    if (!color || color === "transparent") return "rgba(0,0,0,0)"
    if (color.charAt(0) === "#") {
        var rgb = hexToRgb(color)
        return "rgba(" + rgb[0] + "," + rgb[1] + "," + rgb[2] + "," + alpha + ")"
    }
    return color
}

// ── units ─────────────────────────────────────────────────────────────

function decimalsFor(v, decimals) {
    if (decimals !== undefined && decimals !== null) return decimals
    var a = Math.abs(v)
    if (a === 0 || a >= 100) return 0
    if (a >= 10) return 1
    if (a >= 1) return 2
    return Math.min(4, Math.max(2, 1 - Math.floor(Math.log(a) / Math.LN10)))
}

function fixed(v, decimals) {
    var d = decimalsFor(v, decimals)
    var s = v.toFixed(d)
    // auto decimals: don't print 3.00
    if (decimals === undefined || decimals === null) s = s.indexOf(".") !== -1 ? s.replace(/\.?0+$/, "") : s
    return s
}

function scaled(v, decimals, base, prefixes, suffix) {
    var a = Math.abs(v)
    var i = 0
    while (a >= base && i < prefixes.length - 1) { a /= base; v /= base; i++ }
    return { text: fixed(v, decimals), suffix: prefixes[i] + suffix }
}

function timeScaled(v, decimals, unit) {
    // into the largest unit that keeps the number ≥ 1
    var ms = v * { ns: 1e-6, "µs": 1e-3, ms: 1, s: 1000, m: 60000, h: 3600000, d: 86400000 }[unit]
    var a = Math.abs(ms)
    var table = [[86400000 * 365, " year"], [86400000 * 7, " week"], [86400000, " day"], [3600000, " hour"],
                 [60000, " min"], [1000, " s"], [1, " ms"], [1e-3, " µs"], [1e-6, " ns"]]
    if (a === 0) return { text: "0", suffix: " " + unit }
    for (var i = 0; i < table.length; i++) {
        if (a >= table[i][0]) {
            var n = ms / table[i][0]
            var suffix = table[i][1]
            if ((suffix === " year" || suffix === " week" || suffix === " day" || suffix === " hour") && Math.abs(n) !== 1) suffix += "s"
            return { text: fixed(n, decimals), suffix: suffix }
        }
    }
    return { text: fixed(ms * 1e6, decimals), suffix: " ns" }
}

var SI = ["", " K", " M", " G", " T", " P"]
var SHORT = ["", " K", " Mil", " Bil", " Tri"]

// {text, suffix, prefix}; display is prefix + text + suffix
function formatParts(value, unit, decimals) {
    if (value === null || value === undefined || (typeof value === "number" && isNaN(value))) return { text: "No data", suffix: "", prefix: "", empty: true }
    if (typeof value !== "number") return { text: String(value), suffix: "", prefix: "" }
    var v = value
    var r
    switch (unit || "short") {
    case "none": return { text: fixed(v, decimals), suffix: "", prefix: "" }
    case "percent": return { text: fixed(v, decimals), suffix: "%", prefix: "" }
    case "percentunit": return { text: fixed(v * 100, decimals), suffix: "%", prefix: "" }
    case "bytes": r = scaled(v, decimals, 1024, [" ", " Ki", " Mi", " Gi", " Ti", " Pi"], "B"); break
    case "decbytes": r = scaled(v, decimals, 1000, [" ", " k", " M", " G", " T", " P"], "B"); break
    case "bits": r = scaled(v, decimals, 1024, [" ", " Ki", " Mi", " Gi", " Ti"], "b"); break
    case "decbits": r = scaled(v, decimals, 1000, [" ", " k", " M", " G", " T"], "b"); break
    case "kbytes": r = scaled(v * 1024, decimals, 1024, [" ", " Ki", " Mi", " Gi", " Ti"], "B"); break
    case "mbytes": r = scaled(v * 1048576, decimals, 1024, [" ", " Ki", " Mi", " Gi", " Ti"], "B"); break
    case "Bps": r = scaled(v, decimals, 1000, [" ", " k", " M", " G", " T"], "B/s"); break
    case "binBps": r = scaled(v, decimals, 1024, [" ", " Ki", " Mi", " Gi", " Ti"], "B/s"); break
    case "bps": r = scaled(v, decimals, 1000, [" ", " k", " M", " G", " T"], "b/s"); break
    case "binbps": r = scaled(v, decimals, 1024, [" ", " Ki", " Mi", " Gi", " Ti"], "b/s"); break
    case "reqps": r = scaled(v, decimals, 1000, SHORT, ""); r.suffix += " req/s"; break
    case "rps": r = scaled(v, decimals, 1000, SHORT, ""); r.suffix += " rps"; break
    case "ops": r = scaled(v, decimals, 1000, SHORT, ""); r.suffix += " ops/s"; break
    case "wps": r = scaled(v, decimals, 1000, SHORT, ""); r.suffix += " wps"; break
    case "iops": r = scaled(v, decimals, 1000, SHORT, ""); r.suffix += " io/s"; break
    case "opm": case "rpm": case "reqpm": r = scaled(v, decimals, 1000, SHORT, ""); r.suffix += " " + unit; break
    case "hertz": r = scaled(v, decimals, 1000, [" ", " k", " M", " G"], "Hz"); break
    case "celsius": return { text: fixed(v, decimals), suffix: "°C", prefix: "" }
    case "fahrenheit": return { text: fixed(v, decimals), suffix: "°F", prefix: "" }
    case "ns": case "µs": case "ms": case "s": case "m": case "h": case "d": r = timeScaled(v, decimals, unit); break
    case "dtdurations": case "dtdurationms":
        return { text: duration(unit === "dtdurations" ? v * 1000 : v), suffix: "", prefix: "" }
    case "dateTimeAsIso": case "dateTimeAsSystem": case "dateTimeFromNow":
        return { text: new Date(v).toISOString().replace("T", " ").substring(0, 19), suffix: "", prefix: "" }
    case "currencyUSD": r = scaled(v, decimals, 1000, SHORT, ""); r.prefix = "$"; return r
    case "currencyEUR": r = scaled(v, decimals, 1000, SHORT, ""); r.prefix = "€"; return r
    case "short":
        r = scaled(v, decimals, 1000, SHORT, ""); break
    default:
        // custom units: "suffix:x", "prefix:x", or anything else as a suffix
        if (unit.indexOf("suffix:") === 0) return { text: fixed(v, decimals), suffix: unit.substring(7), prefix: "" }
        if (unit.indexOf("prefix:") === 0) return { text: fixed(v, decimals), suffix: "", prefix: unit.substring(7) }
        return { text: fixed(v, decimals), suffix: " " + unit, prefix: "" }
    }
    r.prefix = r.prefix || ""
    if (r.suffix === " ") r.suffix = ""
    r.suffix = r.suffix.replace(/^ +$/, "")
    return r
}

function format(value, unit, decimals) {
    var p = formatParts(value, unit, decimals)
    return p.prefix + p.text + p.suffix
}

function duration(ms) {
    var s = Math.floor(Math.abs(ms) / 1000)
    var d = Math.floor(s / 86400), h = Math.floor(s % 86400 / 3600), m = Math.floor(s % 3600 / 60), sec = s % 60
    var parts = []
    if (d) parts.push(d + "d")
    if (h) parts.push(h + "h")
    if (m && parts.length < 2) parts.push(m + "m")
    if (parts.length < 2) parts.push(sec + "s")
    return (ms < 0 ? "-" : "") + parts.slice(0, 2).join(" ")
}

// ── axes ──────────────────────────────────────────────────────────────

// About `count` round ticks covering [min, max]
function niceTicks(min, max, count) {
    if (!isFinite(min) || !isFinite(max)) return { min: 0, max: 1, ticks: [0, 1] }
    if (min === max) { var pad = Math.abs(min) * 0.1 || 1; min -= pad; max += pad }
    var span = max - min
    var raw = span / Math.max(1, count)
    var mag = Math.pow(10, Math.floor(Math.log(raw) / Math.LN10))
    var norm = raw / mag
    var step = (norm < 1.5 ? 1 : norm < 3 ? 2 : norm < 7 ? 5 : 10) * mag
    var lo = Math.floor(min / step) * step
    var hi = Math.ceil(max / step) * step
    var ticks = []
    for (var v = lo; v <= hi + step / 2; v += step) ticks.push(Math.abs(v) < step / 1e6 ? 0 : v)
    return { min: lo, max: hi, ticks: ticks, step: step }
}

var TIME_STEPS = [1000, 5000, 10000, 15000, 30000, 60000, 120000, 300000, 600000, 900000, 1800000,
                  3600000, 7200000, 10800000, 21600000, 43200000, 86400000, 172800000, 604800000, 2592000000]

function timeTicks(from, to, count) {
    var raw = (to - from) / Math.max(1, count)
    var step = TIME_STEPS[TIME_STEPS.length - 1]
    for (var i = 0; i < TIME_STEPS.length; i++) if (TIME_STEPS[i] >= raw) { step = TIME_STEPS[i]; break }
    // align to local midnight-relative boundaries, like Grafana does
    var tz = new Date(from).getTimezoneOffset() * 60000
    var first = Math.ceil((from - tz) / step) * step + tz
    var ticks = []
    for (var t = first; t <= to; t += step) ticks.push(t)
    return { ticks: ticks, step: step }
}

function pad2(n) { return n < 10 ? "0" + n : "" + n }

function timeLabel(t, step, span) {
    var d = new Date(t)
    var hm = pad2(d.getHours()) + ":" + pad2(d.getMinutes())
    if (step < 60000) return hm + ":" + pad2(d.getSeconds())
    if (span <= 86400000 * 1.5 || (step < 86400000 && (d.getHours() || d.getMinutes()))) return hm
    return pad2(d.getMonth() + 1) + "/" + pad2(d.getDate())
}

function fullTime(t) {
    var d = new Date(t)
    return d.getFullYear() + "-" + pad2(d.getMonth() + 1) + "-" + pad2(d.getDate()) + " "
        + pad2(d.getHours()) + ":" + pad2(d.getMinutes()) + ":" + pad2(d.getSeconds())
}

// ── tables ────────────────────────────────────────────────────────────

// {columns: [{name, unit?, numeric}], rows: [[cell]]}. Instant series
// with labels are joined into one row per label set, one "Value #A"
// column per query, the way Grafana's table shows several instant queries.
function tableFromResults(results, panel) {
    var refs = Object.keys(results || {}).sort()
    var labelKeys = []
    var valueCols = []
    var rowsByKey = {}
    var order = []
    var plainFrames = []

    for (var r = 0; r < refs.length; r++) {
        var frames = (results[refs[r]] && results[refs[r]].frames) || []
        for (var f = 0; f < frames.length; f++) {
            var fields = (frames[f].schema && frames[f].schema.fields) || []
            var vals = (frames[f].data && frames[f].data.values) || []
            var labelled = fields.some(function(x) { return x.type === "number" && x.labels && Object.keys(x.labels).length })
            if (!labelled && refs.length === 1) { plainFrames.push(frames[f]); continue }
            for (var n = 0; n < fields.length; n++) {
                if (fields[n].type !== "number") continue
                var labels = fields[n].labels || {}
                var col = refs.length > 1 ? "Value #" + refs[r] : "Value"
                if (valueCols.indexOf(col) === -1) valueCols.push(col)
                var keys = Object.keys(labels).filter(function(k) { return k !== "__name__" }).sort()
                for (var k = 0; k < keys.length; k++) if (labelKeys.indexOf(keys[k]) === -1) labelKeys.push(keys[k])
                var id = keys.map(function(x) { return x + "=" + labels[x] }).join(",")
                if (!rowsByKey[id]) { rowsByKey[id] = { labels: labels, values: {} }; order.push(id) }
                rowsByKey[id].values[col] = reduce(vals[n] || [], "lastNotNull")
            }
        }
    }

    if (plainFrames.length > 0 && order.length === 0) {
        var pf = plainFrames[0]
        var pfields = pf.schema.fields
        var pvals = pf.data.values
        var count = pvals.length ? pvals[0].length : 0
        var rows = []
        for (var i = 0; i < count; i++) rows.push(pvals.map(function(c) { return c[i] }))
        return {
            columns: pfields.map(function(x) { return { name: (x.config && x.config.displayName) || x.name, field: x.name, type: x.type, refId: refs[0] } }),
            rows: rows
        }
    }

    var columns = labelKeys.map(function(k) { return { name: k, field: k, type: "string" } })
        .concat(valueCols.map(function(c) { return { name: c, field: c, type: "number", refId: c.replace("Value #", "") } }))
    var out = order.map(function(id) {
        var row = rowsByKey[id]
        return labelKeys.map(function(k) { return row.labels[k] !== undefined ? row.labels[k] : "" })
            .concat(valueCols.map(function(c) { return row.values[c] !== undefined ? row.values[c] : null }))
    })
    return { columns: columns, rows: out }
}

// ── layout ────────────────────────────────────────────────────────────

// Flattens a dashboard into positioned items on the 24-column grid,
// honouring rows the person has folded (`folded[rowId]`, defaulting to
// what the dashboard was saved with). Items: {kind: "row"|"panel", panel,
// x, y, w, h, collapsed}.
function layout(dashboard, folded, vars) {
    var panels = (dashboard.panels || []).slice().sort(function(a, b) {
        var ga = a.gridPos || {}, gb = b.gridPos || {}
        return (ga.y || 0) - (gb.y || 0) || (ga.x || 0) - (gb.x || 0)
    })

    var sections = []
    var current = { row: null, panels: [] }
    for (var i = 0; i < panels.length; i++) {
        var p = panels[i]
        if (p.type === "row") {
            sections.push(current)
            current = { row: p, panels: (p.panels || []).slice() }
        } else {
            current.panels.push(p)
        }
    }
    sections.push(current)

    var out = []
    var cursor = 0
    for (var s = 0; s < sections.length; s++) {
        var sec = sections[s]
        if (!sec.row && sec.panels.length === 0) continue
        var collapsed = false
        if (sec.row) {
            var saved = !!sec.row.collapsed
            collapsed = folded && folded[sec.row.id] !== undefined ? folded[sec.row.id] : saved
            out.push({ kind: "row", panel: sec.row, x: 0, y: cursor, w: 24, h: 1, collapsed: collapsed, count: sec.panels.length })
            cursor += 1
        }
        if (collapsed || sec.panels.length === 0) continue
        sec.panels = expandRepeats(sec.panels, vars)
        var minY = Infinity, maxY = 0
        for (var j = 0; j < sec.panels.length; j++) {
            var g = sec.panels[j].gridPos || {}
            minY = Math.min(minY, g.y || 0)
        }
        for (var q = 0; q < sec.panels.length; q++) {
            var gp = sec.panels[q].gridPos || { x: 0, y: 0, w: 12, h: 8 }
            var y = cursor + (gp.y || 0) - minY
            out.push({ kind: "panel", panel: sec.panels[q], x: gp.x || 0, y: y, w: gp.w || 12, h: gp.h || 8 })
            maxY = Math.max(maxY, y + (gp.h || 8))
        }
        cursor = maxY
    }
    return out
}

// A panel repeated over a multi-value variable becomes one copy per value,
// each with that variable pinned (`scopedVars`) and a key of its own;
// panels below it in the section move down by however much room the
// copies took.
function expandRepeats(panels, vars) {
    var out = []
    var shifts = []   // [{fromY, by}]
    for (var i = 0; i < panels.length; i++) {
        var p = panels[i]
        var g = p.gridPos || { x: 0, y: 0, w: 12, h: 8 }
        var shift = 0
        for (var s = 0; s < shifts.length; s++) if ((g.y || 0) >= shifts[s].fromY) shift += shifts[s].by
        var v = p.repeat && vars ? vars[p.repeat] : null
        var values = v ? [].concat(v.value) : []
        if (!v || values.length <= 1) {
            out.push(shift ? Object.assign({}, p, { gridPos: Object.assign({}, g, { y: (g.y || 0) + shift }) }) : p)
            continue
        }
        var horizontal = p.repeatDirection !== "v"
        var perRow = horizontal ? Math.min(values.length, p.maxPerRow || 4) : 1
        var w = horizontal ? Math.max(1, Math.floor(24 / perRow)) : (g.w || 12)
        var rows = Math.ceil(values.length / perRow)
        for (var k = 0; k < values.length; k++) {
            var scoped = {}
            scoped[p.repeat] = { value: values[k] }
            var copy = Object.assign({}, p, {
                id: p.id + "#" + k,
                scopedVars: Object.assign({}, p.scopedVars || {}, scoped),
                gridPos: {
                    x: horizontal ? (k % perRow) * w : (g.x || 0),
                    y: (g.y || 0) + shift + Math.floor(k / perRow) * (g.h || 8),
                    w: w, h: g.h || 8
                }
            })
            out.push(copy)
        }
        shifts.push({ fromY: (g.y || 0) + (g.h || 8), by: (rows - 1) * (g.h || 8) })
    }
    return out
}

// title with variables filled in, repeat values included
function panelTitle(panel, vars) {
    var scoped = panel.scopedVars ? Object.assign({}, vars || {}, panel.scopedVars) : (vars || {})
    return interpolate(panel.title || "", scoped, false)
}

// every non-row panel in a dashboard, nested ones included
function allPanels(dashboard) {
    var out = []
    function walk(list) {
        for (var i = 0; i < (list || []).length; i++) {
            if (list[i].type === "row") walk(list[i].panels)
            else out.push(list[i])
        }
    }
    walk(dashboard.panels)
    return out
}

function findPanel(dashboard, id) {
    var all = allPanels(dashboard)
    for (var i = 0; i < all.length; i++) if (all[i].id === id) return all[i]
    return null
}

var SUPPORTED = ["timeseries", "graph", "stat", "singlestat", "gauge", "bargauge", "table", "text", "piechart"]

function supported(type) {
    return SUPPORTED.indexOf(type) !== -1
}

// node: export for tools/check.mjs; QML ignores this
if (typeof module !== "undefined") module.exports = {
    parseTime: parseTime, resolveRange: resolveRange, rangeLabel: rangeLabel, intervalFor: intervalFor,
    variables: variables, findDatasource: findDatasource, interpolate: interpolate, buildQuery: buildQuery,
    seriesFromResults: seriesFromResults, resultErrors: resultErrors, reduce: reduce, fieldConfig: fieldConfig,
    seriesColor: seriesColor, namedColor: namedColor, format: format, formatParts: formatParts, niceTicks: niceTicks,
    timeTicks: timeTicks, timeLabel: timeLabel, tableFromResults: tableFromResults, layout: layout,
    allPanels: allPanels, findPanel: findPanel, supported: supported, displayName: displayName,
    varRequest: varRequest, varOptions: varOptions, selectionText: selectionText, panelTitle: panelTitle
}