* 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.
902 lines
32 KiB
Luau
902 lines
32 KiB
Luau
-- 9Router Control — the combo management surface.
|
|
--
|
|
-- Three views:
|
|
-- • List — all combos with search + "new combo" button
|
|
-- • Detail — a single combo's model chain with reorder/edit/delete
|
|
-- • Create — a form to build a new combo
|
|
--
|
|
-- Pure subscriber of 9router.* state. User actions are dispatched as IPC
|
|
-- events to the service.
|
|
|
|
local STATE = {
|
|
connection = "9router.connection",
|
|
combos = "9router.combos",
|
|
models = "9router.models",
|
|
selected = "9router.selected_combo",
|
|
last_error = "9router.last_error",
|
|
}
|
|
|
|
local PAD = 12
|
|
local WRAP = 480
|
|
|
|
-- Confirmation / form state
|
|
local pending_delete = nil
|
|
local rename_id = nil
|
|
local rename_value = ""
|
|
local creating = false
|
|
local create_name = ""
|
|
local create_models = ""
|
|
local create_kind = "none"
|
|
local search_query = ""
|
|
local login_password = ""
|
|
local adding_model = false
|
|
local model_search = ""
|
|
local create_error = ""
|
|
local rename_error = ""
|
|
|
|
local KIND_OPTIONS = {
|
|
{ value = "none", label_key = "kind.none" },
|
|
{ value = "fallback", label_key = "kind.fallback" },
|
|
{ value = "round-robin", label_key = "kind.round_robin" },
|
|
{ value = "fusion", label_key = "kind.fusion" },
|
|
}
|
|
|
|
-- ── i18n ─────────────────────────────────────────────────────────────────────
|
|
|
|
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 kind_label(value)
|
|
for _, o in ipairs(KIND_OPTIONS) do
|
|
if o.value == value then return tr(o.label_key) end
|
|
end
|
|
return value or tr("kind.none")
|
|
end
|
|
|
|
-- ── helpers ─────────────────────────────────────────────────────────────────
|
|
|
|
local function dispatch_ipc(event, payload)
|
|
local cmd = "noctalia msg plugin 'weinguyen/9router-control:service' all " .. event
|
|
if payload ~= nil then
|
|
-- Shell-escape the payload (single-quote wrap + escape embedded quotes).
|
|
payload = tostring(payload):gsub("'", "'\\''")
|
|
cmd = cmd .. " '" .. payload .. "'"
|
|
end
|
|
noctalia.runAsync(cmd)
|
|
end
|
|
|
|
local function find_combo(combos, id)
|
|
if type(combos) ~= "table" then return nil end
|
|
for _, c in ipairs(combos) do
|
|
if type(c) == "table" and c.id == id then return c end
|
|
end
|
|
return nil
|
|
end
|
|
|
|
local function model_chain_text(models)
|
|
if type(models) ~= "table" then return "" end
|
|
local parts = {}
|
|
for _, m in ipairs(models) do
|
|
if type(m) == "string" and m ~= "" then parts[#parts + 1] = m end
|
|
end
|
|
return table.concat(parts, " → ")
|
|
end
|
|
|
|
-- 9Router combo name rule: only a-z, A-Z, 0-9, -, _ and .
|
|
local NAME_RE = "^[%w%.%-]+$"
|
|
local function valid_name(name)
|
|
return type(name) == "string" and name ~= "" and string.match(name, NAME_RE) ~= nil
|
|
end
|
|
|
|
-- Compact fingerprint of the combo list (names + model chains + kinds) so a
|
|
-- reorder/edit that keeps the count the same still triggers a re-render.
|
|
local function combos_fingerprint(combos)
|
|
if type(combos) ~= "table" then return "0" end
|
|
local parts = {}
|
|
for _, c in ipairs(combos) do
|
|
if type(c) == "table" then
|
|
parts[#parts + 1] = tostring(c.id or "") .. "|" .. tostring(c.name or "") .. "|" .. tostring(c.kind or "") .. "|" .. model_chain_text(c.models)
|
|
end
|
|
end
|
|
return table.concat(parts, "\1")
|
|
end
|
|
|
|
-- ── list view ───────────────────────────────────────────────────────────────
|
|
|
|
local function render_list(combos, conn)
|
|
local rows = {}
|
|
|
|
-- Header
|
|
rows[#rows + 1] = ui.row({ justify = "space_between", align = "center" }, {
|
|
ui.row({ gap = 8, align = "center" }, {
|
|
ui.glyph({ name = "route", color = "primary", size = 24 }),
|
|
ui.label({ text = tr("list.title"), fontSize = 18, fontWeight = "bold", color = "on_surface" }),
|
|
}),
|
|
ui.row({ gap = 8 }, {
|
|
ui.button({ glyph = "refresh", onClick = function()
|
|
dispatch_ipc("refresh")
|
|
end, tooltip = tr("list.refresh") }),
|
|
}),
|
|
})
|
|
|
|
rows[#rows + 1] = ui.separator({})
|
|
|
|
-- Error banner (if any)
|
|
local err = noctalia.state.get(STATE.last_error)
|
|
if type(err) == "table" and err.message then
|
|
rows[#rows + 1] = ui.row({ gap = 8, align = "center", padding = 8, radius = 8, fill = "error/0.15", border = "error", borderWidth = 1 }, {
|
|
ui.glyph({ name = "alert-circle", color = "error", size = 16 }),
|
|
ui.column({ gap = 2, flexGrow = 1 }, {
|
|
ui.label({ text = err.message, color = "error", fontSize = 13, fontWeight = "bold", maxWidth = WRAP - 40 }),
|
|
ui.label({ text = err.detail or "", fontSize = 11, opacity = 0.75, maxWidth = WRAP - 40 }),
|
|
}),
|
|
ui.button({
|
|
glyph = "x",
|
|
variant = "ghost",
|
|
tooltip = tr("error.dismiss"),
|
|
onClick = function()
|
|
dispatch_ipc("clear_error")
|
|
end,
|
|
}),
|
|
})
|
|
end
|
|
|
|
-- New combo button
|
|
rows[#rows + 1] = ui.button({
|
|
text = tr("list.new_combo"),
|
|
glyph = "plus",
|
|
onClick = function()
|
|
creating = true
|
|
create_name = ""
|
|
create_models = ""
|
|
create_kind = "none"
|
|
render()
|
|
end,
|
|
})
|
|
|
|
-- Search box
|
|
rows[#rows + 1] = ui.input({
|
|
key = "combo_search",
|
|
value = search_query,
|
|
placeholder = tr("list.search"),
|
|
onChange = function(text)
|
|
search_query = text or ""
|
|
render()
|
|
end,
|
|
})
|
|
|
|
-- Filter combos by search query
|
|
local visible = combos
|
|
if type(combos) == "table" and search_query ~= "" then
|
|
local needle = string.lower(search_query)
|
|
visible = {}
|
|
for _, c in ipairs(combos) do
|
|
if type(c) == "table" then
|
|
local hay = string.lower(tostring(c.name or "") .. " " .. model_chain_text(c.models))
|
|
if string.find(hay, needle, 1, true) then
|
|
visible[#visible + 1] = c
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Combo list
|
|
local list_items = {}
|
|
if type(visible) ~= "table" or #visible == 0 then
|
|
list_items[#list_items + 1] = ui.label({
|
|
text = search_query ~= "" and tr("list.no_match") or tr("list.empty"),
|
|
maxWidth = WRAP,
|
|
opacity = 0.7,
|
|
})
|
|
else
|
|
for _, c in ipairs(visible) do
|
|
if type(c) == "table" and c.id then
|
|
local combo_id = c.id
|
|
local name = c.name or c.id
|
|
local chain = model_chain_text(c.models)
|
|
local kind = kind_label(c.kind)
|
|
|
|
local function open_this()
|
|
dispatch_ipc("select_combo", combo_id)
|
|
end
|
|
|
|
local actions
|
|
if pending_delete == combo_id then
|
|
actions = ui.row({ gap = 6 }, {
|
|
ui.button({
|
|
glyph = "check",
|
|
variant = "primary",
|
|
tooltip = tr("list.delete_confirm"),
|
|
onClick = function()
|
|
dispatch_ipc("delete_combo", combo_id)
|
|
pending_delete = nil
|
|
render()
|
|
end,
|
|
}),
|
|
ui.button({
|
|
glyph = "x",
|
|
variant = "secondary",
|
|
tooltip = tr("list.delete_cancel"),
|
|
onClick = function()
|
|
pending_delete = nil
|
|
render()
|
|
end,
|
|
}),
|
|
})
|
|
else
|
|
actions = ui.row({ gap = 6 }, {
|
|
ui.button({
|
|
glyph = "trash",
|
|
variant = "secondary",
|
|
tooltip = tr("list.delete"),
|
|
onClick = function()
|
|
pending_delete = combo_id
|
|
render()
|
|
end,
|
|
}),
|
|
ui.button({
|
|
glyph = "arrow-right",
|
|
variant = "primary",
|
|
tooltip = tr("list.open"),
|
|
onClick = open_this,
|
|
}),
|
|
})
|
|
end
|
|
|
|
list_items[#list_items + 1] = ui.row({
|
|
key = combo_id,
|
|
justify = "space_between",
|
|
align = "center",
|
|
padding = 10,
|
|
radius = 10,
|
|
fill = "surface",
|
|
border = "surface",
|
|
borderWidth = 1,
|
|
}, {
|
|
ui.column({
|
|
gap = 2, flexGrow = 1,
|
|
onClick = open_this,
|
|
}, {
|
|
ui.label({
|
|
text = name,
|
|
maxWidth = WRAP - 140,
|
|
fontWeight = "bold",
|
|
color = "on_surface",
|
|
}),
|
|
ui.label({
|
|
text = chain,
|
|
maxWidth = WRAP - 140,
|
|
opacity = 0.6,
|
|
fontSize = 12,
|
|
}),
|
|
ui.label({
|
|
text = tr("list.kind", { kind = kind }),
|
|
maxWidth = WRAP - 140,
|
|
opacity = 0.5,
|
|
fontSize = 11,
|
|
}),
|
|
}),
|
|
actions,
|
|
})
|
|
end
|
|
end
|
|
end
|
|
|
|
rows[#rows + 1] = ui.scroll({ flexGrow = 1, gap = 8, align = "stretch", justify = "start" }, list_items)
|
|
|
|
panel.render(ui.column({ padding = PAD, gap = 8, flexGrow = 1, align = "stretch", justify = "start" }, rows))
|
|
end
|
|
|
|
-- ── create view ──────────────────────────────────────────────────────────────
|
|
|
|
local function render_create()
|
|
local rows = {}
|
|
|
|
rows[#rows + 1] = ui.row({ justify = "space_between", align = "center" }, {
|
|
ui.row({ gap = 8, align = "center" }, {
|
|
ui.button({
|
|
glyph = "chevron-left",
|
|
variant = "ghost",
|
|
tooltip = tr("create.back"),
|
|
onClick = function()
|
|
creating = false
|
|
render()
|
|
end,
|
|
}),
|
|
ui.glyph({ name = "plus", color = "primary", size = 20 }),
|
|
ui.label({ text = tr("create.title"), fontSize = 18, fontWeight = "bold", color = "on_surface" }),
|
|
}),
|
|
})
|
|
|
|
rows[#rows + 1] = ui.separator({})
|
|
|
|
rows[#rows + 1] = ui.label({ text = tr("create.name_label"), fontWeight = "bold", fontSize = 13 })
|
|
rows[#rows + 1] = ui.input({
|
|
key = "create_name",
|
|
value = create_name,
|
|
placeholder = tr("create.name_placeholder"),
|
|
onChange = function(text)
|
|
create_name = text or ""
|
|
create_error = ""
|
|
end,
|
|
})
|
|
|
|
if create_error ~= "" then
|
|
rows[#rows + 1] = ui.label({ text = create_error, color = "error", fontSize = 12, maxWidth = WRAP })
|
|
end
|
|
|
|
rows[#rows + 1] = ui.label({ text = tr("create.models_label"), fontWeight = "bold", fontSize = 13 })
|
|
rows[#rows + 1] = ui.label({ text = tr("create.models_hint"), opacity = 0.6, fontSize = 11, maxWidth = WRAP })
|
|
rows[#rows + 1] = ui.input({
|
|
key = "create_models",
|
|
value = create_models,
|
|
placeholder = tr("create.models_placeholder"),
|
|
multiline = true,
|
|
flexGrow = 1,
|
|
onChange = function(text)
|
|
create_models = text or ""
|
|
end,
|
|
})
|
|
|
|
rows[#rows + 1] = ui.label({ text = tr("create.kind_label"), fontWeight = "bold", fontSize = 13 })
|
|
local kind_labels = {}
|
|
local kind_values = {}
|
|
local kind_index = nil
|
|
for i, o in ipairs(KIND_OPTIONS) do
|
|
kind_labels[i] = tr(o.label_key)
|
|
kind_values[i] = o.value
|
|
if o.value == create_kind then kind_index = i - 1 end
|
|
end
|
|
local kind_props = {
|
|
key = "create_kind",
|
|
options = kind_labels,
|
|
placeholder = tr("create.kind_label"),
|
|
onChange = function(idx)
|
|
local n = tonumber(idx)
|
|
if n ~= nil then
|
|
create_kind = kind_values[math.floor(n) + 1] or "none"
|
|
end
|
|
end,
|
|
}
|
|
if kind_index ~= nil then kind_props.selectedIndex = kind_index end
|
|
rows[#rows + 1] = ui.select(kind_props)
|
|
|
|
rows[#rows + 1] = ui.separator({})
|
|
|
|
rows[#rows + 1] = ui.row({ justify = "end", gap = 8 }, {
|
|
ui.button({
|
|
text = tr("create.cancel"),
|
|
variant = "secondary",
|
|
onClick = function()
|
|
creating = false
|
|
render()
|
|
end,
|
|
}),
|
|
ui.button({
|
|
text = tr("create.submit"),
|
|
glyph = "check",
|
|
variant = "primary",
|
|
onClick = function()
|
|
if not valid_name(create_name) then
|
|
create_error = tr("error.invalid_name")
|
|
render()
|
|
return
|
|
end
|
|
local models = {}
|
|
for line in string.gmatch(create_models, "[^\n]+") do
|
|
local m = line:gsub("^%s+", ""):gsub("%s+$", "")
|
|
if m ~= "" then models[#models + 1] = m end
|
|
end
|
|
if #models == 0 then
|
|
create_error = tr("error.need_name_models")
|
|
render()
|
|
return
|
|
end
|
|
local payload = noctalia.json.encode({
|
|
name = create_name,
|
|
models = models,
|
|
kind = (create_kind == "none" or create_kind == "") and nil or create_kind,
|
|
})
|
|
dispatch_ipc("create_combo", payload)
|
|
creating = false
|
|
create_error = ""
|
|
render()
|
|
end,
|
|
}),
|
|
})
|
|
|
|
panel.render(ui.column({ padding = PAD, gap = 8, flexGrow = 1, align = "stretch", justify = "start" }, rows))
|
|
end
|
|
|
|
-- ── detail view ─────────────────────────────────────────────────────────────
|
|
|
|
local function render_detail(combo)
|
|
local rows = {}
|
|
|
|
-- Header with back button
|
|
rows[#rows + 1] = ui.row({ justify = "space_between", align = "center" }, {
|
|
ui.row({ gap = 8, align = "center", flexGrow = 1 }, {
|
|
ui.button({
|
|
glyph = "chevron-left",
|
|
variant = "ghost",
|
|
tooltip = tr("detail.back"),
|
|
onClick = function()
|
|
dispatch_ipc("back_to_list")
|
|
end,
|
|
}),
|
|
ui.glyph({ name = "route", color = "primary", size = 20 }),
|
|
ui.column({ gap = 0, flexGrow = 1 }, {
|
|
ui.label({ text = combo.name or combo.id, fontWeight = "bold", maxWidth = WRAP - 200, fontSize = 14 }),
|
|
ui.label({ text = tr("detail.models_count", { count = #(combo.models or {}) }), opacity = 0.6, fontSize = 11 }),
|
|
}),
|
|
}),
|
|
ui.row({ gap = 6 }, {
|
|
ui.button({
|
|
glyph = "refresh",
|
|
tooltip = tr("detail.refresh"),
|
|
onClick = function()
|
|
dispatch_ipc("refresh")
|
|
end,
|
|
}),
|
|
ui.button({
|
|
glyph = "trash",
|
|
variant = "secondary",
|
|
tooltip = tr("detail.delete"),
|
|
onClick = function()
|
|
pending_delete = combo.id
|
|
render()
|
|
end,
|
|
}),
|
|
}),
|
|
})
|
|
|
|
rows[#rows + 1] = ui.separator({})
|
|
|
|
-- Inline delete confirmation
|
|
if pending_delete == combo.id then
|
|
rows[#rows + 1] = ui.row({ justify = "space_between", align = "center", padding = 8, radius = 8, fill = "error/0.15", border = "error", borderWidth = 1 }, {
|
|
ui.label({ text = tr("detail.delete_confirm"), color = "error", fontWeight = "bold", maxWidth = WRAP - 120 }),
|
|
ui.row({ gap = 6 }, {
|
|
ui.button({
|
|
glyph = "check",
|
|
variant = "primary",
|
|
tooltip = tr("detail.delete_yes"),
|
|
onClick = function()
|
|
dispatch_ipc("delete_combo", combo.id)
|
|
pending_delete = nil
|
|
render()
|
|
end,
|
|
}),
|
|
ui.button({
|
|
glyph = "x",
|
|
variant = "secondary",
|
|
tooltip = tr("detail.delete_no"),
|
|
onClick = function()
|
|
pending_delete = nil
|
|
render()
|
|
end,
|
|
}),
|
|
}),
|
|
})
|
|
end
|
|
|
|
-- Kind selector
|
|
local kind_labels = {}
|
|
local kind_values = {}
|
|
local kind_index = nil
|
|
for i, o in ipairs(KIND_OPTIONS) do
|
|
kind_labels[i] = tr(o.label_key)
|
|
kind_values[i] = o.value
|
|
if o.value == (combo.kind or "none") then kind_index = i - 1 end
|
|
end
|
|
local kind_props = {
|
|
key = "detail_kind",
|
|
options = kind_labels,
|
|
placeholder = tr("detail.kind_label"),
|
|
flexGrow = 1,
|
|
onChange = function(idx)
|
|
local n = tonumber(idx)
|
|
if n ~= nil then
|
|
local chosen = kind_values[math.floor(n) + 1] or "none"
|
|
dispatch_ipc("set_kind", combo.id .. ":" .. chosen)
|
|
end
|
|
end,
|
|
}
|
|
if kind_index ~= nil then kind_props.selectedIndex = kind_index end
|
|
rows[#rows + 1] = ui.row({ gap = 8, align = "center" }, {
|
|
ui.label({ text = tr("detail.kind_label"), fontWeight = "bold", fontSize = 13 }),
|
|
ui.select(kind_props),
|
|
})
|
|
|
|
-- Rename
|
|
if rename_id == combo.id then
|
|
rows[#rows + 1] = ui.row({ gap = 8, align = "center" }, {
|
|
ui.input({
|
|
key = "rename_input",
|
|
value = rename_value,
|
|
placeholder = tr("detail.rename_placeholder"),
|
|
flexGrow = 1,
|
|
onChange = function(text)
|
|
rename_value = text or ""
|
|
rename_error = ""
|
|
end,
|
|
}),
|
|
ui.button({
|
|
glyph = "check",
|
|
variant = "primary",
|
|
tooltip = tr("detail.rename_save"),
|
|
onClick = function()
|
|
if not valid_name(rename_value) then
|
|
rename_error = tr("error.invalid_name")
|
|
render()
|
|
return
|
|
end
|
|
dispatch_ipc("rename_combo", combo.id .. ":" .. rename_value)
|
|
rename_id = nil
|
|
rename_value = ""
|
|
rename_error = ""
|
|
render()
|
|
end,
|
|
}),
|
|
ui.button({
|
|
glyph = "x",
|
|
variant = "secondary",
|
|
tooltip = tr("detail.rename_cancel"),
|
|
onClick = function()
|
|
rename_id = nil
|
|
rename_value = ""
|
|
rename_error = ""
|
|
render()
|
|
end,
|
|
}),
|
|
})
|
|
if rename_error ~= "" then
|
|
rows[#rows + 1] = ui.label({ text = rename_error, color = "error", fontSize = 12, maxWidth = WRAP })
|
|
end
|
|
else
|
|
rows[#rows + 1] = ui.button({
|
|
text = tr("detail.rename"),
|
|
glyph = "pencil",
|
|
variant = "outline",
|
|
onClick = function()
|
|
rename_id = combo.id
|
|
rename_value = combo.name or ""
|
|
render()
|
|
end,
|
|
})
|
|
end
|
|
|
|
rows[#rows + 1] = ui.separator({})
|
|
|
|
-- Model chain (reorderable via drag-and-drop)
|
|
rows[#rows + 1] = ui.row({ justify = "space_between", align = "center" }, {
|
|
ui.label({ text = tr("detail.models_title"), fontWeight = "bold", fontSize = 13 }),
|
|
ui.button({
|
|
glyph = "plus",
|
|
variant = "secondary",
|
|
tooltip = tr("detail.add_model"),
|
|
onClick = function()
|
|
adding_model = true
|
|
model_search = ""
|
|
render()
|
|
end,
|
|
}),
|
|
})
|
|
local models = combo.models or {}
|
|
local model_items = {}
|
|
if #models == 0 then
|
|
model_items[#model_items + 1] = ui.label({ text = tr("detail.no_models"), opacity = 0.7, maxWidth = WRAP })
|
|
else
|
|
model_items[#model_items + 1] = ui.label({ text = tr("detail.drag_hint"), opacity = 0.6, fontSize = 11, maxWidth = WRAP })
|
|
for i, m in ipairs(models) do
|
|
local idx = i
|
|
model_items[#model_items + 1] = ui.dropZone({
|
|
key = "drop_" .. tostring(idx),
|
|
value = tostring(idx),
|
|
accepts = { "model" },
|
|
onDrop = function(payload, target)
|
|
dispatch_ipc("move_model", combo.id .. ":" .. tostring(payload) .. ":" .. tostring(target))
|
|
end,
|
|
direction = "column",
|
|
gap = 6,
|
|
padding = 2,
|
|
radius = 8,
|
|
}, {
|
|
ui.dragSource({
|
|
key = "drag_" .. tostring(idx),
|
|
dragType = "model",
|
|
payload = tostring(idx),
|
|
}, {
|
|
ui.row({
|
|
justify = "space_between",
|
|
align = "center",
|
|
padding = 8,
|
|
radius = 8,
|
|
fill = "surface",
|
|
border = "surface",
|
|
borderWidth = 1,
|
|
}, {
|
|
ui.row({ gap = 8, align = "center", flexGrow = 1 }, {
|
|
ui.glyph({ name = "grip-vertical", color = "on_surface", size = 16, opacity = 0.5 }),
|
|
ui.label({ text = tostring(idx), color = "primary", fontWeight = "bold", fontSize = 12 }),
|
|
ui.label({ text = tostring(m), maxWidth = WRAP - 140, fontSize = 13 }),
|
|
}),
|
|
ui.button({
|
|
glyph = "x",
|
|
variant = "ghost",
|
|
tooltip = tr("detail.remove_model"),
|
|
onClick = function()
|
|
dispatch_ipc("remove_model", combo.id .. ":" .. tostring(idx))
|
|
end,
|
|
}),
|
|
}),
|
|
}),
|
|
})
|
|
end
|
|
end
|
|
rows[#rows + 1] = ui.scroll({ flexGrow = 1, gap = 6, align = "stretch", justify = "start" }, model_items)
|
|
|
|
panel.render(ui.column({ padding = PAD, gap = 8, flexGrow = 1, align = "stretch", justify = "start" }, rows))
|
|
end
|
|
|
|
-- ── add model view ───────────────────────────────────────────────────────────────
|
|
|
|
local function render_add_model(combo)
|
|
local rows = {}
|
|
|
|
rows[#rows + 1] = ui.row({ justify = "space_between", align = "center" }, {
|
|
ui.row({ gap = 8, align = "center", flexGrow = 1 }, {
|
|
ui.button({
|
|
glyph = "chevron-left",
|
|
variant = "ghost",
|
|
tooltip = tr("add_model.back"),
|
|
onClick = function()
|
|
adding_model = false
|
|
model_search = ""
|
|
render()
|
|
end,
|
|
}),
|
|
ui.glyph({ name = "plus", color = "primary", size = 20 }),
|
|
ui.label({ text = tr("add_model.title"), fontSize = 18, fontWeight = "bold", color = "on_surface" }),
|
|
}),
|
|
})
|
|
|
|
rows[#rows + 1] = ui.separator({})
|
|
|
|
rows[#rows + 1] = ui.input({
|
|
key = "model_search",
|
|
value = model_search,
|
|
placeholder = tr("add_model.search"),
|
|
onChange = function(text)
|
|
model_search = text or ""
|
|
render()
|
|
end,
|
|
})
|
|
|
|
-- Available models from the system, minus ones already in this combo.
|
|
local all_models = noctalia.state.get(STATE.models) or {}
|
|
local in_combo = {}
|
|
for _, m in ipairs(combo.models or {}) do in_combo[m] = true end
|
|
local needle = string.lower(model_search)
|
|
local items = {}
|
|
local count = 0
|
|
for _, m in ipairs(all_models) do
|
|
if type(m) == "table" and type(m.fullModel) == "string" then
|
|
local full = m.fullModel
|
|
if not in_combo[full] then
|
|
if needle == "" or string.find(string.lower(full), needle, 1, true) then
|
|
count = count + 1
|
|
items[#items + 1] = ui.button({
|
|
key = "add_" .. full,
|
|
text = full,
|
|
variant = "outline",
|
|
contentAlign = "start",
|
|
onClick = function()
|
|
dispatch_ipc("add_model", combo.id .. ":" .. full)
|
|
adding_model = false
|
|
model_search = ""
|
|
render()
|
|
end,
|
|
})
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
if count == 0 then
|
|
items[#items + 1] = ui.label({
|
|
text = needle ~= "" and tr("add_model.no_match") or tr("add_model.empty"),
|
|
opacity = 0.7,
|
|
maxWidth = WRAP,
|
|
})
|
|
end
|
|
|
|
rows[#rows + 1] = ui.scroll({ flexGrow = 1, gap = 6, align = "stretch", justify = "start" }, items)
|
|
|
|
panel.render(ui.column({ padding = PAD, gap = 8, flexGrow = 1, align = "stretch", justify = "start" }, rows))
|
|
end
|
|
|
|
-- ── login view ────────────────────────────────────────────────────────────────
|
|
|
|
local function render_login()
|
|
local rows = {}
|
|
|
|
rows[#rows + 1] = ui.row({ justify = "space_between", align = "center" }, {
|
|
ui.row({ gap = 8, align = "center" }, {
|
|
ui.glyph({ name = "lock", color = "error", size = 24 }),
|
|
ui.label({ text = tr("login.title"), fontSize = 18, fontWeight = "bold", color = "on_surface" }),
|
|
}),
|
|
})
|
|
|
|
rows[#rows + 1] = ui.separator({})
|
|
|
|
rows[#rows + 1] = ui.label({ text = tr("login.hint"), opacity = 0.7, fontSize = 12, maxWidth = WRAP })
|
|
|
|
rows[#rows + 1] = ui.input({
|
|
key = "login_password",
|
|
value = login_password,
|
|
placeholder = tr("login.password_placeholder"),
|
|
password = true,
|
|
onChange = function(text)
|
|
login_password = text or ""
|
|
end,
|
|
})
|
|
|
|
-- Error banner (if any)
|
|
local err = noctalia.state.get(STATE.last_error)
|
|
if type(err) == "table" and err.message then
|
|
rows[#rows + 1] = ui.row({ gap = 8, align = "center", padding = 8, radius = 8, fill = "error/0.15", border = "error", borderWidth = 1 }, {
|
|
ui.glyph({ name = "alert-circle", color = "error", size = 16 }),
|
|
ui.column({ gap = 2, flexGrow = 1 }, {
|
|
ui.label({ text = err.message, color = "error", fontSize = 13, fontWeight = "bold", maxWidth = WRAP - 40 }),
|
|
ui.label({ text = err.detail or "", fontSize = 11, opacity = 0.75, maxWidth = WRAP - 40 }),
|
|
}),
|
|
})
|
|
end
|
|
|
|
rows[#rows + 1] = ui.separator({})
|
|
|
|
rows[#rows + 1] = ui.row({ justify = "end", gap = 8 }, {
|
|
ui.button({
|
|
text = tr("login.submit"),
|
|
glyph = "login",
|
|
variant = "primary",
|
|
onClick = function()
|
|
dispatch_ipc("login", login_password)
|
|
login_password = ""
|
|
render()
|
|
end,
|
|
}),
|
|
})
|
|
|
|
panel.render(ui.column({ padding = PAD, gap = 8, flexGrow = 1, align = "stretch", justify = "start" }, rows))
|
|
end
|
|
|
|
-- ── main render ──────────────────────────────────────────────────────────────
|
|
|
|
render = function()
|
|
local conn = noctalia.state.get(STATE.connection)
|
|
local combos = noctalia.state.get(STATE.combos) or {}
|
|
local selected_id = noctalia.state.get(STATE.selected)
|
|
|
|
-- Login view takes priority when the dashboard requires authentication.
|
|
if type(conn) == "table" and conn.status == "auth_required" then
|
|
render_login()
|
|
return
|
|
end
|
|
|
|
-- Create view takes priority
|
|
if creating then
|
|
render_create()
|
|
return
|
|
end
|
|
|
|
-- Detail view when a combo is selected and still exists
|
|
local selected = find_combo(combos, selected_id)
|
|
if selected then
|
|
if adding_model then
|
|
render_add_model(selected)
|
|
return
|
|
end
|
|
render_detail(selected)
|
|
return
|
|
end
|
|
|
|
-- Otherwise the list view
|
|
render_list(combos, conn)
|
|
end
|
|
|
|
-- ── lifecycle ───────────────────────────────────────────────────────────────
|
|
|
|
local function fingerprint_state()
|
|
local conn = noctalia.state.get(STATE.connection)
|
|
local combos = noctalia.state.get(STATE.combos)
|
|
local selected_id = noctalia.state.get(STATE.selected)
|
|
local err = noctalia.state.get(STATE.last_error)
|
|
return table.concat({
|
|
(type(conn) == "table" and conn.status or "offline"),
|
|
tostring(selected_id or "none"),
|
|
(type(err) == "table" and tostring(err.message) or "none"),
|
|
combos_fingerprint(combos),
|
|
}, "\1")
|
|
end
|
|
|
|
local snap_cache = { fingerprint = nil }
|
|
|
|
function onOpen(_context)
|
|
panel.setWantsSecondTicks(true)
|
|
for _, key in pairs(STATE) do
|
|
noctalia.state.watch(key, function()
|
|
local fp = fingerprint_state()
|
|
if fp ~= snap_cache.fingerprint then
|
|
snap_cache.fingerprint = fp
|
|
render()
|
|
end
|
|
end)
|
|
end
|
|
render()
|
|
end
|
|
|
|
function onClose()
|
|
end
|
|
|
|
function update()
|
|
local fp = fingerprint_state()
|
|
if fp ~= snap_cache.fingerprint then
|
|
snap_cache.fingerprint = fp
|
|
render()
|
|
end
|
|
end
|