ework-web 0.10.42 → 0.10.44

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.42",
3
+ "version": "0.10.44",
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/build.ts CHANGED
@@ -3,7 +3,7 @@ import { join } from "path";
3
3
 
4
4
  const STATIC = join(__dirname, "static");
5
5
  let m = 0;
6
- for (const f of ["app.js", "session.js", "file.js", "tts.js", "highlight.css"]) {
6
+ for (const f of ["app.js", "session.js", "file.js", "tts.js", "highlight.css", "label-picker.js", "project-labels.js", "daemon-groups.js", "db-wizard.js", "daemon-mgr.js"]) {
7
7
  try {
8
8
  const s = statSync(join(STATIC, f));
9
9
  if (s.mtimeMs > m) m = s.mtimeMs;
package/src/index.ts CHANGED
@@ -467,6 +467,7 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
467
467
  if (url.pathname === "/static/daemon-mgr.js") return staticAsset("daemon-mgr.js", "text/javascript; charset=utf-8", req);
468
468
  if (url.pathname === "/static/daemon-groups.js") return staticAsset("daemon-groups.js", "text/javascript; charset=utf-8", req);
469
469
  if (url.pathname === "/static/label-picker.js") return staticAsset("label-picker.js", "text/javascript; charset=utf-8", req);
470
+ if (url.pathname === "/static/project-labels.js") return staticAsset("project-labels.js", "text/javascript; charset=utf-8", req);
470
471
  if (url.pathname === "/static/session.js") return staticAsset("session.js", "text/javascript; charset=utf-8", req);
471
472
  if (url.pathname === "/static/file.js") return staticAsset("file.js", "text/javascript; charset=utf-8", req);
472
473
  if (url.pathname === "/static/tts.js") return staticAsset("tts.js", "text/javascript; charset=utf-8", req);
@@ -1022,8 +1023,34 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
1022
1023
 
1023
1024
  const daemonIdRe = /^\/api\/daemons\/(\d+)$/;
1024
1025
  const daemonRestartRe = /^\/api\/daemons\/(\d+)\/restart$/;
1026
+ const daemonPauseRe = /^\/api\/daemons\/(\d+)\/pause$/;
1027
+ const daemonResumeRe = /^\/api\/daemons\/(\d+)\/resume$/;
1025
1028
  const daemonRemoveRe = /^\/api\/daemons\/(\d+)\/remove$/;
1026
1029
 
1030
+ if (req.method === "POST" && (daemonPauseRe.test(url.pathname) || daemonResumeRe.test(url.pathname))) {
1031
+ if (!ctx.user || ctx.user.is_admin !== 1) return json({ error: "admin required" }, 403);
1032
+ const isPause = daemonPauseRe.test(url.pathname);
1033
+ const match = url.pathname.match(isPause ? daemonPauseRe : daemonResumeRe);
1034
+ const id = match ? Number(match[1]) : NaN;
1035
+ if (!Number.isFinite(id)) return json({ error: "invalid id" }, 400);
1036
+ const rows = await getDB().all<{ endpoint: string }>(
1037
+ `SELECT internal_endpoint AS endpoint FROM {{d_daemons}} WHERE id = ?`, [id]
1038
+ );
1039
+ if (rows.length === 0) return json({ ok: false, error: "daemon not found" }, 404);
1040
+ const ep = rows[0]!.endpoint;
1041
+ const proto = ep.startsWith("http") ? "" : "http://";
1042
+ try {
1043
+ const upstream = await fetch(`${proto}${ep}/api/admin/${isPause ? "pause" : "resume"}`, { method: "POST" });
1044
+ if (!upstream.ok) {
1045
+ const body = await upstream.text().catch(() => "");
1046
+ return json({ ok: false, error: `daemon returned ${upstream.status}: ${body.slice(0, 200)}` }, 502);
1047
+ }
1048
+ } catch (e) {
1049
+ return json({ ok: false, error: `cannot reach daemon: ${errMsg(e)}` }, 502);
1050
+ }
1051
+ return json({ ok: true, paused: isPause });
1052
+ }
1053
+
1027
1054
  if (req.method === "POST" && daemonRestartRe.test(url.pathname)) {
1028
1055
  if (!ctx.user || ctx.user.is_admin !== 1) return json({ error: "admin required" }, 403);
1029
1056
  const match = url.pathname.match(daemonRestartRe);
@@ -1837,7 +1864,7 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
1837
1864
  const project = await getProject(owner, repo);
1838
1865
  if (!project) return html(errorPage("项目不存在", ""), 404);
1839
1866
  const back = `${url.origin}/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/settings/labels`;
1840
- if (!(await canAdminProject(project.id, ctx.user))) {
1867
+ if (!(await canWriteProject(project.id, ctx.user))) {
1841
1868
  return Response.redirect(`${back}?err=${encodeURIComponent("无权限")}`, 303);
1842
1869
  }
1843
1870
  const form = await req.formData().catch(() => new FormData());
@@ -141,7 +141,7 @@ export function renderLayout(props: LayoutProps, inner: string, initialItems: st
141
141
  const repoIssuesHref = `/${encodeURIComponent(repoOwner ?? "")}/${encodeURIComponent(repoName ?? "")}/issues`;
142
142
  const op = props.operatorLogin ?? "operator";
143
143
  const labelsHtml = (props.labels ?? []).length
144
- ? props.labels!.map((l) => `<span class="issue-label" style="border-color:${escapeAttr(l.color)};color:${escapeAttr(l.color)}">${escapeHtml(l.name)}</span>`).join("")
144
+ ? props.labels!.map((l) => `<a class="issue-label" href="${repoIssuesHref}?state=all&label=${encodeURIComponent(l.name)}" style="border-color:${escapeAttr(l.color)};color:${escapeAttr(l.color)}">${escapeHtml(l.name)}</a>`).join("")
145
145
  : "";
146
146
  const labelPickerBtn = props.canEditLabels
147
147
  ? `<button type="button" class="label-edit-btn" id="labelEditBtn" title="管理标签">🏷️</button>`
@@ -55,7 +55,11 @@
55
55
  var stRaw = String(d.status || 'unknown').toLowerCase();
56
56
  var actions = '';
57
57
  if (stRaw === 'active') {
58
- actions = '<button type="button" class="stop" data-id="' + d.id + '">停止</button>';
58
+ actions = '<button type="button" class="pause" data-id="' + d.id + '">暂停</button>' +
59
+ '<button type="button" class="stop" data-id="' + d.id + '">停止</button>';
60
+ } else if (stRaw === 'drained' || stRaw === 'paused') {
61
+ actions = '<button type="button" class="resume" data-id="' + d.id + '">恢复</button>' +
62
+ '<button type="button" class="remove" data-id="' + d.id + '">删除</button>';
59
63
  } else {
60
64
  actions = '<button type="button" class="restart" data-id="' + d.id + '">重启</button>' +
61
65
  '<button type="button" class="remove" data-id="' + d.id + '">删除</button>';
@@ -81,6 +85,8 @@
81
85
  });
82
86
  };
83
87
  bindBtn('button.stop', stopDaemon);
88
+ bindBtn('button.pause', pauseDaemon);
89
+ bindBtn('button.resume', resumeDaemon);
84
90
  bindBtn('button.restart', restartDaemon);
85
91
  bindBtn('button.remove', removeDaemon);
86
92
  }
@@ -138,6 +144,29 @@
138
144
  }
139
145
  }
140
146
 
147
+ async function pauseDaemon(id) {
148
+ if (!confirm('确认暂停 daemon #' + id + '?\n暂停后不再接收新任务,已进行中的会话不受影响。')) return;
149
+ setResult('正在暂停 daemon #' + id + '…', 'loading');
150
+ try {
151
+ var res = await fetch('/api/daemons/' + id + '/pause', { method: 'POST' });
152
+ if (res.status === 401) { setResult('✗ 登录已过期,请刷新页面重新登录', 'err'); return; }
153
+ var data = await res.json();
154
+ if (data.ok) { setResult('✓ daemon #' + id + ' 已暂停', 'ok'); await loadDaemons(); }
155
+ else setResult('✗ ' + (data.error || '未知错误'), 'err');
156
+ } catch (e) { setResult('✗ ' + (e.message || String(e)), 'err'); }
157
+ }
158
+
159
+ async function resumeDaemon(id) {
160
+ setResult('正在恢复 daemon #' + id + '…', 'loading');
161
+ try {
162
+ var res = await fetch('/api/daemons/' + id + '/resume', { method: 'POST' });
163
+ if (res.status === 401) { setResult('✗ 登录已过期,请刷新页面重新登录', 'err'); return; }
164
+ var data = await res.json();
165
+ if (data.ok) { setResult('✓ daemon #' + id + ' 已恢复', 'ok'); await loadDaemons(); }
166
+ else setResult('✗ ' + (data.error || '未知错误'), 'err');
167
+ } catch (e) { setResult('✗ ' + (e.message || String(e)), 'err'); }
168
+ }
169
+
141
170
  async function stopDaemon(id) {
142
171
  if (!confirm('确认停止 daemon #' + id + '?\n只会标记为 drained(不再接新任务),需手动 kill 进程。')) return;
143
172
  setResult('正在标记 daemon #' + id + ' 为 drained…', 'loading');
@@ -0,0 +1,53 @@
1
+ (function () {
2
+ var dataEl = document.getElementById('label-data');
3
+ if (!dataEl) return;
4
+ var data = JSON.parse(dataEl.textContent || '{}');
5
+ var palette = data.palette || [];
6
+ var LABELS = data.labels || [];
7
+
8
+ function bindSwatches(container, colorInput) {
9
+ if (!container || !colorInput) return;
10
+ container.innerHTML = palette.map(function (c) {
11
+ return '<button type="button" class="swatch" data-color="' + c + '" style="background:' + c + '" aria-label="' + c + '"></button>';
12
+ }).join('');
13
+ function sync() {
14
+ container.querySelectorAll('.swatch').forEach(function (b) {
15
+ b.classList.toggle('sel', b.dataset.color.toLowerCase() === colorInput.value.toLowerCase());
16
+ });
17
+ }
18
+ sync();
19
+ container.addEventListener('click', function (e) {
20
+ var b = e.target.closest('.swatch');
21
+ if (!b) return;
22
+ colorInput.value = b.dataset.color;
23
+ sync();
24
+ });
25
+ colorInput.addEventListener('input', sync);
26
+ }
27
+
28
+ bindSwatches(document.getElementById('palette'), document.getElementById('f-color'));
29
+
30
+ var dlg = document.getElementById('dlg');
31
+ if (!dlg) return;
32
+ var ef = document.getElementById('edit-form');
33
+ var dp = document.getElementById('dlg-palette');
34
+ var dlgColor;
35
+
36
+ document.addEventListener('click', function (e) {
37
+ var btn = e.target.closest('[data-edit-btn]');
38
+ if (!btn) return;
39
+ var id = Number(btn.dataset.editBtn);
40
+ var l = LABELS.find(function (x) { return x.id === id; });
41
+ if (!l) return;
42
+ ef.action = btn.form.action;
43
+ ef.querySelector('[name=name]').value = l.name;
44
+ ef.querySelector('[name=color]').value = l.color;
45
+ dlgColor = ef.querySelector('[name=color]');
46
+ ef.querySelector('[name=description]').value = l.description;
47
+ ef.querySelector('[name=exclusive]').checked = l.exclusive === 1;
48
+ bindSwatches(dp, dlgColor);
49
+ dlg.showModal();
50
+ });
51
+
52
+ document.getElementById('dlg-cancel').addEventListener('click', function () { dlg.close(); });
53
+ })();
@@ -1,4 +1,5 @@
1
1
  import { THEME_CSS, escapeHtml, escapeAttr } from "../render/layout";
2
+ import { BUILD_ID } from "../build";
2
3
  import { projectSettingsTabsHTML } from "./projectUpstreams";
3
4
  import { listLabels, type ProjectRow, type UserRow, type LabelRow } from "../store";
4
5
 
@@ -58,7 +59,6 @@ export async function buildProjectLabelsPage(
58
59
  const flashHtml = flash ? `<div class="flash ${flash.kind}">${escapeHtml(flash.msg)}</div>` : "";
59
60
  const createAction = `/${encodeURIComponent(project.owner)}/${encodeURIComponent(project.name)}/settings/labels/add`;
60
61
  const paletteHtml = PALETTE.map((c) => `<button type="button" class="swatch" data-color="${c}" style="background:${c}" aria-label="${c}"></button>`).join("");
61
- const labelsJson = JSON.stringify(labels.map((l) => ({ id: l.id, name: l.name, color: l.color, description: l.description, exclusive: l.exclusive, is_archived: l.is_archived })));
62
62
 
63
63
  return `<!doctype html>
64
64
  <html lang="zh"><head><meta charset="utf-8">
@@ -157,34 +157,7 @@ ${rowsHtml}
157
157
  </div>
158
158
  </form>
159
159
  </dialog>
160
- <script>
161
- const LABELS = ${labelsJson};
162
- const palette = ${JSON.stringify(PALETTE)};
163
- function bindSwatches(container, colorInput){
164
- container.innerHTML = palette.map(c => '<button type="button" class="swatch" data-color="'+c+'" style="background:'+c+'" aria-label="'+c+'"></button>').join('');
165
- const sync = ()=>{ container.querySelectorAll('.swatch').forEach(b=>b.classList.toggle('sel', b.dataset.color.toLowerCase()===colorInput.value.toLowerCase())); };
166
- sync();
167
- container.addEventListener('click', e=>{ const b=e.target.closest('.swatch'); if(!b)return; colorInput.value=b.dataset.color; sync(); });
168
- colorInput.addEventListener('input', sync);
169
- }
170
- bindSwatches(document.getElementById('palette'), document.getElementById('f-color'));
171
- const dlg=document.getElementById('dlg'), ef=document.getElementById('edit-form'), dp=document.getElementById('dlg-palette');
172
- let dlgColor;
173
- document.addEventListener('click', e=>{
174
- const btn=e.target.closest('[data-edit-btn]');
175
- if(!btn)return;
176
- const id=Number(btn.dataset.editBtn);
177
- const l=LABELS.find(x=>x.id===id); if(!l)return;
178
- ef.action=btn.form.action;
179
- ef.querySelector('[name=name]').value=l.name;
180
- ef.querySelector('[name=color]').value=l.color;
181
- dlgColor=ef.querySelector('[name=color]');
182
- ef.querySelector('[name=description]').value=l.description;
183
- ef.querySelector('[name=exclusive]').checked=l.exclusive===1;
184
- bindSwatches(dp, dlgColor);
185
- dlg.showModal();
186
- });
187
- document.getElementById('dlg-cancel').addEventListener('click', ()=>dlg.close());
188
- </script>
160
+ <script type="application/json" id="label-data">${JSON.stringify({ labels: labels.map((l) => ({ id: l.id, name: l.name, color: l.color, description: l.description, exclusive: l.exclusive, is_archived: l.is_archived })), palette: PALETTE })}</script>
161
+ <script src="/static/project-labels.js?v=${BUILD_ID}" defer></script>
189
162
  </body></html>`;
190
163
  }