Unraid 服务器状态栏插件实现拆解:luaws 的 Quickshell 监控方案

导语

这是一篇关于 luaws 自研的 Unraid 服务器状态栏插件(插件 ID:luaws.unraid)的实现记录。它运行在 Omarchy(基于 Hyprland/Quickshell 的桌面)上,让作者在桌面状态栏里就能实时看到家里那台 Unraid NAS 的"心跳":CPU/内存、磁盘阵列与每块盘的温度和读写速率、Docker 容器列表(还能一键启停)、VM、网络吞吐、SMART 告警,以及一键打开 Unraid Web UI。本文完整记录它的目录结构、manifest 声明、QML 界面骨架,以及每段 shell 脚本如何通过免密 SSH 从 Unraid 主机取数,希望给想在桌面监控 NAS 的朋友一个可复用的参考。全文按公开博客口径书写,涉及真实地址、账号、密钥等均已脱敏(见文末审核要点)。

插件做什么

一句话:桌面状态栏里放一个 Unraid 图标按钮,点开是一个信息面板,数据全部来自远程 Unraid 主机。

  • 状态栏按钮:Unraid Logo 图标 + 悬浮提示(CPU 负载/温度、主板温度、内存占比、Docker 运行数);任一指标越限(磁盘温度、CPU/主板温度、SMART、磁盘错误)时图标外圈会出现"呼吸闪烁"的红色警示环。
  • 左键点击展开下拉面板,右键直接打开 Unraid Web UI。
  • 面板内容分区展示:
    • System:CPU 负载条(含 CPU 温度/主板温度)、CPU 型号、内存用量条;
    • Array:阵列状态(STARTED/OFFLINE 等)、磁盘数、总容量/已用百分比/剩余、校验(parity check / resync)进度;
    • Disks:每块盘一行——名称、实时读写速率(R/W,KB/s/MB/s)、SMART 错误数、空间占用(百分比/剩余)、容量、温度(超阈值标红);
    • Docker:容器列表,绿/黄/灰状态点,运行中可点停、停止的可点启动;有 WebUI 端口的容器显示 🌐 可直接打开网页;对某个内网穿透容器还提供单独的 🔗 启动/停止穿透按钮;
    • Network:eth0 实时下行/上行速率;
    • VMs:VM 列表与运行状态,点击可启动/强制关机,操作期间显示"操作中…",并轮询直到拿到真实状态;
    • 底部:上次刷新时间、打开 Unraid Web UI刷新 按钮。

数据默认每 60 秒自动轮询一次(可配置),打开面板的瞬间也会触发一次立即刷新;离线时面板显示"(离线)",状态栏仍保留入口。

目录结构

~/.config/omarchy/plugins/luaws.unraid/
├── manifest.json        # 插件元数据 + 可配置项声明
├── UnraidWidget.qml     # 状态栏按钮 + 下拉面板(QML,约 800 行)
├── unraid-status.sh     # 核心取数脚本(含控制操作模式)
├── host-ports.sh        # 识别 host 网络容器的 WebUI 端口
├── vm-poll.sh           # 查询单个 VM 真实状态(VM 操作闭环)
├── parse_disks.py       # 解析 Unraid disks.ini(每盘信息 + 阵列汇总)
└── unraid.png           # 状态栏/面板用的 Unraid Logo(素材确认存在)

文件权限上,shell 脚本均为可执行(-rwx--x--x),只有属主可读写执行;manifest 与 QML 为普通用户文件。整个插件放在 ~/.config/omarchy/plugins/(Omarchy 约定的用户插件目录),不在系统包目录 /usr/share/omarchy/ 下——这样 omarchy update 不会覆盖它。

manifest.json:插件的"身份证"

Omarchy 插件通过 manifest 声明身份、种类与入口:

{
  "schemaVersion": 1,
  "id": "luaws.unraid",
  "name": "Unraid",
  "version": "1.0.0",
  "author": "luaws",
  "description": "Unraid 状态栏监测 + 下拉面板:CPU/内存/阵列/磁盘温度/Docker/VM,一键打开 Unraid Web UI。",
  "kinds": ["bar-widget"],
  "entryPoints": { "barWidget": "UnraidWidget.qml" },
  "barWidget": {
    "displayName": "Unraid",
    "category": "Apps",
    "defaultSection": "right",
    "allowMultiple": false,
    "defaults": {
      "url": "http://192.168.XX.XX",
      "refreshInterval": 60000,
      "warnTemp": 50,
      "warnCpuTemp": 85,
      "warnMbTemp": 70
    },
    "schema": [ ... ]
  }
}

