* 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.
483 lines
15 KiB
Luau
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()
|