Files
community-plugins/mihomo-control/panel.luau
T
MA DingjieandGitHub 222fec4875 Dev/mihomo control (#339)
* Add Mihomo Control plugin

Monitor and control a Mihomo (Clash Meta) external controller from the
bar: live traffic graph, proxy mode, proxy-group management with latency
tests, and core restart. Works locally (127.0.0.1) or remotely via
host/port/secret settings.

* Add Chinese (Simplified) translation

* Add test-all latency button

Adds a Test all button next to the Proxy groups heading that runs latency
tests on every group sequentially, with a single summary notification.

* Unify traffic and proxy groups section styles

Give both sections the same card look: muted medium-weight heading on the
left, secondary info on the right, and proxy-group cards restyled as
bordered sub-cards inside the section.

* Polish proxy group cards

Show the selected proxy next to the bold group name (ECS > ecs-proxy),
move the expand chevron to the type/members row where the list opens,
and drop the list glyph from the card header.

* Decouple heights of paired group cards

Keep each card in a two-column row at its natural height instead of
stretching the neighbor when one is expanded.

* Use thumbnail generator to generate thumbnail

* mihomo-control: theme-aware panel colors

Use theme roles for traffic and latency colors so they stay readable in
light mode, and keep the status chip's green/amber/red identity with
darker light-mode shades.
2026-08-12 09:34:17 -04:00

483 lines
15 KiB
Luau

--!nonstrict
-- Mihomo Control — control panel.
--
-- A settings-style panel driven by the service's "mihomo.*" state. Every
-- action goes through the "mihomo.command" channel so the service stays the
-- single owner of HTTP access.
--
-- Sections:
-- 1. Header: title, connection status, close button.
-- 2. Connection: mode select (rule/global/direct) and refresh button.
-- 3. Traffic: live up/down rates, totals, memory, active connections, and
-- a "close all connections" action.
-- 4. Proxy groups: one card per group — current selection, member select,
-- and a latency-test button.
local MODES = { "rule", "global", "direct" }
local snapshot = {
connection = {},
config = {},
traffic = {},
connections = {},
groups = {},
}
-- Rolling traffic-rate history for the graph: one sample per published value
-- change, capped so the window stays fixed.
local TRAFFIC_HISTORY = 90
local down_history = {}
local up_history = {}
local function record_traffic(traffic)
local down = tonumber(traffic.down) or 0
local up = tonumber(traffic.up) or 0
local last_index = #down_history
if last_index == 0 or down_history[last_index] ~= down or up_history[last_index] ~= up then
table.insert(down_history, down)
table.insert(up_history, up)
if #down_history > TRAFFIC_HISTORY then
table.remove(down_history, 1)
table.remove(up_history, 1)
end
end
end
local function normalized_series(values, peak)
local out = {}
for i, value in ipairs(values) do
out[i] = peak > 0 and value / peak or 0
end
return out
end
local function refresh_snapshot()
snapshot.connection = noctalia.state.get("mihomo.connection") or snapshot.connection
snapshot.config = noctalia.state.get("mihomo.config") or snapshot.config
snapshot.traffic = noctalia.state.get("mihomo.traffic") or snapshot.traffic
snapshot.connections = noctalia.state.get("mihomo.connections") or snapshot.connections
snapshot.groups = noctalia.state.get("mihomo.groups") or snapshot.groups
record_traffic(snapshot.traffic)
end
local function send_command(cmd)
cmd.seq = math.floor(os.clock() * 1000000)
noctalia.state.set("mihomo.command", cmd)
end
-- Expansion state for proxy-group cards: collapsed by default so long member
-- lists do not flood the panel. Persists across re-renders while the panel
-- script stays loaded; a fresh panel starts collapsed again.
local expanded_groups = {}
local render -- forward declaration; assigned below
local function toggle_group(group_name)
expanded_groups[group_name] = not expanded_groups[group_name]
render()
end
-- ── Formatting helpers (small duplicates per the plugin sandbox) ────────────
local function format_rate(bytes_per_second)
local value = tonumber(bytes_per_second) or 0
if value >= 1024 * 1024 * 1024 then
return string.format("%.2f GB/s", value / (1024 * 1024 * 1024))
end
if value >= 1024 * 1024 then
return string.format("%.1f MB/s", value / (1024 * 1024))
end
if value >= 1024 then
return string.format("%.1f KB/s", value / 1024)
end
return string.format("%d B/s", value)
end
local function format_total(bytes)
local value = tonumber(bytes) or 0
if value >= 1024 ^ 4 then
return string.format("%.2f TB", value / (1024 ^ 4))
end
if value >= 1024 ^ 3 then
return string.format("%.2f GB", value / (1024 ^ 3))
end
if value >= 1024 ^ 2 then
return string.format("%.1f MB", value / (1024 ^ 2))
end
if value >= 1024 then
return string.format("%.1f KB", value / 1024)
end
return string.format("%d B", value)
end
local function latency_text(delay)
if delay == nil then
return "—"
end
if delay > 0 then
return `{delay} ms`
end
return noctalia.tr("panel.timeout")
end
local function latency_color(delay)
if delay == nil then
return "on_surface_variant"
end
if delay > 0 then
if delay <= 300 then
return "primary"
end
if delay <= 800 then
return "warning"
end
return "error"
end
return "error"
end
-- The status chip keeps the classic green/amber/red identity instead of theme
-- roles, but uses darker shades in light mode so it stays readable on light
-- surfaces.
local function status_color()
if noctalia.isDarkMode() then
return { online = "#4ade80", connecting = "#facc15", offline = "#f87171" }
end
return { online = "#15803d", connecting = "#a16207", offline = "#b91c1c" }
end
-- A single "overall" latency per group: the currently selected member's delay
-- (Selector/URLTest/Fallback), or the best tested member latency otherwise
-- (LoadBalance has no selection). Returns 0 when every tested member failed.
local function group_latency(group)
if group.now and group.now ~= "" then
for _, member in ipairs(group.members) do
if member.name == group.now and member.delay ~= nil then
return member.delay
end
end
end
local best = nil
local any_tested = false
for _, member in ipairs(group.members) do
local delay = member.delay
if delay ~= nil then
any_tested = true
if delay > 0 and (best == nil or delay < best) then
best = delay
end
end
end
if best ~= nil then
return best
end
if any_tested then
return 0
end
return nil
end
-- ── UI helpers ──────────────────────────────────────────────────────────────
local function connection_text()
local conn = snapshot.connection
local endpoint = `{conn.host or ""}:{conn.port or ""}`
if conn.status == "online" then
local version = tostring(conn.version or "")
return version ~= ""
and noctalia.tr("panel.online_with_version", { version = version, endpoint = endpoint })
or noctalia.tr("panel.online_plain", { endpoint = endpoint })
end
if conn.status == "connecting" then
return endpoint
end
local err = tostring(conn.error or "")
return err ~= ""
and noctalia.tr("panel.offline_with_error", { error = err })
or noctalia.tr("panel.offline_plain", { endpoint = endpoint })
end
local function status_chip()
local conn = snapshot.connection
local online = conn.status == "online"
local connecting = conn.status == "connecting"
local colors = status_color()
local text = online
and noctalia.tr("panel.status_online")
or (connecting and noctalia.tr("panel.status_connecting") or noctalia.tr("panel.status_offline"))
local color = online and colors.online or (connecting and colors.connecting or colors.offline)
return ui.row({ gap = 6, align = "center", fill = "surface/0.6", radius = 999, paddingH = 10, paddingV = 4 }, {
ui.box({ width = 8, height = 8, radius = 4, fill = color }),
ui.label({ text = text, fontSize = 12, color = "on_surface_variant" }),
})
end
local function controls_row()
local mode = snapshot.config.mode or "rule"
local index = 0
for i, candidate in MODES do
if candidate == mode then
index = i - 1
break
end
end
return ui.row({ gap = 8, align = "center" }, {
ui.select({
options = {
noctalia.tr("panel.mode_rule"),
noctalia.tr("panel.mode_global"),
noctalia.tr("panel.mode_direct"),
},
selectedIndex = index,
onChange = function(index_text)
local chosen = MODES[(tonumber(index_text) or 0) + 1]
if chosen then
send_command({ op = "mode", mode = chosen })
end
end,
}),
ui.button({
text = noctalia.tr("panel.restart"),
variant = "outline",
onClick = function()
send_command({ op = "restart" })
end,
}),
})
end
local function traffic_card()
local traffic = snapshot.traffic
local connections = snapshot.connections
local peak = 1
for _, value in ipairs(down_history) do
if value > peak then
peak = value
end
end
for _, value in ipairs(up_history) do
if value > peak then
peak = value
end
end
return ui.column({ gap = 10, fill = "surface/0.5", radius = 12, padding = 12 }, {
ui.row({ justify = "space_between", align = "center" }, {
ui.label({ text = noctalia.tr("panel.traffic"), fontWeight = "medium", color = "on_surface_variant" }),
ui.label({
text = `{connections.count or 0} {noctalia.tr("panel.connections")}`,
color = "on_surface_variant",
fontSize = 12,
}),
}),
ui.graph({
values = normalized_series(down_history, peak),
values2 = normalized_series(up_history, peak),
color = "primary",
color2 = "secondary",
lineWidth = 2,
fillOpacity = 0.15,
height = 72,
}),
ui.row({ gap = 16, align = "center" }, {
ui.label({ text = `▼ {format_rate(traffic.down)}`, fontSize = 16, fontWeight = "bold", color = "primary" }),
ui.label({ text = `▲ {format_rate(traffic.up)}`, fontSize = 16, fontWeight = "bold", color = "secondary" }),
}),
ui.row({ gap = 16, align = "center" }, {
ui.label({
text = `↓ {format_total(traffic.downTotal)} · ↑ {format_total(traffic.upTotal)} · {format_total(connections.memory)} {noctalia.tr("panel.memory")}`,
color = "on_surface_variant",
fontSize = 12,
}),
}),
})
end
local function group_card(group)
local name_text = group.name
if group.hidden == true then
name_text = name_text .. ` ({noctalia.tr("panel.hidden")})`
end
local expanded = expanded_groups[group.name] == true
local overall = group_latency(group)
local name_children = {
ui.label({ text = name_text, fontWeight = "bold" }),
}
if group.now and group.now ~= "" then
table.insert(name_children, ui.label({ text = ">", color = "on_surface_variant" }))
table.insert(name_children, ui.label({
text = group.now,
color = "on_surface_variant",
fontSize = 12,
flexGrow = 1,
}))
end
local children = {
ui.row({ gap = 6, align = "center", justify = "space_between", onClick = function()
toggle_group(group.name)
end }, {
ui.row({ gap = 4, align = "center", flexGrow = 1 }, name_children),
ui.button({
glyph = "activity",
tooltip = noctalia.tr("panel.delay_test"),
variant = "ghost",
onClick = function()
send_command({ op = "delay_test", group = group.name })
end,
}),
}),
ui.row({ gap = 6, align = "center", justify = "space_between", onClick = function()
toggle_group(group.name)
end }, {
ui.button({
glyph = expanded and "chevron-down" or "chevron-right",
variant = "ghost",
onClick = function()
toggle_group(group.name)
end,
}),
ui.label({
text = `{group.type or ""} · {noctalia.trp("panel.members_count", #group.members)}`,
color = "on_surface_variant",
fontSize = 12,
flexGrow = 1,
}),
ui.label({
text = latency_text(overall),
fontSize = 12,
fontWeight = "medium",
color = latency_color(overall),
}),
}),
}
if expanded and #group.members > 0 then
local member_rows = {}
for _, member in ipairs(group.members) do
local is_current = member.name == group.now
local delay_text = latency_text(member.delay)
local delay_color = latency_color(member.delay)
table.insert(member_rows, ui.row({ gap = 6, align = "center", onClick = function()
send_command({ op = "select", group = group.name, proxy = member.name })
end }, {
ui.box({ width = 6, height = 6, radius = 3, fill = is_current and "primary" or "surface_variant" }),
ui.label({
text = member.name,
flexGrow = 1,
color = is_current and "on_surface" or "on_surface_variant",
fontSize = 12,
}),
ui.label({ text = delay_text, fontSize = 12, color = delay_color }),
}))
end
table.insert(children, ui.column({ gap = 3 }, member_rows))
elseif expanded then
table.insert(children, ui.label({
text = noctalia.tr("panel.no_members"),
color = "on_surface_variant",
fontSize = 12,
}))
end
return ui.column({
gap = 8,
border = "outline",
borderWidth = 1,
radius = 10,
padding = 10,
flexGrow = 1,
}, children)
end
render = function()
local groups = type(snapshot.groups) == "table" and snapshot.groups or {}
local body = {
controls_row(),
traffic_card(),
}
if #groups > 0 then
local group_children = {
ui.row({ align = "center", gap = 8 }, {
ui.label({
text = noctalia.tr("panel.proxy_groups"),
fontWeight = "medium",
color = "on_surface_variant",
flexGrow = 1,
}),
ui.button({
text = noctalia.tr("panel.test_all"),
variant = "ghost",
onClick = function()
send_command({ op = "delay_test_all" })
end,
}),
}),
}
for i = 1, #groups, 2 do
local row_children = { group_card(groups[i]) }
if groups[i + 1] then
table.insert(row_children, group_card(groups[i + 1]))
else
table.insert(row_children, ui.box({ flexGrow = 1 }))
end
table.insert(group_children, ui.row({ gap = 10, align = "start" }, row_children))
end
table.insert(body, ui.column({ gap = 10, fill = "surface/0.5", radius = 12, padding = 12 }, group_children))
else
table.insert(body, ui.column({ gap = 10, fill = "surface/0.5", radius = 12, padding = 12 }, {
ui.label({ text = noctalia.tr("panel.proxy_groups"), fontWeight = "medium", color = "on_surface_variant" }),
ui.label({ text = noctalia.tr("panel.no_groups"), color = "on_surface_variant" }),
}))
end
panel.render(ui.column({ flexGrow = 1, gap = 12 }, {
ui.column({ gap = 6 }, {
ui.row({ align = "center", gap = 8 }, {
ui.image({ path = "icon.png", width = 16, height = 16 }),
ui.label({ text = noctalia.tr("panel.title"), fontSize = 16, fontWeight = "bold", flexGrow = 1 }),
status_chip(),
ui.button({
glyph = "refresh",
tooltip = noctalia.tr("panel.refresh"),
variant = "ghost",
onClick = function()
send_command({ op = "refresh" })
end,
}),
ui.button({
glyph = "close",
onClick = function()
panel.close()
end,
}),
}),
ui.label({ text = connection_text(), color = "on_surface_variant", fontSize = 12 }),
}),
ui.scroll({ flexGrow = 1, gap = 12 }, body),
}))
end
for _, key in {
"mihomo.connection",
"mihomo.config",
"mihomo.traffic",
"mihomo.connections",
"mihomo.groups",
} do
noctalia.state.watch(key, function(_value)
refresh_snapshot()
render()
end)
end
function onOpen(_context)
refresh_snapshot()
render()
end
refresh_snapshot()
render()