ework-web 0.10.8 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ework-web",
3
- "version": "0.10.8",
3
+ "version": "0.10.10",
4
4
  "type": "module",
5
5
  "description": "ework-web — standalone multi-project issue tracker. Local SQLite-backed, no external API dependency. Bun + TypeScript + SSR HTML.",
6
6
  "license": "MIT",
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
+ })();
@@ -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
+ }
package/src/webhooks.ts CHANGED
@@ -541,25 +541,6 @@ async function recordDelivery(
541
541
  }
542
542
  }
543
543
 
544
- async function resolveDeliveryUrl(storedUrl: string): Promise<string> {
545
- try {
546
- const { getActiveDaemons } = await import("./coordination");
547
- const daemons = await getActiveDaemons();
548
- if (daemons.length === 0) return storedUrl;
549
- const storedHost = storedUrl.replace(/^https?:\/\//, "").split("/")[0] ?? "";
550
- const storedMatchesDaemon = daemons.some((d) => {
551
- const daemonHost = d.endpoint.replace(/^https?:\/\//, "");
552
- return daemonHost === storedHost;
553
- });
554
- if (storedMatchesDaemon) return storedUrl;
555
- const best = daemons.sort((a, b) => a.id - b.id)[0]!;
556
- const ep = /^https?:\/\//.test(best.endpoint) ? best.endpoint : `http://${best.endpoint}`;
557
- return ep.replace(/\/$/, "") + "/webhook/gitea";
558
- } catch {
559
- return storedUrl;
560
- }
561
- }
562
-
563
544
  async function deliver(
564
545
  webhook: WebhookRow,
565
546
  event: WebhookEventName,
@@ -567,12 +548,11 @@ async function deliver(
567
548
  ): Promise<void> {
568
549
  await acquireDeliverySlot();
569
550
  try {
570
- const targetUrl = await resolveDeliveryUrl(webhook.url);
571
551
  for (const attempt of RETRY_DELAYS_MS) {
572
552
  if (attempt > 0) await Bun.sleep(attempt);
573
553
  const deliveryUuid = randomUUID();
574
554
  const headers = buildHeaders(event, deliveryUuid, rawBody, webhook.secret);
575
- const result = await postWithTimeout(targetUrl, rawBody, headers);
555
+ const result = await postWithTimeout(webhook.url, rawBody, headers);
576
556
  const success = result.status !== null && result.status >= 200 && result.status < 300;
577
557
  await recordDelivery(webhook.id, event, deliveryUuid, rawBody, result);
578
558
  if (success) return;