值得注意的几点:

  • kinds: ["bar-widget"] 表明它属于状态栏部件;entryPoints.barWidget 指向真正的入口 QML 文件 UnraidWidget.qml(路径相对 manifest 所在目录)。
  • defaultSection: "right":默认落在状态栏右侧。
  • defaultsschema 是插件可配置项:url(Web UI 地址)、refreshInterval(刷新间隔毫秒,UI 上限定 30s–60min、步进 30s)、三个温度告警阈值 warnTemp(磁盘)、warnCpuTempwarnMbTemp。QML 里通过 setting(key, 默认值) 读取,可被用户在 shell.json 或配置面板覆盖。
  • 原文 description 里写死的内网 IP 已在上面脱敏为 192.168.XX.XX

UnraidWidget.qml:按钮 + 下拉面板骨架

QML 文件是整个插件唯一的前端。它导入 QuickshellQuickshell.Io 与 Omarchy 的 qs.Commons / qs.Ui 组件库,外层结构如下:

Panel {
  id: root
  moduleName: "luaws.unraid"
  ipcTarget: "luaws.unraid"

  // 可配置项(来自 manifest schema / shell.json)
  readonly property string url: setting("url", "http://192.168.XX.XX")
  readonly property int refreshInterval: Number(setting("refreshInterval", 60000))
  ...
  // 一组"状态属性":CPU/内存/阵列/磁盘/Docker/VM/网络……
  property string cpuLoad: "--"
  property double cpuPct: 0
  property string arrayState: "OFFLINE"
  property var disks: []
  property var containers: []
  property var vms: []
  ...
}

内部由几大块拼成:

  1. 取数 Process(Quickshell.Io):执行 /bin/sh -c "cd <插件目录> && bash unraid-status.sh",stdout 接一个 SplitParser,每读到一行就交给 parseLine();进程退出时回调 finishRefresh()
  2. 刷新定时器Timer,间隔取 refreshIntervalrepeat + triggeredOnStart,到点触发 refreshNow()
  3. BarIconButton(状态栏按钮):图标是 Image { source: "unraid.png" };右上叠加一层半透明描边矩形,当 alarming 为真时用无限循环的透明度动画"呼吸"告警。左键 toggle() 展开面板,右键 openWeb()
  4. KeyboardPanel(下拉面板)anchorItem: button 让面板挂在按钮下;内部一个 ScrollView + Column,自上而下堆叠 PanelHero(标题/副标题)、若干 PanelSectionHeader 分区、Repeater 渲染磁盘/容器/VM 列表、Button 操作区。面板支持键盘:Esc 关闭、Tab 切换面板、按 O 打开 Web。
  5. 两个自绘小组件InfoRow(左标签右数值的信息行,带 hover 高亮与 tooltip)和 MeterRow(进度条行,带 warn 变红逻辑),都用 <component> 内联定义复用。

状态怎么"流"进 UI

脚本输出约定为每行 类别|字段|值|… 的管道文本parseLine() 按第一段分发,例如:

  • sys|cpu|42 → 设置 CPU 负载、sys|mem|... 设置内存;
  • array|status|STARTED|8 → 阵列状态与盘数,array|resync|... 算校验百分比;
  • disk|... → 逐条 push 进 disks 数组(每盘:名称/温度/状态/类型/容量/设备/错误数/已用%/剩余);
  • rate|<dev>|<r>|<w> → 记录该盘的读写速率,UI 里 diskRate() 把 KB/s 格式化成 R 1.2M W 340K,没流量时显示 idle
  • container|... / vm|... → 容器、VM 列表;
  • smart|... → 追加进 SMART 告警名单。

属性一旦变化,Repeater/InfoRow 等声明式绑定自动重绘——QML 侧没有任何手动刷新 UI 的代码,只是数据的搬运与呈现。

告警是"派生"出来的只读属性:

readonly property bool anyDiskHot:
  disks.some 温度 > warnTemp
readonly property bool alarming:
  anyDiskHot || anySmartFail || anyDiskError || anyCpuHot || anyMbHot

控制操作(Docker / VM / npc)

面板里能点按钮操作容器与 VM。这些操作不经过取数进程,而是 root.bar.run(...) 起一条 shell 直接调用脚本的控制模式,例如:

  • Docker 启停:bash unraid-status.sh docker start '<容器名>'
  • VM:bash unraid-status.sh vm start '<VM名>' / vm destroy '<VM名>'(destroy 即强制关机);
  • 内网穿透:bash unraid-status.sh npc start|stop

