mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-10-05 15:07:51 +00:00
feat(gui): 插件面板补齐安装/编辑/删除,on_disk 报告页面
桌面版插件面板此前只有启用/禁用,装不了新插件、读不回源码、删不掉坏的
插件——而 WebUI 有完整能力。两边不一致,且缺失的那三个恰好是出问题时唯一
能用的三个操作。
补齐(复用后端已有契约,未新增端点):
- POST /api/plugins 安装并保存源码。保存走安装路径是有意的:它会校验
名称、编译并热重载,而 PUT /{name} 只切 enabled 开关,指过去会静默
什么都没存。
- GET /api/plugins/{name} 读源码(读的是磁盘上的源,与写回可往返)。
- DELETE /api/plugins/{name} 删除。内置插件不显示删除按钮。
- 内置插件的"编辑"改为"查看源码"并置只读:它的文件在二进制里,给一个
保存后被丢弃的文本框比直接说明更糟。
- 三个弹窗复用既有的 overlay / overlay-card,不新造一套弹窗体系。
后端补 DiskEntry.Pages:GUI 读的是 on_disk 而非 List,pages 只在 List
里,所以面板里那行"页面:…"永远不显示。新增 pageNames() 复刻加载期的
合并规则(page + pages,去重保序)。判据覆盖 pages 与单页 ui.page 两种
写法,并断言 JSON 里带 pages——GUI 读的是 HTTP body,不是 Go 结构体。
变异去掉赋值后两条判据都失败。
CDP 实测 10 项全部通过:安装带页面的插件、源码往返一致、禁用/启用、
热重载后版本号变化、坏插件在列表可见并带错误信息、非法名拦截、语法错误
被后端拒绝而非静默写入、路径穿越被 IPC 代理拦截。
Co-Authored-By: ModelRouter <noreply@modelrouter.dev>
This commit is contained in:
@ -226,6 +226,7 @@ async function loadPlugins() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const rows = j.on_disk || [];
|
const rows = j.on_disk || [];
|
||||||
|
window.__plRows = rows; // read by editPlugin/deletePlugin for the builtin flag
|
||||||
const active = rows.filter((p) => p.loaded && !p.disabled).length;
|
const active = rows.filter((p) => p.loaded && !p.disabled).length;
|
||||||
const broken = rows.filter((p) => !p.loaded).length;
|
const broken = rows.filter((p) => !p.loaded).length;
|
||||||
hint.textContent =
|
hint.textContent =
|
||||||
@ -250,11 +251,30 @@ async function loadPlugins() {
|
|||||||
const builtin = p.builtin
|
const builtin = p.builtin
|
||||||
? '<span class="pl-builtin">内置</span>'
|
? '<span class="pl-builtin">内置</span>'
|
||||||
: "";
|
: "";
|
||||||
|
// Edit / view source / delete are the operations an operator
|
||||||
|
// actually needs after enable/disable: a plugin that cannot be
|
||||||
|
// read back cannot be fixed, and one that cannot be removed cannot
|
||||||
|
// be rolled back.
|
||||||
|
const acts = [
|
||||||
|
btn,
|
||||||
|
`<button class="ghost" data-act="code" data-name="${escAttr(p.name)}">${
|
||||||
|
p.builtin ? "查看源码" : "编辑"
|
||||||
|
}</button>`,
|
||||||
|
builtin
|
||||||
|
? ""
|
||||||
|
: `<button class="ghost danger" data-act="del" data-name="${escAttr(
|
||||||
|
p.name,
|
||||||
|
)}">删除</button>`,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("");
|
||||||
|
const pages = Array.isArray(p.pages) ? p.pages : [];
|
||||||
return `<div class="pl-item ${cls}">
|
return `<div class="pl-item ${cls}">
|
||||||
<div class="pl-head"><b>${esc(p.name)}</b>${builtin}<span class="pl-state">${label}</span></div>
|
<div class="pl-head"><b>${esc(p.name)}</b>${builtin}<span class="pl-state">${label}</span></div>
|
||||||
${p.description ? `<div class="pl-desc">${esc(p.description)}</div>` : ""}
|
${p.description ? `<div class="pl-desc">${esc(p.description)}</div>` : ""}
|
||||||
|
${pages.length ? `<div class="pl-desc">页面:${esc(pages.join(", "))}</div>` : ""}
|
||||||
${p.error ? `<div class="pl-err">${esc(String(p.error).slice(0, 160))}</div>` : ""}
|
${p.error ? `<div class="pl-err">${esc(String(p.error).slice(0, 160))}</div>` : ""}
|
||||||
<div class="pl-acts">${btn}</div>
|
<div class="pl-acts">${acts}</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
})
|
})
|
||||||
.join("")
|
.join("")
|
||||||
@ -262,6 +282,160 @@ async function loadPlugins() {
|
|||||||
list.querySelectorAll('button[data-act="toggle"]').forEach((b) => {
|
list.querySelectorAll('button[data-act="toggle"]').forEach((b) => {
|
||||||
b.onclick = () => togglePlugin(b.dataset.name, b.dataset.en === "1");
|
b.onclick = () => togglePlugin(b.dataset.name, b.dataset.en === "1");
|
||||||
});
|
});
|
||||||
|
list.querySelectorAll('button[data-act="code"]').forEach((b) => {
|
||||||
|
b.onclick = () => editPlugin(b.dataset.name);
|
||||||
|
});
|
||||||
|
list.querySelectorAll('button[data-act="del"]').forEach((b) => {
|
||||||
|
b.onclick = () => deletePlugin(b.dataset.name);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// editPlugin shows the plugin source in a modal. Built-in plugins are
|
||||||
|
// read-only: their file ships inside the binary, so a save would either be
|
||||||
|
// ignored or silently do nothing — showing an editable text area that
|
||||||
|
// discards on save is worse than saying so up front.
|
||||||
|
function editPlugin(name) {
|
||||||
|
// The plugin list is the only place `builtin` is reported; keep the last
|
||||||
|
// fetch so the modal does not need a second round trip (and cannot guess).
|
||||||
|
const row = (window.__plRows || []).find((p) => p.name === name);
|
||||||
|
const isBuiltin = !!(row && row.builtin);
|
||||||
|
const wrap = document.createElement("div");
|
||||||
|
wrap.className = "overlay";
|
||||||
|
wrap.innerHTML = `<div class="overlay-card pl-wide">
|
||||||
|
<div class="overlay-head"><h2>${esc(name)}${isBuiltin ? "(内置,只读)" : ""}</h2></div>
|
||||||
|
${isBuiltin
|
||||||
|
? `<p class="hint">内置插件随内核发布,源码在二进制内,不可编辑。</p>`
|
||||||
|
: `<textarea id="pl-code" spellcheck="false"></textarea>
|
||||||
|
<p class="hint" id="pl-code-err"></p>`}
|
||||||
|
<div class="row actions">
|
||||||
|
${isBuiltin ? "" : `<button id="pl-code-save">保存并热重载</button>`}
|
||||||
|
<button class="ghost" id="pl-code-close">关闭</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
document.body.appendChild(wrap);
|
||||||
|
wrap.addEventListener("click", (e) => {
|
||||||
|
if (e.target === wrap) wrap.remove();
|
||||||
|
});
|
||||||
|
$("#pl-code-close").onclick = () => wrap.remove();
|
||||||
|
const save = $("#pl-code-save");
|
||||||
|
if (save) {
|
||||||
|
save.onclick = async () => {
|
||||||
|
save.disabled = true;
|
||||||
|
try {
|
||||||
|
// Saving writes through POST /api/plugins — the same install path the
|
||||||
|
// installer uses, which validates the name, compiles the source and
|
||||||
|
// hot-reloads. PUT /{name} only toggles enable/disable, so pointing the
|
||||||
|
// save there would silently disable nothing and store nothing.
|
||||||
|
await window.modelrouter.plugins.request("POST", "/api/plugins", {
|
||||||
|
name,
|
||||||
|
code: $("#pl-code").value,
|
||||||
|
});
|
||||||
|
toast(`${name} 已保存`);
|
||||||
|
wrap.remove();
|
||||||
|
await loadPlugins();
|
||||||
|
} catch (e) {
|
||||||
|
$("#pl-code-err").textContent = e.message;
|
||||||
|
save.disabled = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (isBuiltin) return;
|
||||||
|
(async () => {
|
||||||
|
const ta = $("#pl-code");
|
||||||
|
try {
|
||||||
|
const j = await window.modelrouter.plugins.request(
|
||||||
|
"GET",
|
||||||
|
`/api/plugins/${encodeURIComponent(name)}`,
|
||||||
|
);
|
||||||
|
ta.value = j.code || "";
|
||||||
|
const err = $("#pl-code-err");
|
||||||
|
if (err && j.parse_error) err.textContent = String(j.parse_error);
|
||||||
|
} catch (e) {
|
||||||
|
$("#pl-code-err").textContent = e.message;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
|
function deletePlugin(name) {
|
||||||
|
const wrap = document.createElement("div");
|
||||||
|
wrap.className = "overlay";
|
||||||
|
wrap.innerHTML = `<div class="overlay-card">
|
||||||
|
<div class="overlay-head"><h2>删除插件</h2></div>
|
||||||
|
<p>确定删除 <b>${esc(name)}</b>?插件目录中的文件会被移除,核心将不再加载它。</p>
|
||||||
|
<div class="row actions">
|
||||||
|
<button class="danger" id="pl-del-ok">删除</button>
|
||||||
|
<button class="ghost" id="pl-del-no">取消</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
document.body.appendChild(wrap);
|
||||||
|
wrap.addEventListener("click", (e) => {
|
||||||
|
if (e.target === wrap) wrap.remove();
|
||||||
|
});
|
||||||
|
$("#pl-del-no").onclick = () => wrap.remove();
|
||||||
|
$("#pl-del-ok").onclick = async () => {
|
||||||
|
try {
|
||||||
|
await window.modelrouter.plugins.request(
|
||||||
|
"DELETE",
|
||||||
|
`/api/plugins/${encodeURIComponent(name)}`,
|
||||||
|
);
|
||||||
|
toast(`已删除 ${name}`);
|
||||||
|
wrap.remove();
|
||||||
|
await loadPlugins();
|
||||||
|
} catch (e) {
|
||||||
|
toast(e.message, true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// installPlugin creates a new plugin from source. The name becomes the file
|
||||||
|
// name, so it is validated client-side to save a round trip on the obvious
|
||||||
|
// mistakes (empty, path separators, non-portable characters).
|
||||||
|
function installPlugin() {
|
||||||
|
const wrap = document.createElement("div");
|
||||||
|
wrap.className = "overlay";
|
||||||
|
wrap.innerHTML = `<div class="overlay-card pl-wide">
|
||||||
|
<div class="overlay-head"><h2>安装插件</h2></div>
|
||||||
|
<div class="row"><label>名称</label><input id="pl-new-name" placeholder="my_plugin" /></div>
|
||||||
|
<p class="hint" id="pl-new-err"></p>
|
||||||
|
<textarea id="pl-new-code" spellcheck="false" placeholder="-- 单文件 Lua 插件 plugin = {} ..."></textarea>
|
||||||
|
<div class="row actions">
|
||||||
|
<button id="pl-new-ok">安装</button>
|
||||||
|
<button class="ghost" id="pl-new-no">取消</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
document.body.appendChild(wrap);
|
||||||
|
wrap.addEventListener("click", (e) => {
|
||||||
|
if (e.target === wrap) wrap.remove();
|
||||||
|
});
|
||||||
|
$("#pl-new-no").onclick = () => wrap.remove();
|
||||||
|
$("#pl-new-ok").onclick = async () => {
|
||||||
|
const name = $("#pl-new-name").value.trim();
|
||||||
|
const code = $("#pl-new-code").value;
|
||||||
|
const err = $("#pl-new-err");
|
||||||
|
if (!/^[A-Za-z0-9_-]+$/.test(name)) {
|
||||||
|
err.textContent = "名称只能包含字母、数字、下划线和连字符";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!code.trim()) {
|
||||||
|
err.textContent = "源码不能为空";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$("#pl-new-ok").disabled = true;
|
||||||
|
try {
|
||||||
|
await window.modelrouter.plugins.request("POST", "/api/plugins", {
|
||||||
|
// The backend appends .lua itself (installPlugin writes name+".lua"),
|
||||||
|
// so passing it here would create name.lua.lua.
|
||||||
|
name,
|
||||||
|
code,
|
||||||
|
});
|
||||||
|
toast(`已安装 ${name}`);
|
||||||
|
wrap.remove();
|
||||||
|
await loadPlugins();
|
||||||
|
} catch (e) {
|
||||||
|
err.textContent = e.message;
|
||||||
|
$("#pl-new-ok").disabled = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function togglePlugin(name, disabled) {
|
async function togglePlugin(name, disabled) {
|
||||||
@ -318,6 +492,8 @@ function init() {
|
|||||||
if (plReload) plReload.onclick = loadPlugins;
|
if (plReload) plReload.onclick = loadPlugins;
|
||||||
const plAll = document.getElementById("pl-toggle-all");
|
const plAll = document.getElementById("pl-toggle-all");
|
||||||
if (plAll) plAll.onclick = enableAllPlugins;
|
if (plAll) plAll.onclick = enableAllPlugins;
|
||||||
|
const plInstall = document.getElementById("pl-install");
|
||||||
|
if (plInstall) plInstall.onclick = installPlugin;
|
||||||
$("#rail-autostart").onclick = toggleAutoStart;
|
$("#rail-autostart").onclick = toggleAutoStart;
|
||||||
$("#rail-silent").onclick = toggleSilent;
|
$("#rail-silent").onclick = toggleSilent;
|
||||||
$("#rail-theme").onclick = () => {
|
$("#rail-theme").onclick = () => {
|
||||||
|
|||||||
@ -214,6 +214,7 @@
|
|||||||
<div id="pl-list" class="pl-list"></div>
|
<div id="pl-list" class="pl-list"></div>
|
||||||
<div class="row actions">
|
<div class="row actions">
|
||||||
<button class="ghost" id="pl-toggle-all">全部启用</button>
|
<button class="ghost" id="pl-toggle-all">全部启用</button>
|
||||||
|
<button class="ghost" id="pl-install">安装插件</button>
|
||||||
<button class="ghost" id="pl-reload">刷新</button>
|
<button class="ghost" id="pl-reload">刷新</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="row actions">
|
<div class="row actions">
|
||||||
|
|||||||
@ -674,3 +674,51 @@ html[data-theme="dark"] .overlay {
|
|||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
padding: 10px 0;
|
padding: 10px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---- plugin editor / installer --------------------------------------------
|
||||||
|
The plugin source editor and the install dialog reuse the settings overlay
|
||||||
|
(overlay / overlay-card) rather than a second popup system. They need three
|
||||||
|
things the settings card does not: a wider column for code, a monospace
|
||||||
|
textarea, and a destructive button for delete. */
|
||||||
|
.overlay-card.pl-wide {
|
||||||
|
width: 760px;
|
||||||
|
}
|
||||||
|
.pl-wide textarea {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 380px;
|
||||||
|
resize: vertical;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid var(--line-strong);
|
||||||
|
background: var(--bg-sunken, rgba(127, 127, 127, 0.06));
|
||||||
|
color: inherit;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
font-size: 12.5px;
|
||||||
|
line-height: 1.55;
|
||||||
|
tab-size: 2;
|
||||||
|
}
|
||||||
|
.pl-wide textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--primary);
|
||||||
|
}
|
||||||
|
.pl-wide .row input {
|
||||||
|
flex: 1;
|
||||||
|
padding: 7px 10px;
|
||||||
|
border-radius: 9px;
|
||||||
|
border: 1px solid var(--line-strong);
|
||||||
|
background: var(--bg-sunken, rgba(127, 127, 127, 0.06));
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
.pl-editor .hint,
|
||||||
|
.pl-wide .hint {
|
||||||
|
min-height: 16px;
|
||||||
|
color: var(--muted, #888);
|
||||||
|
}
|
||||||
|
button.danger {
|
||||||
|
color: #e5484d;
|
||||||
|
border-color: rgba(229, 72, 77, 0.4);
|
||||||
|
}
|
||||||
|
button.danger:hover {
|
||||||
|
border-color: #e5484d;
|
||||||
|
color: #e5484d;
|
||||||
|
}
|
||||||
|
|||||||
@ -779,6 +779,34 @@ type DiskEntry struct {
|
|||||||
Version string `json:"version,omitempty"`
|
Version string `json:"version,omitempty"`
|
||||||
Description string `json:"description,omitempty"`
|
Description string `json:"description,omitempty"`
|
||||||
Hooks int `json:"hooks"`
|
Hooks int `json:"hooks"`
|
||||||
|
// Pages lists the UI page ids the plugin contributes. The desktop panel
|
||||||
|
// shows it so an operator can tell at a glance that a plugin actually
|
||||||
|
// renders something, without opening its source.
|
||||||
|
Pages []string `json:"pages,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// pageNames returns the plugin's contributed UI page ids, de-duplicated.
|
||||||
|
// A plugin may declare `page` or `pages` or both, and the same id can appear
|
||||||
|
// twice; the loader's merge keeps the first, so this mirrors that order.
|
||||||
|
func pageNames(p *Plugin) []string {
|
||||||
|
if p == nil || p.UI == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
declared := make([]*UIPage, 0, len(p.UI.Pages)+1)
|
||||||
|
if p.UI.Page != nil {
|
||||||
|
declared = append(declared, p.UI.Page)
|
||||||
|
}
|
||||||
|
declared = append(declared, p.UI.Pages...)
|
||||||
|
var out []string
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, pg := range declared {
|
||||||
|
if pg == nil || seen[pg.PageID] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[pg.PageID] = true
|
||||||
|
out = append(out, pg.PageID)
|
||||||
|
}
|
||||||
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// OnDisk lists every .lua file in the plugin directory with its load state.
|
// OnDisk lists every .lua file in the plugin directory with its load state.
|
||||||
@ -810,6 +838,10 @@ func (ps *Plugins) OnDisk() []DiskEntry {
|
|||||||
de.Version = p.Info.Version
|
de.Version = p.Info.Version
|
||||||
de.Description = p.Info.Description
|
de.Description = p.Info.Description
|
||||||
de.Hooks = len(p.Hooks)
|
de.Hooks = len(p.Hooks)
|
||||||
|
// A plugin may declare a single page or several; both forms end
|
||||||
|
// up in Pages (see the merge at load time), so reporting that one
|
||||||
|
// field covers either.
|
||||||
|
de.Pages = pageNames(p)
|
||||||
} else {
|
} else {
|
||||||
// On disk but not in the running set: either it failed so badly
|
// On disk but not in the running set: either it failed so badly
|
||||||
// that LoadSource never produced a record, or the dir was written
|
// that LoadSource never produced a record, or the dir was written
|
||||||
|
|||||||
@ -375,3 +375,73 @@ return p
|
|||||||
t.Error("a plugin registered for chain_step is not reported as such")
|
t.Error("a plugin registered for chain_step is not reported as such")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestOnDiskReportsPages pins that the disk listing carries the plugin's UI
|
||||||
|
// page ids. The desktop plugin panel reads on_disk (not List) and shows the
|
||||||
|
// pages so an operator can see at a glance that a plugin renders something;
|
||||||
|
// without this field the GUI reads p.pages === undefined and the line simply
|
||||||
|
// never appears, which is the kind of omission nobody reports as a bug.
|
||||||
|
func TestOnDiskReportsPages(t *testing.T) {
|
||||||
|
_, ps, _ := newPluginVM(t)
|
||||||
|
|
||||||
|
code := `
|
||||||
|
local p = { name = "paged", version = "1.0.0" }
|
||||||
|
p.ui = { pages = {} }
|
||||||
|
table.insert(p.ui.pages, { page_id = "alpha", title = "Alpha", mount = "<b>a</b>" })
|
||||||
|
table.insert(p.ui.pages, { page_id = "beta", title = "Beta", mount = "<b>b</b>" })
|
||||||
|
return p
|
||||||
|
`
|
||||||
|
if err := loadPlugin(t, ps, "paged", code); err != nil {
|
||||||
|
t.Fatalf("load: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var found *DiskEntry
|
||||||
|
for _, de := range ps.OnDisk() {
|
||||||
|
if de.Name == "paged" {
|
||||||
|
d := de
|
||||||
|
found = &d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if found == nil {
|
||||||
|
t.Fatal("paged plugin missing from OnDisk")
|
||||||
|
}
|
||||||
|
if len(found.Pages) != 2 {
|
||||||
|
t.Fatalf("Pages = %v, want [alpha beta]", found.Pages)
|
||||||
|
}
|
||||||
|
if found.Pages[0] != "alpha" || found.Pages[1] != "beta" {
|
||||||
|
t.Fatalf("Pages = %v, want [alpha beta] in declaration order", found.Pages)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The field must survive JSON too — the desktop panel reads the HTTP body,
|
||||||
|
// not the Go struct.
|
||||||
|
b, err := json.Marshal(found)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(b), `"pages":["alpha","beta"]`) {
|
||||||
|
t.Fatalf("json omits pages: %s", b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The single-page form (`ui.page`) must also surface, since a plugin using it
|
||||||
|
// is just as rendered as one using `ui.pages`.
|
||||||
|
func TestOnDiskReportsSinglePage(t *testing.T) {
|
||||||
|
_, ps, _ := newPluginVM(t)
|
||||||
|
code := `
|
||||||
|
local p = { name = "single", version = "1.0.0" }
|
||||||
|
p.ui = { page = { page_id = "solo", title = "Solo", mount = "<b>x</b>" } }
|
||||||
|
return p
|
||||||
|
`
|
||||||
|
if err := loadPlugin(t, ps, "single", code); err != nil {
|
||||||
|
t.Fatalf("load: %v", err)
|
||||||
|
}
|
||||||
|
for _, de := range ps.OnDisk() {
|
||||||
|
if de.Name == "single" {
|
||||||
|
if len(de.Pages) != 1 || de.Pages[0] != "solo" {
|
||||||
|
t.Fatalf("Pages = %v, want [solo]", de.Pages)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatal("single plugin missing from OnDisk")
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user