ework-web 0.10.9 → 0.10.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/index.ts +35 -0
- package/src/static/daemon-groups.js +100 -0
- package/src/views/settings.ts +38 -0
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -1004,6 +1004,41 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
|
|
|
1004
1004
|
});
|
|
1005
1005
|
}
|
|
1006
1006
|
|
|
1007
|
+
if (url.pathname === "/api/router/daemons" && req.method === "GET") {
|
|
1008
|
+
if (!ctx.user || ctx.user.is_admin !== 1) return json({ error: "admin required" }, 403);
|
|
1009
|
+
try {
|
|
1010
|
+
const routerUrl = cfg.daemonWebhookUrl.replace(/\/$/, "");
|
|
1011
|
+
const res = await fetch(`${routerUrl}/api/daemons`, { signal: AbortSignal.timeout(5000) });
|
|
1012
|
+
const data = await res.json();
|
|
1013
|
+
return json(data);
|
|
1014
|
+
} catch (e) {
|
|
1015
|
+
return json({ daemons: [], error: errMsg(e) });
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
if (url.pathname === "/api/router/strategy") {
|
|
1020
|
+
if (!ctx.user || ctx.user.is_admin !== 1) return json({ error: "admin required" }, 403);
|
|
1021
|
+
const routerUrl = cfg.daemonWebhookUrl.replace(/\/$/, "");
|
|
1022
|
+
try {
|
|
1023
|
+
if (req.method === "GET") {
|
|
1024
|
+
const res = await fetch(`${routerUrl}/api/strategy`, { signal: AbortSignal.timeout(5000) });
|
|
1025
|
+
return json(await res.json());
|
|
1026
|
+
}
|
|
1027
|
+
if (req.method === "POST") {
|
|
1028
|
+
const body = await req.text();
|
|
1029
|
+
const res = await fetch(`${routerUrl}/api/strategy`, {
|
|
1030
|
+
method: "POST",
|
|
1031
|
+
headers: { "Content-Type": "application/json" },
|
|
1032
|
+
body,
|
|
1033
|
+
signal: AbortSignal.timeout(5000),
|
|
1034
|
+
});
|
|
1035
|
+
return json(await res.json(), res.ok ? 200 : 400);
|
|
1036
|
+
}
|
|
1037
|
+
} catch (e) {
|
|
1038
|
+
return json({ error: errMsg(e) }, 502);
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1007
1042
|
if (url.pathname === "/settings") {
|
|
1008
1043
|
if (req.method === "GET") {
|
|
1009
1044
|
return html(buildSettingsPage(cfg, url.searchParams.get("saved") === "1", ctx.user!, await listCachedModels()).html);
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
(async function() {
|
|
2
|
+
const tbody = document.getElementById("groups-tbody");
|
|
3
|
+
const bindingsList = document.getElementById("bindings-list");
|
|
4
|
+
const strategySelect = document.getElementById("strategy-select");
|
|
5
|
+
const result = document.getElementById("strategy-result");
|
|
6
|
+
const saveBtn = document.getElementById("strategy-save");
|
|
7
|
+
const addBindingBtn = document.getElementById("binding-add");
|
|
8
|
+
|
|
9
|
+
let currentStrategy = { strategy: "least-loaded", groupBindings: {}, daemonGroups: {} };
|
|
10
|
+
|
|
11
|
+
function showResult(msg, ok) {
|
|
12
|
+
result.textContent = msg;
|
|
13
|
+
result.className = "db-result " + (ok ? "db-ok" : "db-err");
|
|
14
|
+
setTimeout(() => { result.textContent = ""; result.className = "db-result"; }, 3000);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function load() {
|
|
18
|
+
try {
|
|
19
|
+
const [daemonsRes, strategyRes] = await Promise.all([
|
|
20
|
+
fetch("/api/router/daemons").then(r => r.json()),
|
|
21
|
+
fetch("/api/router/strategy").then(r => r.json()),
|
|
22
|
+
]);
|
|
23
|
+
const daemons = daemonsRes.daemons || [];
|
|
24
|
+
currentStrategy = strategyRes;
|
|
25
|
+
strategySelect.value = currentStrategy.strategy || "least-loaded";
|
|
26
|
+
|
|
27
|
+
tbody.innerHTML = daemons.map(d => {
|
|
28
|
+
const groups = (currentStrategy.daemonGroups || {})[d.id] || [];
|
|
29
|
+
return `<tr>
|
|
30
|
+
<td>${d.id}</td>
|
|
31
|
+
<td>${d.displayName || "?"}<br><span style="font-size:11px;color:var(--text-muted)">${d.endpoint}</span></td>
|
|
32
|
+
<td><span class="pill ${d.status}">${d.status}</span></td>
|
|
33
|
+
<td><input type="text" data-daemon-id="${d.id}" value="${groups.join(", ")}" placeholder="group-a, group-b" style="width:100%;padding:.25rem .4rem;border:1px solid var(--border);border-radius:4px;background:var(--bg);color:var(--text);font-size:12px"></td>
|
|
34
|
+
</tr>`;
|
|
35
|
+
}).join("") || '<tr><td colspan="4" class="daemon-empty">没有已注册的 daemon(节点启动后自动出现)</td></tr>';
|
|
36
|
+
|
|
37
|
+
renderBindings();
|
|
38
|
+
} catch (e) {
|
|
39
|
+
tbody.innerHTML = '<tr><td colspan="4" class="daemon-empty">无法连接 router — 确认 router 已启动</td></tr>';
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function renderBindings() {
|
|
44
|
+
const bindings = currentStrategy.groupBindings || {};
|
|
45
|
+
const keys = Object.keys(bindings);
|
|
46
|
+
bindingsList.innerHTML = keys.length === 0
|
|
47
|
+
? '<p class="hint" style="margin:0 0 .5rem">暂无绑定</p>'
|
|
48
|
+
: keys.map(k => `<div style="display:flex;align-items:center;gap:.5rem;margin:.3rem 0;font-size:13px">
|
|
49
|
+
<code>${k}</code> → <span style="color:var(--accent)">${bindings[k]}</span>
|
|
50
|
+
<button type="button" class="secondary" data-binding-key="${k}" style="padding:.2rem .5rem;font-size:11px">删除</button>
|
|
51
|
+
</div>`).join("");
|
|
52
|
+
|
|
53
|
+
bindingsList.querySelectorAll("button[data-binding-key]").forEach(btn => {
|
|
54
|
+
btn.onclick = () => {
|
|
55
|
+
delete currentStrategy.groupBindings[btn.dataset.bindingKey];
|
|
56
|
+
renderBindings();
|
|
57
|
+
};
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
addBindingBtn.onclick = () => {
|
|
62
|
+
const repo = document.getElementById("binding-repo").value.trim();
|
|
63
|
+
const group = document.getElementById("binding-group").value.trim();
|
|
64
|
+
if (!repo || !group) return;
|
|
65
|
+
if (!currentStrategy.groupBindings) currentStrategy.groupBindings = {};
|
|
66
|
+
currentStrategy.groupBindings[repo] = group;
|
|
67
|
+
document.getElementById("binding-repo").value = "";
|
|
68
|
+
document.getElementById("binding-group").value = "";
|
|
69
|
+
renderBindings();
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
saveBtn.onclick = async () => {
|
|
73
|
+
currentStrategy.strategy = strategySelect.value;
|
|
74
|
+
const newGroups = {};
|
|
75
|
+
tbody.querySelectorAll("input[data-daemon-id]").forEach(input => {
|
|
76
|
+
const id = parseInt(input.dataset.daemonId, 10);
|
|
77
|
+
const groups = input.value.split(",").map(s => s.trim()).filter(Boolean);
|
|
78
|
+
if (groups.length > 0) newGroups[id] = groups;
|
|
79
|
+
});
|
|
80
|
+
currentStrategy.daemonGroups = newGroups;
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
const res = await fetch("/api/router/strategy", {
|
|
84
|
+
method: "POST",
|
|
85
|
+
headers: { "Content-Type": "application/json" },
|
|
86
|
+
body: JSON.stringify(currentStrategy),
|
|
87
|
+
});
|
|
88
|
+
const data = await res.json();
|
|
89
|
+
if (res.ok) {
|
|
90
|
+
showResult("✓ 策略已保存", true);
|
|
91
|
+
} else {
|
|
92
|
+
showResult("✗ " + (data.error || "保存失败"), false);
|
|
93
|
+
}
|
|
94
|
+
} catch (e) {
|
|
95
|
+
showResult("✗ " + e.message, false);
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
load();
|
|
100
|
+
})();
|
package/src/views/settings.ts
CHANGED
|
@@ -170,6 +170,44 @@ ${modelRefreshForm}
|
|
|
170
170
|
${ttsLink}
|
|
171
171
|
${buildDbSection(viewer)}
|
|
172
172
|
${buildDaemonSection(viewer)}
|
|
173
|
+
${viewer.is_admin === 1 ? buildGroupsSection() : ""}
|
|
173
174
|
</main></body></html>`;
|
|
174
175
|
return { html };
|
|
175
176
|
}
|
|
177
|
+
|
|
178
|
+
function buildGroupsSection(): string {
|
|
179
|
+
return `
|
|
180
|
+
<section class="sg" id="groups-section">
|
|
181
|
+
<h2>Daemon 分组与路由策略</h2>
|
|
182
|
+
<p class="hint">配置 daemon 分组和 webhook 派发策略。Router 自动摘除心跳超时的节点(120s)。</p>
|
|
183
|
+
<div class="sf">
|
|
184
|
+
<span>路由策略</span>
|
|
185
|
+
<select id="strategy-select">
|
|
186
|
+
<option value="least-loaded">least-loaded(负载最低优先)</option>
|
|
187
|
+
<option value="round-robin">round-robin(轮询)</option>
|
|
188
|
+
<option value="first-available">first-available(按 ID 顺序)</option>
|
|
189
|
+
<option value="group">group(按分组路由)</option>
|
|
190
|
+
</select>
|
|
191
|
+
</div>
|
|
192
|
+
<div id="daemon-groups-editor" style="margin-top:.7rem">
|
|
193
|
+
<table class="daemon-list" id="groups-table">
|
|
194
|
+
<thead><tr><th>ID</th><th>节点</th><th>状态</th><th>分组</th></tr></thead>
|
|
195
|
+
<tbody id="groups-tbody"></tbody>
|
|
196
|
+
</table>
|
|
197
|
+
</div>
|
|
198
|
+
<div id="bindings-editor" style="margin-top:.7rem">
|
|
199
|
+
<h2 style="margin-bottom:.5rem">Repo → 分组绑定</h2>
|
|
200
|
+
<div id="bindings-list"></div>
|
|
201
|
+
<div class="db-controls">
|
|
202
|
+
<input type="text" id="binding-repo" placeholder="owner/repo" style="padding:.35rem .5rem;border:1px solid var(--border);border-radius:6px;background:var(--bg);color:var(--text);font-size:13px;flex:1">
|
|
203
|
+
<input type="text" id="binding-group" placeholder="group-name" style="padding:.35rem .5rem;border:1px solid var(--border);border-radius:6px;background:var(--bg);color:var(--text);font-size:13px;flex:1">
|
|
204
|
+
<button type="button" id="binding-add" class="secondary">添加绑定</button>
|
|
205
|
+
</div>
|
|
206
|
+
</div>
|
|
207
|
+
<div class="db-controls" style="margin-top:.7rem">
|
|
208
|
+
<button type="button" id="strategy-save">保存策略</button>
|
|
209
|
+
</div>
|
|
210
|
+
<div class="db-result" id="strategy-result"></div>
|
|
211
|
+
</section>
|
|
212
|
+
<script src="/static/daemon-groups.js"></script>`;
|
|
213
|
+
}
|