细节上做了几件事防止"点了没反应/状态假跳":VM 点击后立刻把该 VM 置为"操作中…"并隐藏按钮,随后进入单独的状态轮询(见 vm-poll.sh 一节),只有 virsh 回报的真实状态到达目标值,UI 才显示"已启动/已关机";20 次轮询仍不到目标则提示"操作超时"。Docker 启停则是 fire-and-forget,真实状态在下一次整表刷新里对齐。

unraid-status.sh:一次 SSH 会话拉全部数据

这是插件的"数据泵"。开头定义连接参数:

SSH_KEY="$HOME/.ssh/<私钥文件名占位>"
UNRAID_HOST="<user>@192.168.XX.XX"
SSH_OPTS="-i $SSH_KEY \
  -o StrictHostKeyChecking=no -o BatchMode=yes -o ConnectTimeout=6"

说明数据通道:

  • 通道是 SSH,免密/证书登录:指定私钥文件(脱敏),BatchMode=yes 保证任何情况下都不弹交互密码(只信任已配置好的密钥,避免脚本挂起),ConnectTimeout=6 快速失败,StrictHostKeyChecking=no 省去首次指纹确认。全程没有口令明文
  • 用户名与主机地址在文中以 <user>@192.168.XX.XX 占位(Unraid 实际以管理员账号登录,此处不展开真实身份)。

双模式:控制模式与取数模式

脚本带参数时进入"控制模式"——把命令转发到远端执行后直接退出:

if [[ $# -ge 3 ]]; then
  action="$1"; target="$2"; name="$3"
  case "$action" in
    docker) ssh $SSH_OPTS "$UNRAID_HOST" "docker $target \"$name\" >/dev/null 2>&1; echo ok" ;;
    vm)     ssh $SSH_OPTS "$UNRAID_HOST" "virsh $target \"$name\" >/dev/null 2>&1; echo ok" ;;
  esac
fi
# npc start/stop:docker exec 进穿透容器,后台拉起/杀掉穿透进程

不带参数则是取数模式,即 QML 定时器触发的那条路径。

取数策略:远端跑一段脚本,本机解析

取数没有"SSH 一次只拿一个指标"地来回连,而是一次连接、远端执行一大段 bash,把所有原始数据按"标记块"输出,回本机再解析:

REMOTE_SCRIPT='#!/bin/bash
# ……在 Unraid 上执行的所有采集命令……
echo "SYS|cpu|...|mem|...|array|...|docker|...|vm|...|ver|...|cputemp|...|mbtemp|...|resync|..."
echo "@@DISKS@@"
cat /var/local/emhttp/disks.ini
echo "@@MONITOR@@"
cat /var/local/emhttp/monitor.ini
echo "@@DOCKER@@"
docker ps -a --format "{{.Names}}|{{.State}}|{{.Status}}|{{.Ports}}"
echo "@@RATE@@"
# ……两次采样 /proc/diskstats 求 1 秒窗口读写速率……
echo "@@NET@@"
# ……两次采样 /proc/net/dev 求 eth0 速率……
echo "@@VM@@"
virsh list --all
echo "@@NPC@@"
# 数穿透进程数
echo "@@END@@"
'

RAW=$(printf '%s' "$REMOTE_SCRIPT" | ssh $SSH_OPTS "$UNRAID_HOST" 'bash -s' 2>/dev/null)

远端脚本通过 stdin 管道给 ssh ... bash -s,本地只拼一次命令串,不落盘、不反复建连——这也是注释里"一次 SSH 拉全部原始数据"的设计意图:采集 30+ 项指标只付出一次 SSH 握手成本。

远端主要数据源:

数据 来源
CPU 负载 /var/local/emhttp/cpuload.ini(对 host= 行求平均)
内存 free -m
阵列状态/盘数 /var/local/emhttp/var.inimdState=mdNumDisks=
校验进度 mdcmd status 输出(mdResyncAction/Pos/Size
CPU/主板温度 一次 sensors:取所有 Core N: 的最高值、MB Temp
CPU 型号 /proc/cpuinfo
Unraid 版本/主机名 /etc/unraid-versionhostname
磁盘明细 /var/local/emhttp/disks.ini
每盘空间/SMART /var/local/emhttp/monitor.ini[used][smart] 段)
Docker docker ps -a
VM virsh list
磁盘读写速率 /proc/diskstats 两次采样(间隔 sleep 1)
网卡速率 /proc/net/dev 两次采样(间隔 sleep 1)

