17.2 KiB · text History 7a88816
import QtQuick
import QtQuick.Layouts
import org.kde.plasma.plasmoid
import org.kde.plasma.core as PlasmaCore
import org.kde.kirigami as Kirigami

import "Grafana.js" as G

PlasmoidItem {
    id: root

    switchWidth: Kirigami.Units.gridUnit * 24
    switchHeight: Kirigami.Units.gridUnit * 18

    // In a panel always show the pinned strip; on the desktop the
    // dashboard itself once there's room for it.
    preferredRepresentation: Plasmoid.formFactor === PlasmaCore.Types.Horizontal
                             || Plasmoid.formFactor === PlasmaCore.Types.Vertical
                             ? compactRepresentation : null

    Plasmoid.backgroundHints: PlasmaCore.Types.DefaultBackground | PlasmaCore.Types.ConfigurableBackground

    toolTipMainText: ""
    toolTipSubText: ""

    readonly property url logoSource: Qt.resolvedUrl("../icons/dashboards.svg")

    property string instanceUrl: Plasmoid.configuration.instanceUrl
    property string accessToken: Plasmoid.configuration.accessToken
    readonly property var dashboardList: parseJson(Plasmoid.configuration.dashboards, [])
    readonly property var pinnedList: parseJson(Plasmoid.configuration.pinned, [])
    readonly property string timeOverride: Plasmoid.configuration.timeRange
    readonly property int refreshOverride: Plasmoid.configuration.refreshSeconds
    readonly property int pinnedSeconds: Math.max(10, Plasmoid.configuration.pinnedSeconds)
    readonly property int rowHeight: Math.max(16, Plasmoid.configuration.rowHeight)
    readonly property bool pinnedLabels: Plasmoid.configuration.pinnedLabels
    readonly property var selections: parseJson(Plasmoid.configuration.selections, {})

    readonly property bool hasValidConfig: api.configured
    readonly property bool hasDashboards: dashboardList.length > 0

    // the dashboard on screen; falls back to the first configured one
    property string currentUid: ""
    readonly property string shownUid: {
        for (var i = 0; i < dashboardList.length; i++) if (dashboardList[i].uid === currentUid) return currentUid
        return dashboardList.length ? dashboardList[0].uid : ""
    }

    // a desktop widget showing its full representation is always "open"
    readonly property bool showing: expanded || (Plasmoid.formFactor !== PlasmaCore.Types.Horizontal
                                                 && Plasmoid.formFactor !== PlasmaCore.Types.Vertical)

    // ── server state ────────────────────────────────────────────────────
    property var settings: null      // /api/frontend/settings
    property var dashboards: ({})    // uid -> {dashboard, meta, at}
    property var varOptions: ({})    // uid -> {name -> [{text, value}]}
    property var results: ({})       // "uid/panelId" -> {results, error, at, range, interval}
    property var ranges: ({})        // uid -> {from, to} of the last refresh
    property var loadingPanels: ({}) // "uid/panelId" -> true
    property int dataRev: 0
    property int dashRev: 0
    property var folded: ({})        // uid -> {rowId -> bool}
    property string lastError: ""
    property bool offline: false

    GrafanaAPI {
        id: grafanaApi
        baseUrl: root.instanceUrl
        accessToken: root.accessToken
    }
    property alias api: grafanaApi

    function parseJson(text, fallback) {
        try {
            var v = JSON.parse(text)
            return v === null || v === undefined ? fallback : v
        } catch (e) {
            return fallback
        }
    }

    Component.onCompleted: {
        currentUid = Plasmoid.configuration.currentDashboard
        Qt.callLater(start)
    }

    onInstanceUrlChanged: resetAndStart()
    onAccessTokenChanged: resetAndStart()
    onDashboardListChanged: Qt.callLater(start)
    onPinnedListChanged: Qt.callLater(refreshPinned)
    onTimeOverrideChanged: Qt.callLater(refreshShown)
    onShownUidChanged: {
        if (shownUid && shownUid !== Plasmoid.configuration.currentDashboard) Plasmoid.configuration.currentDashboard = shownUid
        Qt.callLater(refreshShown)
    }
    onShowingChanged: if (showing) Qt.callLater(refreshShown)

    function resetAndStart() {
        api.abortAll()
        settings = null
        dashboards = {}
        varOptions = {}
        results = {}
        ranges = {}
        loadingPanels = {}
        dataRev++
        dashRev++
        Qt.callLater(start)
    }

    function start() {
        if (!hasValidConfig) return
        ensureSettings(function() {
            refreshPinned()
            refreshShown()
        })
    }

    function setKey(obj, key, value) {
        var copy = Object.assign({}, obj)
        if (value === undefined) delete copy[key]
        else copy[key] = value
        return copy
    }

    // ── loading ─────────────────────────────────────────────────────────

    function ensureSettings(cb) {
        if (settings) { cb(); return }
        api.frontendSettings(function(ok, data, network) {
            offline = !ok && network
            if (!ok) { lastError = data; return }
            settings = data
            lastError = ""
            cb()
        })
    }

    // Fetches a dashboard (unless it's fresh), resolves library panels, then
    // evaluates its query variables in order, each seeing the ones before.
    function loadDashboard(uid, force, cb) {
        var cached = dashboards[uid]
        if (cached && !force && Date.now() - cached.at < 600000) { cb(true); return }
        api.dashboard(uid, function(ok, data, network) {
            offline = !ok && network
            if (!ok) { lastError = uid + ": " + data; cb(false); return }
            resolveLibraryPanels(data.dashboard, function() {
                dashboards = setKey(dashboards, uid, { dashboard: data.dashboard, meta: data.meta, at: Date.now() })
                loadVariables(uid, function() {
                    dashRev++
                    cb(true)
                })
            })
        })
    }

    function resolveLibraryPanels(dashboard, cb) {
        var refs = []
        function walk(list) {
            for (var i = 0; i < (list || []).length; i++) {
                if (list[i].libraryPanel && list[i].libraryPanel.uid && !list[i].targets) refs.push(list[i])
                walk(list[i].panels)
            }
        }
        walk(dashboard.panels)
        if (refs.length === 0) { cb(); return }
        var pending = refs.length
        refs.forEach(function(p) {
            api.libraryPanel(p.libraryPanel.uid, function(ok, data) {
                if (ok && data && data.result && data.result.model) {
                    var model = data.result.model
                    for (var k in model) if (k !== "gridPos" && k !== "id") p[k] = model[k]
                }
                if (--pending === 0) cb()
            })
        })
    }

    function loadVariables(uid, cb) {
        var dash = dashboards[uid].dashboard
        var list = (dash.templating && dash.templating.list) || []
        var fetched = {}
        var range = rangeFor(uid)
        var i = 0
        function next() {
            if (i >= list.length) {
                varOptions = setKey(varOptions, uid, fetched)
                cb()
                return
            }
            var v = list[i++]
            var vars = G.variables(dash, settings.datasources, selections[uid], fetched)
            var req = G.varRequest(v, vars, settings.datasources, range)
            if (!req) { next(); return }
            api.get(req.path, function(ok, data) {
                if (ok) fetched[v.name] = G.varOptions(v, req, data)
                next()
            })
        }
        next()
    }

    function variablesFor(uid) {
        var entry = dashboards[uid]
        if (!entry || !settings) return {}
        return G.variables(entry.dashboard, settings.datasources, selections[uid], (varOptions[uid] || {}))
    }

    function rangeSpec(uid) {
        if (timeOverride) return { from: timeOverride, to: "now" }
        var entry = dashboards[uid]
        return entry && entry.dashboard.time ? entry.dashboard.time : { from: "now-6h", to: "now" }
    }

    function rangeFor(uid) {
        return G.resolveRange(rangeSpec(uid), Date.now())
    }

    function refreshIntervalFor(uid) {
        if (refreshOverride > 0) return Math.max(10, refreshOverride) * 1000
        var entry = dashboards[uid]
        var r = entry && entry.dashboard.refresh
        var ms = 0
        var m = /^(\d+)([smhd])$/.exec(r || "")
        if (m) ms = parseInt(m[1]) * { s: 1000, m: 60000, h: 3600000, d: 86400000 }[m[2]]
        return Math.max(10000, ms || 300000)
    }

    // ── querying ────────────────────────────────────────────────────────

    function panelKey(uid, panelId) { return uid + "/" + panelId }

    // Every visible panel of a dashboard, against one shared time range so
    // they line up. Folded rows stay unqueried until opened.
    function refreshDashboard(uid, force, onlyMissing) {
        if (!uid || !hasValidConfig) return
        ensureSettings(function() {
            loadDashboard(uid, force, function(ok) {
                if (!ok) return
                var dash = dashboards[uid].dashboard
                var vars = variablesFor(uid)
                var range = onlyMissing && ranges[uid] ? ranges[uid] : rangeFor(uid)
                ranges = setKey(ranges, uid, range)
                var items = G.layout(dash, folded[uid] || {}, vars)
                for (var i = 0; i < items.length; i++) {
                    if (items[i].kind !== "panel") continue
                    var p = items[i].panel
                    if (onlyMissing && results[panelKey(uid, p.id)]) continue
                    queryPanel(uid, p, vars, range, Math.max(100, Math.min(1000, Math.round(items[i].w * 40))))
                }
            })
        })
    }

    function refreshShown() {
        if (showing && shownUid) refreshDashboard(shownUid, false, false)
    }

    function queryPanel(uid, panel, vars, range, maxDataPoints) {
        var key = panelKey(uid, panel.id)
        if (panel.type === "text" || panel.type === "row") return
        if (!G.supported(panel.type)) return
        var q = G.buildQuery(panel, dashboards[uid].dashboard, vars, settings.datasources, range, maxDataPoints)
        if (q.error) {
            setResult(key, { error: q.error, at: Date.now(), range: range })
            return
        }
        loadingPanels = setKey(loadingPanels, key, true)
        api.query(q.body, function(ok, data, network) {
            loadingPanels = setKey(loadingPanels, key, undefined)
            if (!ok) {
                offline = network
                var prev = results[key]
                // keep the last picture, say why it's stale
                setResult(key, { results: prev ? prev.results : null, error: data, at: Date.now(), range: prev ? prev.range : range })
                return
            }
            offline = false
            var errs = G.resultErrors(data.results)
            setResult(key, { results: data.results, error: errs.join("; "), at: Date.now(), range: range, interval: q.interval })
        })
    }

    function setResult(key, value) {
        results[key] = value
        dataRev++
    }

    function resultFor(uid, panelId) {
        return results[panelKey(uid, panelId)] || null
    }

    function isLoading(uid, panelId) {
        return !!loadingPanels[panelKey(uid, panelId)]
    }

    readonly property bool anyLoading: Object.keys(loadingPanels).length > 0

    // ── pinned ──────────────────────────────────────────────────────────

    function refreshPinned() {
        if (!hasValidConfig || pinnedList.length === 0) return
        ensureSettings(function() {
            var uids = {}
            for (var i = 0; i < pinnedList.length; i++) uids[pinnedList[i].uid] = true
            Object.keys(uids).forEach(function(uid) {
                loadDashboard(uid, false, function(ok) {
                    if (!ok) return
                    var dash = dashboards[uid].dashboard
                    var vars = variablesFor(uid)
                    var range = rangeFor(uid)
                    for (var j = 0; j < pinnedList.length; j++) {
                        if (pinnedList[j].uid !== uid) continue
                        var p = G.findPanel(dash, pinnedList[j].panelId)
                        if (p) queryPanel(uid, p, vars, range, 100)
                    }
                })
            })
        })
    }

    // What a pinned panel shows: its first series reduced the way the panel
    // reduces it, formatted with its unit, in its threshold colour.
    function pinnedValue(pin) {
        var entry = dashboards[pin.uid]
        var res = resultFor(pin.uid, pin.panelId)
        var out = { text: "…", color: "", series: [], error: res ? res.error : "", title: pin.title }
        if (!entry || !res) return out
        var panel = G.findPanel(entry.dashboard, pin.panelId)
        if (!panel) { out.text = "?"; out.error = "panel no longer exists"; return out }
        out.title = G.panelTitle(panel, variablesFor(pin.uid))
        var series = G.seriesFromResults(res.results || {}, panel)
        var calc = ((panel.options && panel.options.reduceOptions && panel.options.reduceOptions.calcs) || ["lastNotNull"])[0]
        for (var i = 0; i < series.length; i++) {
            var cfg = G.fieldConfig(panel, series[i])
            var v = G.reduce(series[i].values, calc)
            out.series.push({ name: G.displayName(series[i], cfg), text: G.format(v, cfg.unit, cfg.decimals),
                              color: G.seriesColor(cfg, i, v, { min: cfg.min, max: cfg.max }, "") })
        }
        if (out.series.length === 0) { out.text = res.error ? "!" : "–"; return out }
        out.text = out.series[0].text
        out.color = out.series[0].color
        return out
    }

    // ── refreshing ──────────────────────────────────────────────────────

    Timer {
        interval: root.refreshIntervalFor(root.shownUid)
        running: root.showing && root.hasValidConfig && root.shownUid !== ""
        repeat: true
        onTriggered: root.refreshDashboard(root.shownUid, false, false)
    }

    Timer {
        interval: root.pinnedSeconds * 1000
        running: root.hasValidConfig && root.pinnedList.length > 0
        repeat: true
        onTriggered: root.refreshPinned()
    }

    // Offline (just woke, Wi-Fi not back): retry soon rather than sitting
    // on an error for a whole interval.
    Timer {
        interval: 10000
        running: root.offline && root.hasValidConfig
        repeat: true
        onTriggered: { root.settings ? (root.refreshPinned(), root.refreshShown()) : root.start() }
    }

    // Wall-clock watchdog: timers don't fire during suspend, so a tick far
    // later than scheduled means the machine slept and anything in flight
    // is suspect.
    Timer {
        property double lastTick: Date.now()
        interval: 5000
        running: true
        repeat: true
        onTriggered: {
            var now = Date.now()
            var gap = now - lastTick
            lastTick = now
            if (gap > interval * 6) {
                console.log("Grafana: resumed after", Math.round(gap / 1000), "s")
                root.api.abortAll()
                root.loadingPanels = {}
                root.refreshPinned()
                root.refreshShown()
            }
        }
    }

    // ── view actions ────────────────────────────────────────────────────

    function selectDashboard(uid) {
        currentUid = uid
    }

    function toggleRow(uid, rowId, collapsedNow) {
        var f = Object.assign({}, folded[uid] || {})
        f[rowId] = !collapsedNow
        folded = setKey(folded, uid, f)
        dashRev++
        if (collapsedNow) refreshDashboard(uid, false, true)
    }

    function setVariable(uid, name, value) {
        var all = Object.assign({}, selections)
        var mine = Object.assign({}, all[uid] || {})
        mine[name] = value
        all[uid] = mine
        Plasmoid.configuration.selections = JSON.stringify(all)
        // a variable can feed the next one's query, so re-evaluate them all
        if (dashboards[uid]) {
            loadVariables(uid, function() {
                dashRev++
                refreshDashboard(uid, false, false)
            })
        }
    }

    function openAll() {
        root.expanded = !root.expanded
    }

    function openDashboard(uid) {
        if (root.expanded && shownUid === uid) {
            root.expanded = false
        } else {
            currentUid = uid
            root.expanded = true
        }
    }

    // ── representations ─────────────────────────────────────────────────

    compactRepresentation: CompactStrip {
        store: root
    }

    fullRepresentation: FullView {
        store: root
    }
}