Add 9Router control plugin (#291)
* Add 9Router panel plugin This commit introduces the initial version of the 9Router panel plugin. It provides a Noctalia panel to manage 9Router model combos directly from the bar, without requiring the web dashboard. Key features: - List, create, rename, and delete model combos. - Reorder models within a combo via drag-and-drop. - Change combo routing kind (fallback, round-robin, fusion). - Search and filter combos. - Supports 9Router dashboard authentication via CLI token or password. Includes a bar widget showing connection status, a full UI panel, and a background service to interact with the 9Router REST API. Translations for English and Vietnamese are included. * Rename 9Router Panel to 9Router Control Update plugin IDs, IPC commands, and internal references. * Quote shell args in service commands Add shell_quote() for URLs, bodies, and file paths passed to noctalia.runAsync. Declare required external commands in plugin.toml and document them in the README.
This commit is contained in:
@@ -0,0 +1,553 @@
|
||||
-- 9Router Control service — the headless backend and single source of truth.
|
||||
--
|
||||
-- Responsibilities:
|
||||
-- • Talk to the 9Router REST API (GET/POST/PUT/DELETE /api/combos)
|
||||
-- • Authenticate: plain requests work when the dashboard has login disabled
|
||||
-- (the default local setup); on a 401 we compute the CLI token from the
|
||||
-- machine-id + cli-secret files and retry with the x-9r-cli-token header.
|
||||
-- • Track connection state, the combo list, and the selected combo
|
||||
-- • Publish shared state for the widget and panel subscribers
|
||||
--
|
||||
-- The widget and panel are pure subscribers of `9router.*` state keys.
|
||||
|
||||
-- ── helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
-- Translation with an optional per-plugin language override (mirrors panel.luau).
|
||||
local i18n_cache = { lang = nil, table = nil }
|
||||
|
||||
local function load_lang_table(lang)
|
||||
if i18n_cache.lang == lang and i18n_cache.table then
|
||||
return i18n_cache.table
|
||||
end
|
||||
local merged = {}
|
||||
local function merge_file(path)
|
||||
local ok, content = pcall(noctalia.readFile, path)
|
||||
if not ok or type(content) ~= "string" or content == "" then return end
|
||||
local ok2, data = pcall(noctalia.json.decode, content)
|
||||
if not ok2 or type(data) ~= "table" then return end
|
||||
local function deep(dst, src)
|
||||
for k, v in pairs(src) do
|
||||
if type(v) == "table" and type(dst[k]) == "table" then
|
||||
deep(dst[k], v)
|
||||
else
|
||||
dst[k] = v
|
||||
end
|
||||
end
|
||||
end
|
||||
deep(merged, data)
|
||||
end
|
||||
merge_file("translations/en.json")
|
||||
if lang ~= "en" then
|
||||
merge_file("translations/" .. lang .. ".json")
|
||||
end
|
||||
i18n_cache.lang = lang
|
||||
i18n_cache.table = merged
|
||||
return merged
|
||||
end
|
||||
|
||||
local function tr(key, args)
|
||||
local lang = noctalia.getConfig and noctalia.getConfig("language")
|
||||
if type(lang) ~= "string" or lang == "" or lang == "auto" then
|
||||
return noctalia.tr(key, args)
|
||||
end
|
||||
local node = load_lang_table(lang)
|
||||
for part in string.gmatch(key, "[^%.]+") do
|
||||
if type(node) ~= "table" then node = nil break end
|
||||
node = node[part]
|
||||
end
|
||||
if type(node) ~= "string" then
|
||||
return noctalia.tr(key, args)
|
||||
end
|
||||
if type(args) == "table" then
|
||||
node = string.gsub(node, "{(%w+)}", function(name)
|
||||
local v = args[name]
|
||||
if v == nil then return "{" .. name .. "}" end
|
||||
return tostring(v)
|
||||
end)
|
||||
end
|
||||
return node
|
||||
end
|
||||
|
||||
local function debug_log(msg)
|
||||
if noctalia.getConfig and noctalia.getConfig("debug_logging") then
|
||||
noctalia.log("9router-control: " .. tostring(msg))
|
||||
end
|
||||
end
|
||||
|
||||
-- Single-quote a value so it is treated as a literal argument by the shell,
|
||||
-- neutralising any metacharacters in user-provided strings (data_dir, host).
|
||||
local function shell_quote(value)
|
||||
return "'" .. tostring(value or ""):gsub("'", "'\\''") .. "'"
|
||||
end
|
||||
|
||||
local function get_server_host()
|
||||
local v = noctalia.getConfig and noctalia.getConfig("server_host")
|
||||
if type(v) == "string" and v ~= "" then return v end
|
||||
return "127.0.0.1"
|
||||
end
|
||||
|
||||
local function get_server_port()
|
||||
local v = noctalia.getConfig and noctalia.getConfig("server_port")
|
||||
if type(v) == "number" and v > 0 then return v end
|
||||
return 20128
|
||||
end
|
||||
|
||||
local function get_data_dir()
|
||||
local v = noctalia.getConfig and noctalia.getConfig("data_dir")
|
||||
if type(v) == "string" and v ~= "" then return v end
|
||||
return "~/.9router"
|
||||
end
|
||||
|
||||
-- ── state ────────────────────────────────────────────────────────────────────
|
||||
|
||||
local connection = { status = "offline", error = "" }
|
||||
local combos = {}
|
||||
local models = {}
|
||||
local selected_combo_id = nil
|
||||
local last_error = nil
|
||||
local auth_token = nil -- JWT cookie captured from a successful login
|
||||
|
||||
local function publish()
|
||||
noctalia.state.set("9router.connection", connection)
|
||||
noctalia.state.set("9router.combos", combos)
|
||||
noctalia.state.set("9router.models", models)
|
||||
noctalia.state.set("9router.selected_combo", selected_combo_id)
|
||||
noctalia.state.set("9router.last_error", last_error)
|
||||
end
|
||||
|
||||
local function set_connection(status, err)
|
||||
connection = { status = status, error = err or "" }
|
||||
publish()
|
||||
end
|
||||
|
||||
local function set_last_error(message, detail)
|
||||
last_error = { message = message, detail = detail }
|
||||
publish()
|
||||
end
|
||||
|
||||
local function clear_last_error()
|
||||
last_error = nil
|
||||
publish()
|
||||
end
|
||||
|
||||
-- ── HTTP ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
local base_url = nil
|
||||
local cli_token = nil
|
||||
local token_loading = false
|
||||
|
||||
local function build_base_url()
|
||||
return "http://" .. get_server_host() .. ":" .. tostring(get_server_port())
|
||||
end
|
||||
|
||||
-- Compute the 9Router CLI token: sha256(rawMachineId + "9r-cli-auth" +
|
||||
-- cliSecret).substring(0,16). Noctalia has no sha256 binding, so we shell out.
|
||||
-- The command reads the machine-id and cli-secret files from the data dir.
|
||||
local function compute_cli_token(callback)
|
||||
if cli_token then
|
||||
callback(cli_token)
|
||||
return
|
||||
end
|
||||
if token_loading then
|
||||
-- Wait for the in-flight computation to finish.
|
||||
local tries = 0
|
||||
local function poll()
|
||||
tries = tries + 1
|
||||
if cli_token then
|
||||
callback(cli_token)
|
||||
elseif tries > 50 then
|
||||
callback(nil)
|
||||
else
|
||||
noctalia.runAsync("sleep 0.1 && echo done", function()
|
||||
poll()
|
||||
end)
|
||||
end
|
||||
end
|
||||
poll()
|
||||
return
|
||||
end
|
||||
token_loading = true
|
||||
local dir = get_data_dir()
|
||||
-- Expand a leading "~/" to the user's home directory here (not in the
|
||||
-- shell) so the path is an absolute path with no shell variable expansion
|
||||
-- and can be safely single-quoted.
|
||||
if dir:sub(1, 2) == "~/" then
|
||||
dir = (noctalia.getenv and noctalia.getenv("HOME") or "") .. dir:sub(2)
|
||||
end
|
||||
local cmd = "printf '%s' \"$(cat " .. shell_quote(dir .. "/machine-id") .. " 2>/dev/null)9r-cli-auth$(cat " .. shell_quote(dir .. "/auth/cli-secret") .. " 2>/dev/null)\" | sha256sum | cut -c1-16"
|
||||
noctalia.runAsync(cmd, function(result)
|
||||
token_loading = false
|
||||
local token = ""
|
||||
if result and result.exitCode == 0 and type(result.stdout) == "string" then
|
||||
token = noctalia.string and noctalia.string.trim and noctalia.string.trim(result.stdout) or result.stdout
|
||||
end
|
||||
if token ~= "" then
|
||||
cli_token = token
|
||||
debug_log("computed CLI token")
|
||||
end
|
||||
callback(token ~= "" and token or nil)
|
||||
end)
|
||||
end
|
||||
|
||||
-- Perform a request with auth fallback. `with_token` is true on the retry pass.
|
||||
-- Once a CLI token or login cookie is known it is attached to every request, so
|
||||
-- the retry only fires when a 401 arrives before we have any auth material.
|
||||
local function request(method, path, body, callback, with_token)
|
||||
local url = (base_url or build_base_url()) .. path
|
||||
local headers = { "Accept: application/json" }
|
||||
local body_str = nil
|
||||
if body then
|
||||
body_str = noctalia.json.encode(body)
|
||||
table.insert(headers, "Content-Type: application/json")
|
||||
end
|
||||
-- Attach the CLI token on every request once it is known.
|
||||
if cli_token then
|
||||
table.insert(headers, "x-9r-cli-token: " .. cli_token)
|
||||
end
|
||||
-- Attach the login cookie on every request once it is known.
|
||||
if auth_token then
|
||||
table.insert(headers, "Cookie: auth_token=" .. auth_token)
|
||||
end
|
||||
noctalia.http({
|
||||
url = url,
|
||||
method = method,
|
||||
headers = headers,
|
||||
body = body_str,
|
||||
follow_redirects = false,
|
||||
}, function(response)
|
||||
if not response then
|
||||
callback({ ok = false, status = 0, body = "" })
|
||||
return
|
||||
end
|
||||
local status = response.status or 0
|
||||
-- On 401: if we have not tried with auth yet, compute the CLI token (if
|
||||
-- needed) and retry once. If we already have auth material but still got
|
||||
-- a 401, the token/cookie is stale — surface it so the UI can re-login.
|
||||
if status == 401 and not with_token then
|
||||
if cli_token or auth_token then
|
||||
request(method, path, body, callback, true)
|
||||
else
|
||||
compute_cli_token(function(token)
|
||||
if token then
|
||||
request(method, path, body, callback, true)
|
||||
else
|
||||
callback({ ok = false, status = status, body = response.body })
|
||||
end
|
||||
end)
|
||||
end
|
||||
return
|
||||
end
|
||||
callback({ ok = status >= 200 and status < 300, status = status, body = response.body })
|
||||
end)
|
||||
end
|
||||
|
||||
local function decode_body(body)
|
||||
if type(body) ~= "string" or body == "" then return nil end
|
||||
local ok, data = pcall(noctalia.json.decode, body)
|
||||
if not ok or type(data) ~= "table" then return nil end
|
||||
return data
|
||||
end
|
||||
|
||||
-- ── combo operations ─────────────────────────────────────────────────────────
|
||||
|
||||
local function load_combos()
|
||||
request("GET", "/api/combos", nil, function(resp)
|
||||
if not resp.ok then
|
||||
if resp.status == 0 then
|
||||
set_connection("offline", tr("error.connection_failed"))
|
||||
elseif resp.status == 401 then
|
||||
set_connection("auth_required", tr("error.auth_required"))
|
||||
else
|
||||
set_connection("offline", tr("error.http_status", { status = tostring(resp.status) }))
|
||||
end
|
||||
return
|
||||
end
|
||||
local data = decode_body(resp.body)
|
||||
if not data or type(data.combos) ~= "table" then
|
||||
set_connection("offline", tr("error.bad_response"))
|
||||
return
|
||||
end
|
||||
combos = data.combos
|
||||
set_connection("online", "")
|
||||
clear_last_error()
|
||||
publish()
|
||||
end)
|
||||
end
|
||||
|
||||
local function refresh_combos()
|
||||
load_combos()
|
||||
end
|
||||
|
||||
local function load_models()
|
||||
request("GET", "/api/models", nil, function(resp)
|
||||
if not resp.ok then return end
|
||||
local data = decode_body(resp.body)
|
||||
if not data or type(data.models) ~= "table" then return end
|
||||
models = data.models
|
||||
publish()
|
||||
end)
|
||||
end
|
||||
|
||||
local function select_combo(id)
|
||||
selected_combo_id = id
|
||||
publish()
|
||||
end
|
||||
|
||||
local function back_to_list()
|
||||
selected_combo_id = nil
|
||||
publish()
|
||||
end
|
||||
|
||||
local function find_combo(id)
|
||||
for _, c in ipairs(combos) do
|
||||
if type(c) == "table" and c.id == id then return c end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function reorder_model(payload)
|
||||
-- payload: "combo_id:index:direction"
|
||||
local id, idx_str, direction = payload:match("^([^:]+):(%d+):(%w+)$")
|
||||
if not id or not idx_str or not direction then return end
|
||||
local combo = find_combo(id)
|
||||
if not combo or type(combo.models) ~= "table" then return end
|
||||
local idx = tonumber(idx_str)
|
||||
local models = {}
|
||||
for _, m in ipairs(combo.models) do models[#models + 1] = m end
|
||||
local n = #models
|
||||
if idx < 1 or idx > n then return end
|
||||
local target = direction == "up" and idx - 1 or idx + 1
|
||||
if target < 1 or target > n then return end
|
||||
models[idx], models[target] = models[target], models[idx]
|
||||
request("PUT", "/api/combos/" .. id, { models = models }, function(resp)
|
||||
if resp.ok then
|
||||
refresh_combos()
|
||||
else
|
||||
set_last_error(tr("error.reorder_failed"), tr("error.http_status", { status = tostring(resp.status) }))
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
local function move_model(payload)
|
||||
-- payload: "combo_id:from_index:to_index" (1-based)
|
||||
local id, from_str, to_str = payload:match("^([^:]+):(%d+):(%d+)$")
|
||||
if not id or not from_str or not to_str then return end
|
||||
local combo = find_combo(id)
|
||||
if not combo or type(combo.models) ~= "table" then return end
|
||||
local from = tonumber(from_str)
|
||||
local to = tonumber(to_str)
|
||||
local models = {}
|
||||
for _, m in ipairs(combo.models) do models[#models + 1] = m end
|
||||
local n = #models
|
||||
if from < 1 or from > n or to < 1 or to > n or from == to then return end
|
||||
local moved = table.remove(models, from)
|
||||
table.insert(models, to, moved)
|
||||
request("PUT", "/api/combos/" .. id, { models = models }, function(resp)
|
||||
if resp.ok then
|
||||
refresh_combos()
|
||||
else
|
||||
set_last_error(tr("error.reorder_failed"), tr("error.http_status", { status = tostring(resp.status) }))
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
local function add_model(payload)
|
||||
-- payload: "combo_id:model_fullModel" (provider/model)
|
||||
local id, model = payload:match("^([^:]+):(.+)$")
|
||||
if not id or not model or model == "" then return end
|
||||
local combo = find_combo(id)
|
||||
if not combo or type(combo.models) ~= "table" then return end
|
||||
local models = {}
|
||||
for _, m in ipairs(combo.models) do models[#models + 1] = m end
|
||||
for _, m in ipairs(models) do
|
||||
if m == model then return end -- already present
|
||||
end
|
||||
models[#models + 1] = model
|
||||
request("PUT", "/api/combos/" .. id, { models = models }, function(resp)
|
||||
if resp.ok then
|
||||
refresh_combos()
|
||||
else
|
||||
set_last_error(tr("error.add_model_failed"), tr("error.http_status", { status = tostring(resp.status) }))
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
local function remove_model(payload)
|
||||
-- payload: "combo_id:index" (1-based)
|
||||
local id, idx_str = payload:match("^([^:]+):(%d+)$")
|
||||
if not id or not idx_str then return end
|
||||
local combo = find_combo(id)
|
||||
if not combo or type(combo.models) ~= "table" then return end
|
||||
local idx = tonumber(idx_str)
|
||||
local models = {}
|
||||
for _, m in ipairs(combo.models) do models[#models + 1] = m end
|
||||
if idx < 1 or idx > #models then return end
|
||||
table.remove(models, idx)
|
||||
request("PUT", "/api/combos/" .. id, { models = models }, function(resp)
|
||||
if resp.ok then
|
||||
refresh_combos()
|
||||
else
|
||||
set_last_error(tr("error.remove_model_failed"), tr("error.http_status", { status = tostring(resp.status) }))
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
local function create_combo(payload)
|
||||
-- payload: JSON string { name, models, kind }
|
||||
local ok, data = pcall(noctalia.json.decode, payload)
|
||||
if not ok or type(data) ~= "table" then return end
|
||||
local name = type(data.name) == "string" and data.name or ""
|
||||
local models = type(data.models) == "table" and data.models or {}
|
||||
local kind = type(data.kind) == "string" and data.kind or nil
|
||||
if name == "" or #models == 0 then
|
||||
set_last_error(tr("error.create_failed"), tr("error.need_name_models"))
|
||||
return
|
||||
end
|
||||
request("POST", "/api/combos", { name = name, models = models, kind = kind }, function(resp)
|
||||
if resp.ok then
|
||||
refresh_combos()
|
||||
else
|
||||
local data2 = decode_body(resp.body)
|
||||
local detail = (type(data2) == "table" and type(data2.error) == "string" and data2.error)
|
||||
or tr("error.http_status", { status = tostring(resp.status) })
|
||||
set_last_error(tr("error.create_failed"), detail)
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
local function delete_combo(id)
|
||||
request("DELETE", "/api/combos/" .. id, nil, function(resp)
|
||||
if resp.ok then
|
||||
if selected_combo_id == id then selected_combo_id = nil end
|
||||
refresh_combos()
|
||||
else
|
||||
set_last_error(tr("error.delete_failed"), tr("error.http_status", { status = tostring(resp.status) }))
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
local function rename_combo(payload)
|
||||
-- payload: "combo_id:new_name"
|
||||
local id, name = payload:match("^([^:]+):(.+)$")
|
||||
if not id or not name or name == "" then return end
|
||||
request("PUT", "/api/combos/" .. id, { name = name }, function(resp)
|
||||
if resp.ok then
|
||||
refresh_combos()
|
||||
else
|
||||
local data = decode_body(resp.body)
|
||||
local detail = (type(data) == "table" and type(data.error) == "string" and data.error)
|
||||
or tr("error.http_status", { status = tostring(resp.status) })
|
||||
set_last_error(tr("error.rename_failed"), detail)
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
local function set_kind(payload)
|
||||
-- payload: "combo_id:kind"
|
||||
local id, kind = payload:match("^([^:]+):(.+)$")
|
||||
if not id or not kind then return end
|
||||
local value = (kind == "none" or kind == "") and nil or kind
|
||||
request("PUT", "/api/combos/" .. id, { kind = value }, function(resp)
|
||||
if resp.ok then
|
||||
refresh_combos()
|
||||
else
|
||||
set_last_error(tr("error.kind_failed"), tr("error.http_status", { status = tostring(resp.status) }))
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
-- ── auth (login / logout) ────────────────────────────────────────────────────
|
||||
|
||||
-- Log in against the dashboard and capture the auth_token cookie. Noctalia's
|
||||
-- http binding does not expose response headers, so we shell out to curl with
|
||||
-- `-i` to read the Set-Cookie header, then reuse the cookie on later requests.
|
||||
local function login(payload)
|
||||
if type(payload) ~= "string" or payload == "" then
|
||||
set_last_error(tr("error.login_failed"), tr("error.need_password"))
|
||||
return
|
||||
end
|
||||
local url = (base_url or build_base_url()) .. "/api/auth/login"
|
||||
-- Escape the password for the JSON body, then for the shell single-quote.
|
||||
local json_password = payload:gsub("\\", "\\\\"):gsub('"', '\\"')
|
||||
local body = '{"password":"' .. json_password .. '"}'
|
||||
local cmd = "curl -s -i -X POST " .. shell_quote(url) .. " -H 'Content-Type: application/json' -d " .. shell_quote(body)
|
||||
debug_log("login: posting to /api/auth/login")
|
||||
noctalia.runAsync(cmd, function(result)
|
||||
if not result or result.exitCode ~= 0 then
|
||||
set_last_error(tr("error.login_failed"), tr("error.connection_failed"))
|
||||
return
|
||||
end
|
||||
local stdout = result.stdout or ""
|
||||
local cookie = stdout:match("auth_token=([^;%s]+)")
|
||||
if not cookie then
|
||||
-- No cookie set — likely a bad password. Surface the server error.
|
||||
local body_part = stdout:match("\r?\n\r?\n(.*)$") or stdout
|
||||
local ok, data = pcall(noctalia.json.decode, body_part)
|
||||
local detail = (ok and type(data) == "table" and type(data.error) == "string" and data.error)
|
||||
or tr("error.login_failed")
|
||||
set_last_error(tr("error.login_failed"), detail)
|
||||
return
|
||||
end
|
||||
auth_token = cookie
|
||||
debug_log("login: authenticated")
|
||||
clear_last_error()
|
||||
set_connection("online", "")
|
||||
load_combos()
|
||||
end)
|
||||
end
|
||||
|
||||
local function logout()
|
||||
auth_token = nil
|
||||
cli_token = nil
|
||||
set_connection("auth_required", tr("error.auth_required"))
|
||||
end
|
||||
|
||||
-- ── ipc handling ─────────────────────────────────────────────────────────────
|
||||
|
||||
function onIpc(event, payload)
|
||||
debug_log("IPC: " .. tostring(event))
|
||||
if event == "refresh" then
|
||||
refresh_combos()
|
||||
elseif event == "select_combo" then
|
||||
if type(payload) == "string" then select_combo(payload) end
|
||||
elseif event == "back_to_list" then
|
||||
back_to_list()
|
||||
elseif event == "reorder_model" then
|
||||
if type(payload) == "string" then reorder_model(payload) end
|
||||
elseif event == "move_model" then
|
||||
if type(payload) == "string" then move_model(payload) end
|
||||
elseif event == "add_model" then
|
||||
if type(payload) == "string" then add_model(payload) end
|
||||
elseif event == "remove_model" then
|
||||
if type(payload) == "string" then remove_model(payload) end
|
||||
elseif event == "create_combo" then
|
||||
if type(payload) == "string" then create_combo(payload) end
|
||||
elseif event == "delete_combo" then
|
||||
if type(payload) == "string" then delete_combo(payload) end
|
||||
elseif event == "rename_combo" then
|
||||
if type(payload) == "string" then rename_combo(payload) end
|
||||
elseif event == "set_kind" then
|
||||
if type(payload) == "string" then set_kind(payload) end
|
||||
elseif event == "login" then
|
||||
if type(payload) == "string" then login(payload) end
|
||||
elseif event == "logout" then
|
||||
logout()
|
||||
elseif event == "clear_error" then
|
||||
clear_last_error()
|
||||
end
|
||||
end
|
||||
|
||||
-- ── init ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
function onOpen()
|
||||
base_url = build_base_url()
|
||||
publish()
|
||||
load_combos()
|
||||
load_models()
|
||||
end
|
||||
|
||||
function onExit()
|
||||
end
|
||||
|
||||
-- Start on load
|
||||
onOpen()
|
||||
Reference in New Issue
Block a user