速率怎么算:在远端先记录 diskstats/net/dev 的计数器,sleep 1 后再读一次,差值换算成"每秒 KB"——磁盘按扇区差 ×512/1024 得 KiB/s,网卡按字节差 /1024 得 KiB/s(QML 端再格式化为 KB/s / MB/s)。一次 sleep 同时覆盖磁盘与网卡两个窗口。

本机解析与二次加工

拿到 RAW 后,脚本用一个 block() 函数按 @@XXX@@ 标记切块:

block() { printf '%s\n' "$RAW" | awk -v want="@@$1@@" '
  $0==want{f=1;next}  f && /^@@/ {exit}  f {print}'; }
SYS_LINE=$(printf '%s\n' "$RAW" | grep "^SYS|" | head -1)
DISKS_RAW=$(block DISKS)
MON_RAW=$(block MONITOR)
...

随后做几类加工,全部转换成统一的 类别|字段|值 行输出:

  1. SYS 行:用 IFS='|' 按固定列位拆出 CPU/内存/阵列/Docker/VM/版本/温度/校验字段,再逐条 echo "sys|cpu|..." 等;拆字段而不是在远端 echo,是为了让 QML 的 SplitParser 拿到干净的"一行一指标"流。
  2. disks.ini → parse_disks.py:交给独立 Python 脚本解析(见下节)。
  3. monitor.ini:内联一段 Python 抽 [used] 段每盘占用百分比(输出 used|diskX|<pct>)和 [smart] 段的告警 ack 标志(输出 smart|<盘>|fail)。
  4. Docker 行docker ps 的 Ports 列对 bridge 网络会给出 0.0.0.0:9119->9119/tcp,但对 host 网络容器是空的;此时调用 host-ports.sh 补一个 WebUI 端口再输出 container|...
  5. VM 行virsh list --all 的表格用 awk 把多空格压成 |,输出 vm|<name>|<state>

离线兜底RAW 为空(SSH 失败/主机不可达)时输出一组 sys|cpu|--array|status|OFFLINE|0docker|count|0|0unraid|version|offline|--exit 1——QML 那边照常解析,UI 自然显示"离线"而非卡死。

parse_disks.py:disks.ini 的清洗器

Unraid 的 /var/local/emhttp/disks.ini 是一份带 ["diskX"] 段落的 INI 风格文件,每个盘一段、含 temp/status/type/fsSize/fsFree/device/numErrors 等键。Python 脚本从 stdin 读入,用正则按 ["diskN"] 切段并抽取键值:

for m in re.finditer(r'\["([^"]+)"\](.*?)(?=\["|\Z)', txt, re.S):
    nm = m.group(1); body = m.group(2)
    if not nm.startswith('disk'): continue
    ...
    disks[nm] = { 'temp': ..., 'status': ..., 'fsSize': ...,
                  'fsFree': ..., 'dev': ..., 'err': ... }

要点:

  • 跳过"未在位"的盘(DISK_NP / DISK_NP_DSBL)和温度不可读(temp == '*')的条目;
  • 单盘一行输出 disk|<名>|<温度>|<状态>|<类型>|<容量>|<设备>|<错误数>|<已用%>|<剩余>——注意温度、状态这些字符串字段保留原样给 QML 展示,纯数值字段在这里算好;
  • 顺带做阵列汇总:所有盘 fsSize 累加、fsUsed 累加,输出一行 array|space|<总容量>|<占用%>|<剩余>,供面板"Total"行显示;
  • 容量单位换算:Unraid 的 size 单位是 KiB,≥1024 GiB 显示为 x.xT,否则整 GiB。

选 Python 而不是在 bash 里正则,注释写得很直白:"用独立 python 脚本避免转义"——多层引号嵌套下 bash 处理这种带引号的 KV 文本极易出错。

host-ports.sh:给 host 网络容器找回"门牌号"

docker ps 的 Ports 列只在 bridge 网络下才有值;用 host 网络的容器端口直接落在宿主机上,docker 无从报告。为了面板里那些 host 网络容器也能显示 🌐 打开按钮,这个脚本去远端做了一次"进程→端口"反查:

# 1. 遍历 docker ps 中的容器,只挑 network=host 的
# 2. docker top <容器> 拿到容器内所有进程 PID
# 3. 在宿主机 ss -tlnp 里找出这些 PID 监听的 TCP 端口
# 4. 每个容器按"WebUI 端口优先级表"选一个端口
#    优先级表: 3000 3001 3002 8080 8081 8082 8096 8123
#               9091 5244 8780 3131 1242 4533 19035 ...
#    没匹配到则退回数值最大的端口

优先级表里列了常见 WebUI 端口(如 8096、8123、9091 等),把"哪个端口是网页管理口"这种启发式固化下来。输出是 容器名 端口 两列。

脚本把结果缓存到本机 /tmp/unraid-hostports.cacheTTL 600 秒:命中且未过期就直接读缓存,避免每次整表刷新都额外打一次 SSH(毕竟它不在主取数流程的同一连接里)。

vm-poll.sh:VM 操作的"状态闭环"

virsh start / destroy 是异步的——命令返回不代表状态已切换。面板不希望"点启动后 60 秒才看到变化",于是有了独立小脚本:

# vm-poll.sh <vm-name>:SSH 到 Unraid 查询单个 VM 真实状态
STATE=$(ssh $SSH_OPTS "$UNRAID_HOST" "virsh domstate '$NAME' 2>/dev/null")
echo "vm|$NAME|$STATE"     # state: running / shut off / paused ...

配合 QML 侧的 vmPollTick():VM 操作期间起一个 2.5 秒周期的 Timer,每次调 vm-poll.sh单个 VM(独立的 Process,不整表刷新、不影响面板其它内容);返回行落入 parseVmPoll()就地更新 vms 数组里那个 VM 的状态(不重排其它行)。当状态等于目标值(running / shut off)即停止轮询并提示"已启动/已关机";连续 20 次(约 50 秒)没到位则提示"操作超时,请检查 VM 状态"。轮询期间若上一次还没返回会跳过本轮,避免进程堆积。

刷新调度与防并发

触发刷新的来源其实有多个:主 Timer(每 60s)、打开面板的瞬间(onOpenedChanged)、点"刷新"按钮、VM/Docker 控制后的 2.5s 补偿计时器。如果放任并发,会在同一窗口里叠多个 SSH 进程。QML 的处理是经典的去重合并

property bool refreshing: false
property bool pendingRefresh: false

function refreshNow() {
  if (root.refreshing) { root.pendingRefresh = true; return }  // 已在刷:挂起请求
  // 清空旧数据,启动 Process
  root.refreshing = true
  statusProc.running = true
}

function finishRefresh() {
  root.refreshing = false
  if (root.pendingRefresh) { /* 立即再刷一次,消费掉挂起的请求 */ }
}

即:任何时刻最多一个取数 Process 在跑;刷新期间的新请求只置一个 pendingRefresh 位,等当前进程退出后立刻补刷一次。refreshing 期间先把 disks/rates/used/containers/vms 清空,避免新旧数据混显示。

注册与重载

插件是"目录即注册"式的:

  1. 把插件目录放到用户目录 ~/.config/omarchy/plugins/luaws.unraid/,内含 manifest 与入口 QML;
  2. 在状态栏配置 ~/.config/omarchy/shell.jsonbar.right 数组里加一条引用:
"right": [
  { "id": "omarchy.tray" },
  ...
  { "id": "luaws.unraid" },
  ...
]

即可把它挂到右侧区域(位置也可用 omarchy bar move luaws.unraid --section right 这类命令调整)。

改完怎么生效:Omarchy shell 对 shell.json~/.config/omarchy/plugins/ 下的插件代码是保存即热重载的,普通改动无需重启;若偶发没生效,可强制重新扫描:omarchy-shell shell rescanPlugins;彻底一点则 omarchy restart shell(重启整个 Quickshell shell 进程)。因为插件文件都在 ~/.config(用户域),系统升级/omarchy update 不会覆盖,属于 Omarchy 推荐的"用户自定义走用户目录"模式。

小结:这套方案的可取之处

  • 单连接全量采集:30 多项指标一次 SSH bash -s 拉回,用标记块分隔、本机解析,网络往返成本极低,60s 轮询无压力;
  • 协议解耦:远端输出"原始块"→ 本机转成 类别|字段|值 行协议 → QML SplitParser 增量更新属性,每一层都可独立替换(比如换成 REST API 或本地 agent 取数,QML 一行不用改);
  • 声明式 UI:QML 只做"数据到视图"的绑定,告警、颜色、tooltip 全是派生属性,无命令式 DOM 更新;
  • 把"异步操作"做闭环:VM 启停这种长尾操作单独开 2.5s 轮询查真实状态,而不是盲等固定延时;
  • 能动手的不只看:面板里能启停 Docker、启停 VM、启停内网穿透、开各容器 WebUI——是个"遥控器"而不只是"仪表盘"。

安全上,脚本只依赖 SSH 证书免密登录并强制 BatchMode,无明文口令;敏感项(内网 IP、SSH 账号/私钥文件名、穿透服务器与 vkey、真实用户名路径、容器/VM/磁盘真名)在本文全部做了占位处理。