From 2a0020ca918d95fa5b0c85e9d71315adc850fdf9 Mon Sep 17 00:00:00 2001 From: enne2 Date: Sun, 25 Oct 2026 16:12:52 +0100 Subject: [PATCH] Strata dashboard plasmoid v1.2: barre a soglie e velocita decode/prefill - card GPU con barre progressive: VRAM (informativa), utilizzo 60/85 %, temperatura 75/85 C, potenza su budget 350 W (ambra 70 %, rosso 90 %) - velocita del motore: DEC (generazione) e PRE (prefill) in tok/s, live durante la generazione, ultima richiesta da fermo (badge LIVE/ULTIMA); semantica inversa: verde sopra la nominale, ambra fra 50 % e nominale - card servizio + controllo unificata con switch load/unload nativo e stato che si chiude sulla fase osservata - MetricBar condiviso con levelColor()/speedColor() e soglie in testa al QML - README: requisiti, campi del control plane, tabella soglie, limiti del box - avatar pixel-art 512x512 in assets/repository-avatar.png --- .gitignore | 6 + LICENSE | 21 ++ README.md | 89 ++++++ assets/repository-avatar.png | Bin 0 -> 9659 bytes contents/scripts/action.sh | 8 + contents/scripts/status.sh | 7 + contents/ui/main.qml | 581 +++++++++++++++++++++++++++++++++++ metadata.json | 18 ++ 8 files changed, 730 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 assets/repository-avatar.png create mode 100755 contents/scripts/action.sh create mode 100755 contents/scripts/status.sh create mode 100644 contents/ui/main.qml create mode 100644 metadata.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b4cc226 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +# backup locali del QML (non versionati) +contents/ui/*.bak-* +*.bak-* + +# output locali +outputs/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..35c73b3 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 enne2 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..7e238fb --- /dev/null +++ b/README.md @@ -0,0 +1,89 @@ +# KDE Strata Dashboard Plasmoid + +Plasmoide per **KDE Plasma 6** che mostra lo stato del servizio di inferenza **Strata** +(Qwen3.8-Flash-Next IQ2_XS servito su NVIDIA L40S) leggendo il control plane HTTP del +servizio. Non parla mai direttamente con il motore: lo `8082` di Strata ascolta solo su +`127.0.0.1` della macchina che lo ospita, quindi tutte le letture passano dal control plane. + +## Cosa mostra + +- **fase** del servizio con badge colorato: caricato / generazione / caricamento / + scaricato / fermo / non raggiungibile; +- **VRAM** usata e totale (barra indaco, informativa: il modello occupa quasi tutta la GPU); +- **utilizzo, temperatura e potenza** della GPU come **barre progressive** che si riempiono + in proporzione al valore e cambiano colore alle soglie; +- **velocità del motore**: `DEC` (generazione, tok/s) e `PRE` (prefill, tok/s), live durante + la generazione e riferite all'ultima richiesta quando il motore è fermo (badge + `LIVE` / `ULTIMA`); +- **modello**: alias, tipo di KV, contesto, concorrenza, disponibilità vision; +- **switch carica/scarica** nativo (`POST /load`, `POST /unload?wait=0`) con stato + `PENDING` che si chiude sulla fase osservata, non su un timer. + +## Requisiti + +- KDE Plasma 6; +- `curl` e `bash`; +- chiave del control plane in `~/.config/strata-control/control.key` (0600); +- control plane raggiungibile su `STRATACTL_API` (default `http://172.30.150.224:18090`). + +## Installazione + +```bash +kpackagetool6 -t Plasma/Applet -i . +``` + +Per aggiornare un'installazione esistente: + +```bash +kpackagetool6 -t Plasma/Applet -u . +``` + +Aggiungi poi **Strata** al desktop o a un pannello dalle impostazioni dei widget Plasma. + +## Backend + +- `contents/scripts/status.sh`: `GET $STRATACTL_API/status` con header `X-API-Key`, stampa il + JSON su stdout (il widget lo legge con `P5Support.DataSource`, intervallo 4 s). +- `contents/scripts/action.sh load|unload`: `POST` in background con `setsid` ed exit + immediato, così il polling del widget non resta bloccato durante le operazioni lunghe. + +Campi usati dal widget: + +| Campo di `/status` | Uso | +| --- | --- | +| `phase`, `service.*` | badge di fase, tooltip | +| `model.live.tok_s`, `model.live.prefill_tok_s_mean` | barre DEC/PRE live | +| `model.last_timings.predicted_per_second`, `.prompt_per_second` | DEC/PRE dell'ultima richiesta | +| `model.alias`, `kv`, `ctx`, `concurrency`, `vision` | card servizio | +| `gpu.mem_used_mib/mem_total_mib/mem_pct`, `util_pct`, `temp_c`, `power_w`, `name` | card GPU | + +Il control plane deve inoltrare `model.live` e `model.last_timings`: la versione del +2026-10-25 legge `live` da `/metrics` (cache 3 s) e passa `last_timings` da `/v1/status`. +Senza quei campi le barre DEC/PRE restano grigie. + +## Soglie e scale + +Tutte in testa a `contents/ui/main.qml`, facili da ritoccare: + +| Grandezza | Fondo scala | Ambra | Rosso | +| --- | --- | --- | --- | +| Utilizzo GPU | 100 % | 60 % | 85 % | +| Temperatura GPU | 100 °C | 75 °C | 85 °C | +| Potenza GPU | 350 W (TDP L40S) | 70 % del budget | 90 % del budget | +| Decode (generazione) | 120 tok/s | < 40 tok/s (nominale) | < 20 tok/s | +| Prefill | 5000 tok/s | < 3000 tok/s (nominale) | < 1500 tok/s | + +Per le due velocità la semantica è **inversa** rispetto ai carichi: alto è bene, quindi +verde sopra la nominale, ambra fra il 50 % e la nominale, rosso sotto. Grigio = valore non +disponibile (motore scarico, sensore assente). + +## Note + +- Avatar del repository in `assets/repository-avatar.png`, immagine pixel-art generata con + Antigravity (modello immagine Gemini). +- Il widget occupa un box desktop da 324x308 pixel logici: card e spaziature sono tarate su + quella altezza. Se il containment lo rimpicciolisce il footer `SYNC` viene tagliato. + +## Licenza + +MIT. Vedi [LICENSE](LICENSE). diff --git a/assets/repository-avatar.png b/assets/repository-avatar.png new file mode 100644 index 0000000000000000000000000000000000000000..2096ee6ba74186bd82d80c96f8e89bfdc307a5db GIT binary patch literal 9659 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 [uniq] — POST in background, esce subito. +API="${STRATACTL_API:-http://172.30.150.224:18090}" +KEYF="$HOME/.config/strata-control/control.key" +[ $# -ge 1 ] || exit 1 +[ -r "$KEYF" ] || exit 1 +setsid curl -s -m 320 -X POST -H "X-API-Key: $(cat "$KEYF")" "$API/$1" >/dev/null 2>&1 < /dev/null & +exit 0 diff --git a/contents/scripts/status.sh b/contents/scripts/status.sh new file mode 100755 index 0000000..22091cc --- /dev/null +++ b/contents/scripts/status.sh @@ -0,0 +1,7 @@ +#!/bin/bash +# Stato del control plane Strata (JSON su stdout). +API="${STRATACTL_API:-http://172.30.150.224:18090}" +KEYF="$HOME/.config/strata-control/control.key" +if [ ! -r "$KEYF" ]; then printf '{"error":"chiave mancante"}'; exit 0; fi +out=$(curl -s -m 6 -H "X-API-Key: $(cat "$KEYF")" "$API/status" 2>/dev/null) +if [ -z "$out" ]; then printf '{"error":"control plane non raggiungibile"}'; else printf '%s' "$out"; fi diff --git a/contents/ui/main.qml b/contents/ui/main.qml new file mode 100644 index 0000000..d209a1f --- /dev/null +++ b/contents/ui/main.qml @@ -0,0 +1,581 @@ +/* Strata (qwen3.8-flash-next) — stato e controllo del servizio di inferenza su seonse-engine-02. + * + * Adattato da llama-gsq3: ora parla con il control plane di Strata, che espone + * load/unload nativi (/load, /unload?wait=0). Strata serve UNA richiesta alla volta: + * la concorrenza e' solo informativa, non c'e' piu' il selettore di slot. + * Stile invariato rispetto agli altri plasmoidi enne2. + */ +import QtQuick +import QtQuick.Layouts +import QtQuick.Controls as QQC2 +import org.kde.plasma.plasmoid +import org.kde.plasma.core as PlasmaCore +import org.kde.plasma.components 3.0 as PC3 +import org.kde.plasma.plasma5support as Plasma5Support +import org.kde.kirigami as Kirigami + +PlasmoidItem { + id: root + + Plasmoid.backgroundHints: PlasmaCore.Types.DefaultBackground + preferredRepresentation: fullRepresentation + switchWidth: 150 + switchHeight: 150 + + readonly property color themeGreen: "#4ade80" + readonly property color themeIndigo: "#818cf8" + readonly property color themeViolet: "#a855f7" + + // ---------- palette dei livelli ---------- + readonly property color okColor: "#4ade80" // sotto la soglia di attenzione + readonly property color warnColor: "#fbbf24" // attenzione + readonly property color critColor: "#f87171" // critico + readonly property color naColor: "#5b6b7d" // valore non disponibile + + // ---------- soglie GPU (ritocca qui) ---------- + // Le barre PWR si riempiono sul budget: ambra oltre il 70 %, rosso oltre il 90 %. + // NVIDIA L40S: TDP 350 W, 48 GB; la VRAM resta informativa (colore accento). + readonly property real gpuPowerMax: 350 + readonly property real utilWarn: 60 + readonly property real utilCrit: 85 + readonly property real tempWarn: 75 + readonly property real tempCrit: 85 + readonly property real powerWarnRatio: 0.70 + readonly property real powerCritRatio: 0.90 + + // ---------- velocità di generazione e prefill (ritocca qui) ---------- + // Nominali misurate sul L40S con MTP (2026-10-23): decode ~40 tok/s (picchi ~69), + // prefill ~2800-3800 tok/s. Fondo scala = massimo ragionevole. + readonly property real decodeNominal: 40 + readonly property real decodeMax: 120 + readonly property real prefillNominal: 3000 + readonly property real prefillMax: 5000 + + readonly property string scripts: Qt.resolvedUrl("../scripts/").toString().replace(/^file:\/\//, "") + readonly property string pollSource: "/bin/bash " + root.scripts + "status.sh" + + property var st: ({}) + property string phase: "unknown" + property string errorMsg: "" + property string lastUpdate: "" + property bool pending: false + property string pendingTarget: "" + property int pendingTries: 0 + property int counter: 0 + property string actSource: "" + + readonly property var modelInfo: root.st.model || ({}) + readonly property var gpuInfo: root.st.gpu || ({}) + readonly property var liveInfo: root.modelInfo.live || ({}) + readonly property var lastTimings: root.modelInfo.last_timings || ({}) + readonly property bool speedsLive: typeof root.liveInfo.tok_s === "number" + // durante la generazione valgono i valori live, da idle quelli dell'ultima richiesta + readonly property real decodeTokS: (typeof root.liveInfo.tok_s === "number") + ? root.liveInfo.tok_s : root.metric(root.lastTimings.predicted_per_second) + readonly property real prefillTokS: (typeof root.liveInfo.prefill_tok_s_mean === "number") + ? root.liveInfo.prefill_tok_s_mean : root.metric(root.lastTimings.prompt_per_second) + readonly property var concInfo: root.modelInfo.concurrency || ({}) + readonly property bool loaded: root.modelInfo.loaded === true + readonly property bool running: root.pending ? (root.pendingTarget === "load") + : (root.phase === "loaded" || root.phase === "generating" || root.phase === "loading") + + Plasmoid.busy: root.pending + toolTipMainText: "Strata — " + root.phaseWord() + toolTipSubText: { + const m = root.modelInfo, g = root.gpuInfo + if (root.errorMsg.length > 0) return root.errorMsg + return "fase: " + root.phase + + "\nmodello: " + (m.alias || "—") + (m.kv ? " (" + m.kv + ")" : "") + + "\ncontesto: " + root.ctxLabel() + " per richiesta" + + "\nconcorrenza: " + (root.concInfo.serving || 1) + " · vision: " + (m.vision ? "attiva" : "non disponibile") + + "\nVRAM: " + (g.mem_used_mib / 1024).toFixed(1) + " / " + (g.mem_total_mib / 1024).toFixed(1) + " GiB" + + "\nutil " + (g.util_pct || 0).toFixed(0) + "% · " + (g.temp_c || 0).toFixed(0) + " °C · " + (g.power_w || 0).toFixed(0) + " W" + + "\ndecode " + root.fmt(root.decodeTokS, 1, " tok/s") + " · prefill " + root.fmt(root.prefillTokS, 0, " tok/s") + + (root.speedsLive ? " (live)" : " (ultima richiesta)") + } + + function phaseAccent() { + switch (root.phase) { + case "loaded": return root.themeGreen + case "generating": return root.themeIndigo + case "loading": return root.themeViolet + case "unreachable": return Kirigami.Theme.negativeTextColor + default: return Kirigami.Theme.disabledTextColor + } + } + function phaseWord() { + if (root.pending) return root.pendingTarget === "load" ? "CARICAMENTO" : "SCARICAMENTO" + switch (root.phase) { + case "loaded": return "IN MEMORIA" + case "generating": return "GENERAZIONE" + case "loading": return "CARICAMENTO" + case "unloaded": return "SCARICATO" + case "stopped": return "SERVIZIO FERMO" + case "unreachable": return "NON RAGGIUNGIBILE" + default: return "?" + } + } + function phaseBadge() { + if (root.pending) return "ATTENDI" + switch (root.phase) { + case "loaded": return "IDLE" + case "generating": return "GEN" + case "loading": return "LOAD" + case "unloaded": return "OFF" + case "stopped": return "STOP" + default: return "ERR" + } + } + function stateLine() { + const m = root.modelInfo + if (root.phase === "generating" && m.tok_s) return "in generazione · " + Number(m.tok_s).toFixed(1) + " t/s" + switch (root.phase) { + case "loaded": return "caricato, in attesa" + case "loading": return "caricamento in corso…" + case "unloaded": return "scaricato · VRAM libera" + case "stopped": return "container fermo" + default: return root.errorMsg.length > 0 ? root.errorMsg : "stato sconosciuto" + } + } + function ctxLabel() { + const c = root.modelInfo.ctx + if (!c) return "—" + return c >= 1024 ? Math.round(c / 1024) + "k" : String(c) + } + function gib(v) { return (typeof v === "number") ? (v / 1024).toFixed(1) : "—" } + function num(v, d) { return (typeof v === "number") ? v : (d || 0) } + // valore numerico o NaN (per le barre: NaN -> grigio e barra vuota) + function metric(v) { return (typeof v === "number") ? v : NaN } + function fmt(v, d, suffix) { return (typeof v === "number") ? v.toFixed(d === undefined ? 0 : d) + (suffix || "") : "n/d" } + + // Colore semaforico di una barra: NaN -> grigio, poi verde/ambra/rosso. + function levelColor(value, warnAt, critAt) { + if (isNaN(value)) return root.naColor + if (value >= critAt) return root.critColor + if (value >= warnAt) return root.warnColor + return root.okColor + } + // Frazione 0..1 di riempimento. + function fillRatio(value, scaleMax) { + if (isNaN(value) || !(scaleMax > 0)) return 0 + return Math.max(0, Math.min(1, value / scaleMax)) + } + // Per le velocità la semantica è inversa a quella dei carichi: alto = bene. + function speedColor(value, nominal) { + if (isNaN(value) || value <= 0) return root.naColor + if (value >= nominal) return root.okColor + if (value >= nominal * 0.5) return root.warnColor + return root.critColor + } + + // ---------- polling ---------- + Plasma5Support.DataSource { + id: poll + engine: "executable" + interval: 4000 + connectedSources: [root.pollSource] + onNewData: function (source, data) { + const out = ((data && data["stdout"]) || "").trim() + if (out.length < 2) { root.errorMsg = "nessuna risposta dal control plane"; root.phase = "unreachable"; return } + let o + try { o = JSON.parse(out) } catch (e) { root.errorMsg = "risposta non valida"; return } + if (o.error) { root.errorMsg = o.error; root.phase = "unreachable"; return } + root.errorMsg = "" + root.st = o + root.phase = o.phase || "unknown" + root.lastUpdate = new Date().toLocaleTimeString(Qt.locale(), "HH:mm:ss") + root.checkPending() + } + } + + // ---------- azioni ---------- + Plasma5Support.DataSource { + id: act + engine: "executable" + connectedSources: [] + onNewData: function (source, data) { if (source === root.actSource) root.clearAction() } + } + function clearAction() { + if (root.actSource.length > 0) { try { act.disconnectSource(root.actSource) } catch (e) {} } + root.actSource = "" + } + function runAction(cmd, target) { + root.counter++ + root.pending = true + root.pendingTarget = target + root.pendingTries = 0 + root.actSource = "/bin/bash " + root.scripts + "action.sh " + cmd + " " + root.counter + act.connectSource(root.actSource) + pendingTimer.restart() + } + // criterio autorevole: la fase osservata, non il flag locale + function checkPending() { + if (!root.pending) return + const done = (root.pendingTarget === "load" && + (root.phase === "loaded" || root.phase === "generating")) + || (root.pendingTarget === "unload" && + (root.phase === "unloaded" || root.phase === "stopped")) + if (done) { root.pending = false; pendingTimer.stop() } + } + Timer { + id: pendingTimer + interval: 1000 + repeat: true + onTriggered: { + root.pendingTries++ + if (root.pendingTries > 240) { // 240 s: il load da freddo puo' durare ~160 s + root.pending = false + root.errorMsg = "operazione non confermata (timeout)" + stop() + } + } + } + + // Barra progressiva con colore a soglie: si riempie su value/scaleMax, + // colora verde -> ambra -> rosso in base a warnAt/critAt. + component MetricBar: RowLayout { + id: bar + property string label + property real value: NaN + property real scaleMax: 100 + property real warnAt: 60 + property real critAt: 85 + property string display + // opzionale: metriche dove il valore alto è positivo (velocità) + property bool higherIsBetter: false + property real nominal: 1 + + readonly property real ratio: root.fillRatio(bar.value, bar.scaleMax) + readonly property color tint: bar.higherIsBetter + ? root.speedColor(bar.value, bar.nominal) + : root.levelColor(bar.value, bar.warnAt, bar.critAt) + + Layout.fillWidth: true + spacing: 5 + + PC3.Label { + text: bar.label + color: Kirigami.Theme.disabledTextColor + font.pointSize: Kirigami.Theme.defaultFont.pointSize - 4 + Layout.preferredWidth: 28 + Layout.alignment: Qt.AlignVCenter + } + + Item { + Layout.fillWidth: true + Layout.preferredHeight: 5 + Layout.alignment: Qt.AlignVCenter + + Rectangle { + anchors.fill: parent + radius: 2.5 + color: Qt.rgba(1, 1, 1, 0.12) + + Rectangle { + width: parent.width * bar.ratio + height: parent.height + radius: 2.5 + color: bar.tint + Behavior on width { NumberAnimation { duration: 300; easing.type: Easing.OutCubic } } + Behavior on color { ColorAnimation { duration: 300 } } + } + } + } + + PC3.Label { + text: bar.display + color: isNaN(bar.value) ? root.naColor : bar.tint + font.bold: true + font.pointSize: Kirigami.Theme.defaultFont.pointSize - 3 + horizontalAlignment: Text.AlignRight + Layout.preferredWidth: 52 + Layout.alignment: Qt.AlignVCenter + } + } + + compactRepresentation: Component { + Item { + implicitWidth: Kirigami.Units.iconSizes.smallMedium + implicitHeight: Kirigami.Units.iconSizes.smallMedium + Kirigami.Icon { + anchors.fill: parent + source: "utilities-system-monitor" + color: root.phaseAccent() + } + MouseArea { anchors.fill: parent; onClicked: Plasmoid.expanded = !Plasmoid.expanded } + } + } + + fullRepresentation: Component { + Item { + implicitWidth: 256 + implicitHeight: 248 + Layout.preferredWidth: 256 + Layout.preferredHeight: 248 + + ColumnLayout { + anchors.top: parent.top + anchors.bottom: parent.bottom + anchors.horizontalCenter: parent.horizontalCenter + width: Math.min(parent.width - 16, 300) + spacing: 2 + + // header + RowLayout { + Layout.fillWidth: true + spacing: 6 + Kirigami.Icon { + source: "utilities-system-monitor" + color: root.phaseAccent() + Layout.preferredWidth: 20 + Layout.preferredHeight: 20 + } + RowLayout { + spacing: 6 + Layout.fillWidth: true + PC3.Label { + text: "STRATA" + font.bold: true + font.pointSize: Kirigami.Theme.defaultFont.pointSize + 1 + } + PC3.Label { + text: root.phaseWord() + color: root.phaseAccent() + font.bold: true + font.pointSize: Kirigami.Theme.defaultFont.pointSize - 3 + } + Item { Layout.fillWidth: true } + } + Item { Layout.fillWidth: true } + Rectangle { + Layout.preferredWidth: 8 + Layout.preferredHeight: 8 + radius: 4 + color: root.phaseAccent() + } + PC3.Button { + icon.name: "view-refresh" + display: QQC2.AbstractButton.IconOnly + enabled: !root.pending + onClicked: { try { poll.disconnectSource(root.pollSource); poll.connectSource(root.pollSource) } catch (e) {} } + } + } + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 1 + color: Qt.rgba(root.phaseAccent().r, root.phaseAccent().g, root.phaseAccent().b, 0.35) + } + + PC3.Label { + visible: root.errorMsg.length > 0 + Layout.fillWidth: true + wrapMode: Text.Wrap + maximumLineCount: 2 + elide: Text.ElideRight + color: Kirigami.Theme.neutralTextColor + font.pointSize: Kirigami.Theme.defaultFont.pointSize - 3 + text: root.errorMsg + } + + // card GPU + Rectangle { + Layout.fillWidth: true + implicitHeight: gpuBody.implicitHeight + 10 + radius: 5 + color: Qt.rgba(0, 0, 0, 0.28) + border.width: 1 + border.color: Qt.rgba(root.themeIndigo.r, root.themeIndigo.g, root.themeIndigo.b, 0.65) + + ColumnLayout { + id: gpuBody + anchors.fill: parent + anchors.margins: 4 + spacing: 3 + RowLayout { + Layout.fillWidth: true + PC3.Label { + text: "GPU" + color: Kirigami.Theme.disabledTextColor + font.pointSize: Kirigami.Theme.defaultFont.pointSize - 3 + } + PC3.Label { + text: String(root.gpuInfo.name || "").replace("NVIDIA ", "") + color: Kirigami.Theme.disabledTextColor + font.pointSize: Kirigami.Theme.defaultFont.pointSize - 3 + } + PC3.Label { + text: root.speedsLive ? "LIVE" : "ULTIMA" + color: root.speedsLive ? root.themeGreen : Kirigami.Theme.disabledTextColor + font.bold: true + font.pointSize: Kirigami.Theme.defaultFont.pointSize - 4 + } + Item { Layout.fillWidth: true } + PC3.Label { + text: root.gib(root.gpuInfo.mem_used_mib) + " / " + root.gib(root.gpuInfo.mem_total_mib) + " GiB" + font.bold: true + font.pointSize: Kirigami.Theme.defaultFont.pointSize - 2 + } + } + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 6 + radius: 3 + color: Qt.rgba(1, 1, 1, 0.12) + Rectangle { + width: parent.width * Math.min(1, Math.max(0, root.num(root.gpuInfo.mem_pct, 0) / 100)) + height: parent.height + radius: 3 + color: root.themeIndigo + Behavior on width { NumberAnimation { duration: 250; easing.type: Easing.OutCubic } } + } + } + MetricBar { + label: "UTIL" + value: root.metric(root.gpuInfo.util_pct) + scaleMax: 100 + warnAt: root.utilWarn + critAt: root.utilCrit + display: root.fmt(root.gpuInfo.util_pct, 0, "%") + } + MetricBar { + label: "TEMP" + value: root.metric(root.gpuInfo.temp_c) + scaleMax: 100 + warnAt: root.tempWarn + critAt: root.tempCrit + display: root.fmt(root.gpuInfo.temp_c, 0, " °C") + } + MetricBar { + label: "PWR" + value: root.metric(root.gpuInfo.power_w) + scaleMax: root.gpuPowerMax + warnAt: root.gpuPowerMax * root.powerWarnRatio + critAt: root.gpuPowerMax * root.powerCritRatio + display: root.fmt(root.gpuInfo.power_w, 0, " W") + } + + // velocità del motore: decode (generazione) e prefill, tok/s + MetricBar { + label: "DEC" + value: root.decodeTokS + scaleMax: root.decodeMax + nominal: root.decodeNominal + higherIsBetter: true + display: root.fmt(root.decodeTokS, 1, " t/s") + } + MetricBar { + label: "PRE" + value: root.prefillTokS + scaleMax: root.prefillMax + nominal: root.prefillNominal + higherIsBetter: true + display: root.fmt(root.prefillTokS, 0, " t/s") + } + } + } + + // card servizio + controllo (load/unload nativo) + Rectangle { + Layout.fillWidth: true + implicitHeight: svcBody.implicitHeight + 10 + radius: 5 + color: Qt.rgba(0, 0, 0, 0.28) + border.width: 1 + border.color: Qt.rgba(root.phaseAccent().r, root.phaseAccent().g, root.phaseAccent().b, 0.65) + + ColumnLayout { + id: svcBody + anchors.fill: parent + anchors.margins: 4 + spacing: 2 + + RowLayout { + Layout.fillWidth: true + spacing: 4 + PC3.Label { + text: "SERVIZIO" + color: Kirigami.Theme.disabledTextColor + font.pointSize: Kirigami.Theme.defaultFont.pointSize - 3 + } + PC3.Label { + text: root.phaseBadge() + color: root.phaseAccent() + font.bold: true + font.pointSize: Kirigami.Theme.defaultFont.pointSize - 3 + } + Item { Layout.fillWidth: true } + PC3.BusyIndicator { + visible: root.pending + running: root.pending + Layout.preferredWidth: Kirigami.Units.iconSizes.small + Layout.preferredHeight: Kirigami.Units.iconSizes.small + } + PC3.Switch { + checked: root.running + enabled: !root.pending && root.phase !== "unreachable" && root.phase !== "stopped" + onToggled: { + root.runAction(checked ? "load" : "unload", checked ? "load" : "unload") + checked = Qt.binding(function () { return root.running }) + } + QQC2.ToolTip.text: root.running ? "Scarica il modello (libera la VRAM)" : "Carica il modello in VRAM" + QQC2.ToolTip.visible: hovered + QQC2.ToolTip.delay: 500 + } + } + + PC3.Label { + Layout.fillWidth: true + text: root.modelInfo.alias || "—" + font.bold: true + font.pointSize: Kirigami.Theme.defaultFont.pointSize - 1 + elide: Text.ElideRight + } + + RowLayout { + Layout.fillWidth: true + PC3.Label { + text: (root.modelInfo.kv ? root.modelInfo.kv + " KV" : "—") + " · ctx " + root.ctxLabel() + + " · " + (root.concInfo.serving || 1) + "x" + color: Kirigami.Theme.disabledTextColor + font.pointSize: Kirigami.Theme.defaultFont.pointSize - 3 + } + Item { Layout.fillWidth: true } + PC3.Label { + text: root.modelInfo.vision ? "vision ✓" : "vision —" + color: root.modelInfo.vision ? root.themeGreen : Kirigami.Theme.disabledTextColor + font.pointSize: Kirigami.Theme.defaultFont.pointSize - 3 + } + } + + PC3.Label { + Layout.fillWidth: true + text: root.stateLine() + color: root.pending ? root.themeViolet : Kirigami.Theme.disabledTextColor + font.pointSize: Kirigami.Theme.defaultFont.pointSize - 4 + elide: Text.ElideRight + } + } + } + + Item { Layout.fillHeight: true } + + RowLayout { + Layout.fillWidth: true + PC3.Label { + text: root.lastUpdate ? "SYNC " + root.lastUpdate : "SYNC…" + color: Kirigami.Theme.disabledTextColor + font.pointSize: Kirigami.Theme.defaultFont.pointSize - 3 + } + Item { Layout.fillWidth: true } + PC3.Label { + text: root.pending ? "PENDING" : root.phaseBadge() + color: root.phaseAccent() + font.bold: true + font.pointSize: Kirigami.Theme.defaultFont.pointSize - 3 + } + } + } + } + } +} diff --git a/metadata.json b/metadata.json new file mode 100644 index 0000000..2834910 --- /dev/null +++ b/metadata.json @@ -0,0 +1,18 @@ +{ + "KPackageStructure": "Plasma/Applet", + "KPlugin": { + "Id": "org.enne2.llamagsq3", + "Name": "Strata (qwen3.8-flash-next)", + "Description": "Stato e controllo del servizio Strata: fase, VRAM, utilizzo/temperatura/potenza GPU a barre, velocita di generazione e prefill, load/unload nativi.", + "Icon": "utilities-system-monitor", + "Version": "1.2", + "License": "MIT", + "Authors": [ + { + "Name": "enne2" + } + ], + "Category": "System Information" + }, + "X-Plasma-API-Minimum-Version": "6.0" +}