omarchy 状态栏插件实现:Unraid 服务器监控
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":默认落在状态栏右侧。defaults与schema是插件可配置项:url(Web UI 地址)、refreshInterval(刷新间隔毫秒,UI 上限定 30s–60min、步进 30s)、三个温度告警阈值warnTemp(磁盘)、warnCpuTemp、warnMbTemp。QML 里通过setting(key, 默认值)读取,可被用户在shell.json或配置面板覆盖。- 原文 description 里写死的内网 IP 已在上面脱敏为
192.168.XX.XX。
UnraidWidget.qml:按钮 + 下拉面板骨架
QML 文件是整个插件唯一的前端。它导入 Quickshell、Quickshell.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: []
...
}
内部由几大块拼成:
- 取数
Process(Quickshell.Io):执行/bin/sh -c "cd <插件目录> && bash unraid-status.sh",stdout 接一个SplitParser,每读到一行就交给parseLine();进程退出时回调finishRefresh()。 - 刷新定时器:
Timer,间隔取refreshInterval,repeat + triggeredOnStart,到点触发refreshNow()。 BarIconButton(状态栏按钮):图标是Image { source: "unraid.png" };右上叠加一层半透明描边矩形,当alarming为真时用无限循环的透明度动画"呼吸"告警。左键toggle()展开面板,右键openWeb()。KeyboardPanel(下拉面板):anchorItem: button让面板挂在按钮下;内部一个ScrollView+Column,自上而下堆叠PanelHero(标题/副标题)、若干PanelSectionHeader分区、Repeater渲染磁盘/容器/VM 列表、Button操作区。面板支持键盘:Esc 关闭、Tab 切换面板、按O打开 Web。- 两个自绘小组件:
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.ini(mdState=、mdNumDisks=) |
| 校验进度 | mdcmd status 输出(mdResyncAction/Pos/Size) |
| CPU/主板温度 | 一次 sensors:取所有 Core N: 的最高值、MB Temp |
| CPU 型号 | /proc/cpuinfo |
| Unraid 版本/主机名 | /etc/unraid-version、hostname |
| 磁盘明细 | /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)
...
随后做几类加工,全部转换成统一的 类别|字段|值 行输出:
- SYS 行:用
IFS='|'按固定列位拆出 CPU/内存/阵列/Docker/VM/版本/温度/校验字段,再逐条echo "sys|cpu|..."等;拆字段而不是在远端 echo,是为了让 QML 的SplitParser拿到干净的"一行一指标"流。 - disks.ini → parse_disks.py:交给独立 Python 脚本解析(见下节)。
- monitor.ini:内联一段 Python 抽
[used]段每盘占用百分比(输出used|diskX|<pct>)和[smart]段的告警ack标志(输出smart|<盘>|fail)。 - Docker 行:
docker ps的 Ports 列对 bridge 网络会给出0.0.0.0:9119->9119/tcp,但对 host 网络容器是空的;此时调用host-ports.sh补一个 WebUI 端口再输出container|...。 - VM 行:
virsh list --all的表格用 awk 把多空格压成|,输出vm|<name>|<state>。
离线兜底:RAW 为空(SSH 失败/主机不可达)时输出一组 sys|cpu|--、array|status|OFFLINE|0、docker|count|0|0、unraid|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.cache,TTL 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 清空,避免新旧数据混显示。
注册与重载
插件是"目录即注册"式的:
- 把插件目录放到用户目录
~/.config/omarchy/plugins/luaws.unraid/,内含 manifest 与入口 QML; - 在状态栏配置
~/.config/omarchy/shell.json的bar.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 轮询无压力; - 协议解耦:远端输出"原始块"→ 本机转成
类别|字段|值行协议 → QMLSplitParser增量更新属性,每一层都可独立替换(比如换成 REST API 或本地 agent 取数,QML 一行不用改); - 声明式 UI:QML 只做"数据到视图"的绑定,告警、颜色、tooltip 全是派生属性,无命令式 DOM 更新;
- 把"异步操作"做闭环:VM 启停这种长尾操作单独开 2.5s 轮询查真实状态,而不是盲等固定延时;
- 能动手的不只看:面板里能启停 Docker、启停 VM、启停内网穿透、开各容器 WebUI——是个"遥控器"而不只是"仪表盘"。
安全上,脚本只依赖 SSH 证书免密登录并强制 BatchMode,无明文口令;敏感项(内网 IP、SSH 账号/私钥文件名、穿透服务器与 vkey、真实用户名路径、容器/VM/磁盘真名)在本文全部做了占位处理。