ework-web 0.6.0 → 0.8.0

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.6.0",
3
+ "version": "0.8.0",
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",
@@ -7,6 +7,16 @@ export interface DaemonInfo {
7
7
  endpoint: string;
8
8
  }
9
9
 
10
+ export interface DaemonDetail {
11
+ id: number;
12
+ displayName: string;
13
+ endpoint: string;
14
+ capacity: number;
15
+ lastHeartbeat: string | null;
16
+ status: string;
17
+ registeredAt: string | null;
18
+ }
19
+
10
20
  const HEARTBEAT_STALE_MS = 120_000;
11
21
 
12
22
  export async function getActiveDaemons(): Promise<DaemonInfo[]> {
@@ -30,3 +40,31 @@ export async function getActiveDaemons(): Promise<DaemonInfo[]> {
30
40
  return [];
31
41
  }
32
42
  }
43
+
44
+ export async function listAllDaemons(): Promise<DaemonDetail[]> {
45
+ try {
46
+ const rows = await getDB().all<{
47
+ id: number;
48
+ display_name: string | null;
49
+ internal_endpoint: string;
50
+ capacity: number | null;
51
+ last_heartbeat: string | null;
52
+ status: string | null;
53
+ registered_at: string | null;
54
+ }>(
55
+ `SELECT id, display_name, internal_endpoint, capacity, last_heartbeat, status, registered_at FROM {{d_daemons}} ORDER BY id`,
56
+ );
57
+ return rows.map((r) => ({
58
+ id: r.id,
59
+ displayName: r.display_name ?? `daemon-${r.id}`,
60
+ endpoint: r.internal_endpoint,
61
+ capacity: r.capacity ?? 0,
62
+ lastHeartbeat: r.last_heartbeat,
63
+ status: r.status ?? "unknown",
64
+ registeredAt: r.registered_at,
65
+ }));
66
+ } catch (e) {
67
+ log.info(`coordination: list daemons failed (${e instanceof Error ? e.message : String(e)})`);
68
+ return [];
69
+ }
70
+ }
package/src/index.ts CHANGED
@@ -5,8 +5,8 @@ import { spawn } from "child_process";
5
5
  import { homedir } from "os";
6
6
  import { loadConfig, DB_OVERRIDABLE, parseOverride, resolveTtsBackend } from "./config";
7
7
  import type { Config } from "./config";
8
- import { setConfig, initDB } from "./db";
9
- import { getActiveDaemons } from "./coordination";
8
+ import { setConfig, initDB, getDB } from "./db";
9
+ import { getActiveDaemons, listAllDaemons } from "./coordination";
10
10
  import { testMysqlConnection, migrateSqliteToMysql, writeMysqlEnv, migrateMysqlToSqlite, writeSqliteEnv, migrateDaemonSqliteToMysql } from "./db-admin";
11
11
  import type { MysqlTargetOpts } from "./db-admin";
12
12
  import { checkAuth, makeAuthCookieHeader, clearAuthCookieHeader, loginHTML, sanitizeNext, ensureBootstrapAdmin, ensureBootstrapSystem, isReservedSystemLogin } from "./auth";
@@ -88,6 +88,7 @@ import {
88
88
  import { classifyActor, type CommentView } from "./render/components";
89
89
  import { buildWebhooksPage } from "./views/webhooks";
90
90
  import { buildWebhookDeliveriesPage } from "./views/webhookDeliveries";
91
+ import { browseRemoteFile, proxyFileSince, RemoteFileError } from "./remote-file";
91
92
  import { buildProjectMembersPage } from "./views/projectMembers";
92
93
  import { buildProjectUpstreamsPage, trySetUpstreamUrls } from "./views/projectUpstreams";
93
94
  import { buildProjectModelPage } from "./views/projectModel";
@@ -410,6 +411,7 @@ function chunkTextTTS(text: string, max = 120): string[] {
410
411
  async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean; user: UserRow | null }): Promise<Response> {
411
412
  if (url.pathname === "/static/app.js") return staticAsset("app.js", "text/javascript; charset=utf-8", req);
412
413
  if (url.pathname === "/static/db-wizard.js") return staticAsset("db-wizard.js", "text/javascript; charset=utf-8", req);
414
+ if (url.pathname === "/static/daemon-mgr.js") return staticAsset("daemon-mgr.js", "text/javascript; charset=utf-8", req);
413
415
  if (url.pathname === "/static/session.js") return staticAsset("session.js", "text/javascript; charset=utf-8", req);
414
416
  if (url.pathname === "/static/file.js") return staticAsset("file.js", "text/javascript; charset=utf-8", req);
415
417
  if (url.pathname === "/static/tts.js") return staticAsset("tts.js", "text/javascript; charset=utf-8", req);
@@ -599,6 +601,18 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
599
601
 
600
602
  if (url.pathname === "/file") {
601
603
  const rawPath = url.searchParams.get("path") ?? "";
604
+ const daemonParam = url.searchParams.get("daemon");
605
+ if (daemonParam && !isLocalhost(daemonParam)) {
606
+ try {
607
+ const mode = url.searchParams.get("mode") ?? "tail";
608
+ const order = url.searchParams.get("order") ?? "desc";
609
+ const { html: body } = await browseRemoteFile(daemonParam, rawPath, mode, order, ctx.user?.login);
610
+ return html(body);
611
+ } catch (e) {
612
+ const status = e instanceof RemoteFileError ? e.status : 500;
613
+ return html(errorPage(status === 404 ? "文件不存在" : "远程访问失败", errMsg(e)), status);
614
+ }
615
+ }
602
616
  const mode = url.searchParams.get("mode") ?? undefined;
603
617
  const order = url.searchParams.get("order") ?? undefined;
604
618
  try {
@@ -628,6 +642,15 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
628
642
  const rawPath = url.searchParams.get("path") ?? "";
629
643
  const after = Number(url.searchParams.get("after") ?? "0");
630
644
  if (!Number.isFinite(after) || after < 0) return json({ error: "bad after" }, 400);
645
+ const daemonParam = url.searchParams.get("daemon");
646
+ if (daemonParam && !isLocalhost(daemonParam)) {
647
+ try {
648
+ return json(await proxyFileSince(daemonParam, rawPath, after));
649
+ } catch (e) {
650
+ const status = e instanceof RemoteFileError ? e.status : 500;
651
+ return json({ error: errMsg(e) }, status);
652
+ }
653
+ }
631
654
  try {
632
655
  const delta = readFileSince(cfg, rawPath, after);
633
656
  return json(delta);
@@ -809,6 +832,73 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
809
832
  return json({ ...result, targetPath, restarting: true });
810
833
  }
811
834
 
835
+ if (req.method === "GET" && url.pathname === "/api/daemons") {
836
+ if (!ctx.user || ctx.user.is_admin !== 1) return json({ error: "admin required" }, 403);
837
+ return json(await listAllDaemons());
838
+ }
839
+
840
+ if (req.method === "POST" && url.pathname === "/api/daemons") {
841
+ if (!ctx.user || ctx.user.is_admin !== 1) return json({ error: "admin required" }, 403);
842
+ if (!rateLimit(`daemons-add:${ip}`, 5, 5 / 3600)) return json({ error: "rate limited" }, 429);
843
+ const payload = await req.json().catch(() => ({} as unknown));
844
+ const port = (payload && typeof payload === "object" && "port" in payload
845
+ ? (payload as { port?: unknown }).port
846
+ : undefined);
847
+ const args = ["add-daemon"];
848
+ if (typeof port === "number" && Number.isFinite(port) && port > 0 && port < 65536) {
849
+ args.push(String(Math.trunc(port)));
850
+ } else if (port !== undefined && port !== null) {
851
+ return json({ ok: false, error: "invalid port" }, 400);
852
+ }
853
+ let child: import("child_process").ChildProcessByStdio<null, import("stream").Readable, import("stream").Readable>;
854
+ try {
855
+ child = spawn("ework-aio", args, { stdio: ["ignore", "pipe", "pipe"] });
856
+ } catch {
857
+ return json({ ok: false, error: "ework-aio not found" });
858
+ }
859
+ const result = await new Promise<{ ok: boolean; output?: string; error?: string }>((resolve) => {
860
+ const ac = new AbortController();
861
+ const timer = setTimeout(() => {
862
+ ac.abort();
863
+ try { child.kill("SIGKILL"); } catch { /* already dead */ }
864
+ }, 30_000);
865
+ let stdout = "";
866
+ let stderr = "";
867
+ let settled = false;
868
+ const finish = (r: { ok: boolean; output?: string; error?: string }) => {
869
+ if (settled) return;
870
+ settled = true;
871
+ clearTimeout(timer);
872
+ resolve(r);
873
+ };
874
+ child.stdout.on("data", (b: Buffer) => { stdout += b.toString("utf-8"); });
875
+ child.stderr.on("data", (b: Buffer) => { stderr += b.toString("utf-8"); });
876
+ child.once("error", () => finish({ ok: false, error: "ework-aio not found" }));
877
+ child.once("exit", (code) => {
878
+ if (code === 0) finish({ ok: true, output: stdout });
879
+ else finish({ ok: false, error: stderr.trim() || `ework-aio add-daemon exited with code ${code}` });
880
+ });
881
+ ac.signal.addEventListener("abort", () => {
882
+ finish({ ok: false, error: "timeout: ework-aio add-daemon did not exit within 30s" });
883
+ });
884
+ });
885
+ return json(result, result.ok ? 200 : 422);
886
+ }
887
+
888
+ const daemonIdRe = /^\/api\/daemons\/(\d+)$/;
889
+ if (req.method === "DELETE" && daemonIdRe.test(url.pathname)) {
890
+ if (!ctx.user || ctx.user.is_admin !== 1) return json({ error: "admin required" }, 403);
891
+ const match = url.pathname.match(daemonIdRe);
892
+ const id = match ? Number(match[1]) : NaN;
893
+ if (!Number.isFinite(id)) return json({ error: "invalid id" }, 400);
894
+ try {
895
+ await getDB().run(`UPDATE {{d_daemons}} SET status = 'drained' WHERE id = ?`, [id]);
896
+ } catch (e) {
897
+ return json({ ok: false, error: errMsg(e) });
898
+ }
899
+ return json({ ok: true });
900
+ }
901
+
812
902
  if (url.pathname === "/settings") {
813
903
  if (req.method === "GET") {
814
904
  return html(buildSettingsPage(cfg, url.searchParams.get("saved") === "1", ctx.user!, await listCachedModels()).html);
@@ -0,0 +1,206 @@
1
+ import { THEME_CSS, escapeHtml, tabNavHTML } from "./render/layout";
2
+
3
+ export class RemoteFileError extends Error {
4
+ status: number;
5
+ constructor(message: string, status: number) {
6
+ super(message);
7
+ this.name = "RemoteFileError";
8
+ this.status = status;
9
+ }
10
+ }
11
+
12
+ interface DirEntry {
13
+ name: string;
14
+ isDir: boolean;
15
+ size: number;
16
+ mtime: number;
17
+ }
18
+
19
+ interface DirListing {
20
+ path: string;
21
+ entries: DirEntry[];
22
+ }
23
+
24
+ interface FileContent {
25
+ path: string;
26
+ size: number;
27
+ rows: { n: number; t: string }[];
28
+ mode: string;
29
+ byteCapped: boolean;
30
+ note: string;
31
+ }
32
+
33
+ function fmtSize(n: number): string {
34
+ if (n < 1024) return `${n}B`;
35
+ if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)}KB`;
36
+ return `${(n / (1024 * 1024)).toFixed(1)}MB`;
37
+ }
38
+
39
+ function fmtMtime(ms: number): string {
40
+ if (!ms) return "";
41
+ const diff = Date.now() - ms;
42
+ if (diff < 60000) return "刚刚";
43
+ if (diff < 3600000) return `${Math.floor(diff / 60000)}分前`;
44
+ if (diff < 86400000) return `${Math.floor(diff / 3600000)}时前`;
45
+ return new Date(ms).toISOString().slice(5, 16);
46
+ }
47
+
48
+ async function fetchDaemon(
49
+ endpoint: string,
50
+ apiPath: string,
51
+ params: Record<string, string>
52
+ ): Promise<Response> {
53
+ const url = new URL(`${endpoint}${apiPath}`);
54
+ for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
55
+ const resp = await fetch(url.toString(), { signal: AbortSignal.timeout(10_000) });
56
+ return resp;
57
+ }
58
+
59
+ export async function browseRemoteFile(
60
+ endpoint: string,
61
+ rawPath: string,
62
+ mode: string,
63
+ order: string,
64
+ viewerLogin?: string
65
+ ): Promise<{ html: string }> {
66
+ const daemonLabel = escapeHtml(endpoint);
67
+
68
+ const listResp = await fetchDaemon(endpoint, "/api/files/list", { path: rawPath });
69
+ if (listResp.ok) {
70
+ const data = (await listResp.json()) as DirListing;
71
+ return { html: renderDirListing(data, endpoint, daemonLabel, viewerLogin) };
72
+ }
73
+ if (listResp.status !== 400) {
74
+ const err = await listResp.json().catch(() => ({ error: "unknown" })) as { error?: string };
75
+ throw new RemoteFileError(err.error ?? `daemon returned ${listResp.status}`, listResp.status);
76
+ }
77
+
78
+ const readResp = await fetchDaemon(endpoint, "/api/files/read", {
79
+ path: rawPath,
80
+ mode: mode === "head" ? "head" : "tail",
81
+ order: order === "asc" ? "asc" : "desc",
82
+ });
83
+ if (!readResp.ok) {
84
+ const err = await readResp.json().catch(() => ({ error: "unknown" })) as { error?: string };
85
+ throw new RemoteFileError(err.error ?? `daemon returned ${readResp.status}`, readResp.status);
86
+ }
87
+ const data = (await readResp.json()) as FileContent;
88
+ return { html: renderFileContent(data, endpoint, daemonLabel, viewerLogin) };
89
+ }
90
+
91
+ function renderDirListing(
92
+ data: DirListing,
93
+ endpoint: string,
94
+ daemonLabel: string,
95
+ viewerLogin?: string
96
+ ): string {
97
+ const enc = encodeURIComponent;
98
+ const daemonParam = `&daemon=${enc(endpoint)}`;
99
+ const parentPath = data.path.split("/").slice(0, -1).join("/") || "/";
100
+ const rows = data.entries.map((e) => {
101
+ const childPath = `${data.path.replace(/\/$/, "")}/${e.name}`;
102
+ const icon = e.isDir ? "📁" : "📄";
103
+ const size = e.isDir ? "—" : fmtSize(e.size);
104
+ return `<tr>
105
+ <td><a href="/file?path=${enc(childPath)}${daemonParam}">${icon} ${escapeHtml(e.name)}</a></td>
106
+ <td class="ms">${size}</td>
107
+ <td class="ts">${fmtMtime(e.mtime)}</td>
108
+ </tr>`;
109
+ }).join("") || `<tr><td colspan="3" class="empty">空目录</td></tr>`;
110
+
111
+ const user = viewerLogin ? { login: viewerLogin, is_admin: 0 } : undefined;
112
+ return `<!doctype html>
113
+ <html lang="zh"><head><meta charset="utf-8">
114
+ <meta name="viewport" content="width=device-width,initial-scale=1">
115
+ <link rel="icon" type="image/svg+xml" href="/favicon.svg">
116
+ <title>ework-web · ${escapeHtml(data.path)}</title>
117
+ <style>${THEME_CSS}
118
+ .nav{display:flex;align-items:center;gap:.5rem;padding:.55rem 1rem;background:var(--header-bg);color:var(--header-text);font-size:13px}
119
+ .nav a{color:var(--header-text);opacity:.95}
120
+ .wrap{max-width:900px;margin:0 auto;padding:1rem}
121
+ h1{font-size:15px;margin:0 0 .3rem;font-family:ui-monospace,monospace;word-break:break-all}
122
+ .remote-badge{display:inline-block;background:#1f6feb33;color:#58a6ff;padding:2px 8px;border-radius:10px;font-size:11px;font-weight:600;margin-left:.5rem}
123
+ .dtable{width:100%;border-collapse:collapse;font-size:13px;margin-top:.6rem}
124
+ .dtable th,.dtable td{border:1px solid var(--border);padding:.3rem .5rem;text-align:left}
125
+ .dtable th{background:var(--bg);color:var(--text-muted);font-weight:600}
126
+ .dtable a{color:var(--accent);text-decoration:none}
127
+ .dtable a:hover{text-decoration:underline}
128
+ .dtable .ms,.dtable .ts{color:var(--text-muted);white-space:nowrap;font-size:12px}
129
+ .empty{color:var(--text-muted);text-align:center;padding:.6rem}
130
+ .parent-link{font-size:13px;margin-bottom:.4rem}
131
+ .parent-link a{color:var(--text-muted)}
132
+ .tabs{display:flex;gap:.3rem;padding:.5rem 1rem;border-bottom:1px solid var(--border);font-size:13px}
133
+ .tab{padding:.3rem .7rem;border-radius:6px 6px 0 0;text-decoration:none;color:var(--text-muted)}
134
+ .tab.active{background:var(--accent);color:#fff}
135
+ </style></head><body>
136
+ <header class="nav"><a href="/" style="color:var(--header-text)">🏠 ework-web</a></header>
137
+ ${tabNavHTML("sessions", user)}
138
+ <main class="wrap">
139
+ <div class="parent-link">📁 <a href="/file?path=${enc(parentPath)}${daemonParam}">..</a></div>
140
+ <h1>${escapeHtml(data.path)}<span class="remote-badge">远程 ${daemonLabel}</span></h1>
141
+ <table class="dtable"><thead><tr>
142
+ <th>名称</th><th>大小</th><th>修改时间</th>
143
+ </tr></thead><tbody>${rows}</tbody></table>
144
+ </main></body></html>`;
145
+ }
146
+
147
+ function renderFileContent(
148
+ data: FileContent,
149
+ endpoint: string,
150
+ daemonLabel: string,
151
+ viewerLogin?: string
152
+ ): string {
153
+ const enc = encodeURIComponent;
154
+ const daemonParam = `&daemon=${enc(endpoint)}`;
155
+ const user = viewerLogin ? { login: viewerLogin, is_admin: 0 } : undefined;
156
+ const lines = data.rows.map((r) =>
157
+ `<div class="l"><span class="ln">${r.n}</span><span class="lt">${escapeHtml(r.t)}</span></div>`
158
+ ).join("");
159
+
160
+ return `<!doctype html>
161
+ <html lang="zh"><head><meta charset="utf-8">
162
+ <meta name="viewport" content="width=device-width,initial-scale=1">
163
+ <link rel="icon" type="image/svg+xml" href="/favicon.svg">
164
+ <title>ework-web · ${escapeHtml(data.path)}</title>
165
+ <style>${THEME_CSS}
166
+ .nav{display:flex;align-items:center;gap:.5rem;padding:.55rem 1rem;background:var(--header-bg);color:var(--header-text);font-size:13px}
167
+ .nav a{color:var(--header-text);opacity:.95}
168
+ .wrap{max-width:1000px;margin:0 auto;padding:1rem}
169
+ h1{font-size:14px;margin:0 0 .3rem;font-family:ui-monospace,monospace;word-break:break-all}
170
+ .remote-badge{display:inline-block;background:#1f6feb33;color:#58a6ff;padding:2px 8px;border-radius:10px;font-size:11px;font-weight:600;margin-left:.5rem}
171
+ .note{color:var(--text-muted);font-size:12px;margin:0 0 .5rem}
172
+ .parent-link{font-size:13px;margin-bottom:.4rem}
173
+ .parent-link a{color:var(--text-muted)}
174
+ .fview{background:var(--bg-elev);border:1px solid var(--border);border-radius:6px;overflow:auto;max-height:80vh;font-size:12.5px;font-family:ui-monospace,monospace}
175
+ .l{display:flex}
176
+ .ln{color:var(--text-muted);min-width:3.5rem;text-align:right;padding:0 .5rem;user-select:none;border-right:1px solid var(--border)}
177
+ .lt{padding:0 .5rem;white-space:pre-wrap;word-break:break-all;flex:1}
178
+ .tabs{display:flex;gap:.3rem;padding:.5rem 1rem;border-bottom:1px solid var(--border);font-size:13px}
179
+ .tab{padding:.3rem .7rem;border-radius:6px 6px 0 0;text-decoration:none;color:var(--text-muted)}
180
+ .tab.active{background:var(--accent);color:#fff}
181
+ </style></head><body>
182
+ <header class="nav"><a href="/" style="color:var(--header-text)">🏠 ework-web</a></header>
183
+ ${tabNavHTML("sessions", user)}
184
+ <main class="wrap">
185
+ <div class="parent-link">📁 <a href="/file?path=${enc(data.path.split("/").slice(0, -1).join("/") || "/")}${daemonParam}">..</a></div>
186
+ <h1>${escapeHtml(data.path)}<span class="remote-badge">远程 ${daemonLabel}</span></h1>
187
+ ${data.note ? `<p class="note">${escapeHtml(data.note)}</p>` : ""}
188
+ <div class="fview">${lines}</div>
189
+ </main></body></html>`;
190
+ }
191
+
192
+ export async function proxyFileSince(
193
+ endpoint: string,
194
+ rawPath: string,
195
+ after: number
196
+ ): Promise<{ rows: { n: number; t: string }[]; size: number; rotated: boolean; capped: boolean }> {
197
+ const resp = await fetchDaemon(endpoint, "/api/files/since", {
198
+ path: rawPath,
199
+ after: String(after),
200
+ });
201
+ if (!resp.ok) {
202
+ const err = await resp.json().catch(() => ({ error: "unknown" })) as { error?: string };
203
+ throw new RemoteFileError(err.error ?? `daemon returned ${resp.status}`, resp.status);
204
+ }
205
+ return await resp.json();
206
+ }
@@ -0,0 +1,145 @@
1
+ // Daemon cluster manager (settings page). External file because CSP
2
+ // script-src 'self' blocks inline scripts — see index.ts SEC_HEADERS.
3
+ (function () {
4
+ "use strict";
5
+ var listEl = document.getElementById('daemon-list');
6
+ if (!listEl) return;
7
+ var resultEl = document.getElementById('daemon-result');
8
+ var addBtn = document.getElementById('daemon-add');
9
+ var portInput = document.getElementById('daemon-port');
10
+ var refreshTimer = null;
11
+ var inFlight = false;
12
+
13
+ function esc(s) {
14
+ return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) {
15
+ return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c];
16
+ });
17
+ }
18
+
19
+ function fmtTime(s) {
20
+ if (!s) return '—';
21
+ var t = Date.parse(String(s).includes('T') ? s : String(s).replace(' ', 'T') + 'Z');
22
+ if (Number.isNaN(t)) return esc(s);
23
+ var d = new Date(t);
24
+ var pad = function (n) { return n < 10 ? '0' + n : '' + n; };
25
+ return esc(d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) +
26
+ ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds()));
27
+ }
28
+
29
+ function statusPill(status) {
30
+ var raw = String(status || 'unknown').toLowerCase();
31
+ var cls = raw === 'active' ? 'active' : raw === 'drained' ? 'drained' : (raw === 'dead' ? 'dead' : 'unknown');
32
+ return '<span class="pill ' + cls + '">' + esc(raw) + '</span>';
33
+ }
34
+
35
+ function render(daemons) {
36
+ if (!Array.isArray(daemons) || daemons.length === 0) {
37
+ listEl.innerHTML = '<div class="daemon-empty">暂无 daemon 注册。可在下方启动新实例。</div>';
38
+ return;
39
+ }
40
+ var rows = daemons.map(function (d) {
41
+ var stopBtn = String(d.status || '').toLowerCase() === 'active'
42
+ ? '<button type="button" class="stop" data-id="' + d.id + '">停止</button>'
43
+ : '';
44
+ return '<tr>' +
45
+ '<td class="cap">' + d.id + '</td>' +
46
+ '<td class="endpoint">' + esc(d.endpoint || '') + '</td>' +
47
+ '<td class="cap">' + esc(d.capacity != null ? d.capacity : '') + '</td>' +
48
+ '<td class="heartbeat">' + fmtTime(d.lastHeartbeat) + '</td>' +
49
+ '<td class="registered">' + fmtTime(d.registeredAt) + '</td>' +
50
+ '<td>' + statusPill(d.status) + '</td>' +
51
+ '<td>' + stopBtn + '</td>' +
52
+ '</tr>';
53
+ }).join('');
54
+ listEl.innerHTML =
55
+ '<table><thead><tr>' +
56
+ '<th>ID</th><th>Endpoint</th><th>容量</th><th>心跳</th><th>注册于</th><th>状态</th><th></th>' +
57
+ '</tr></thead><tbody>' + rows + '</tbody></table>';
58
+ listEl.querySelectorAll('button.stop').forEach(function (b) {
59
+ b.addEventListener('click', function () { stopDaemon(Number(b.getAttribute('data-id'))); });
60
+ });
61
+ }
62
+
63
+ function setResult(text, kind) {
64
+ if (!resultEl) return;
65
+ resultEl.className = 'db-result db-' + (kind === 'ok' ? 'ok' : kind === 'err' ? 'err' : 'loading');
66
+ resultEl.textContent = text;
67
+ }
68
+
69
+ async function loadDaemons() {
70
+ if (inFlight) return;
71
+ inFlight = true;
72
+ try {
73
+ var res = await fetch('/api/daemons', { headers: { 'Accept': 'application/json' } });
74
+ if (!res.ok) {
75
+ var errBody = await res.json().catch(function () { return { error: 'HTTP ' + res.status }; });
76
+ listEl.innerHTML = '<div class="daemon-empty">加载失败:' + esc(errBody.error || res.status) + '</div>';
77
+ return;
78
+ }
79
+ render(await res.json());
80
+ } catch (e) {
81
+ listEl.innerHTML = '<div class="daemon-empty">加载失败:' + esc(e.message || String(e)) + '</div>';
82
+ } finally {
83
+ inFlight = false;
84
+ }
85
+ }
86
+
87
+ async function addDaemon() {
88
+ if (addBtn) addBtn.disabled = true;
89
+ setResult('正在启动 daemon…', 'loading');
90
+ var body = {};
91
+ var portVal = portInput && portInput.value ? Number(portInput.value) : NaN;
92
+ if (portInput && portInput.value.trim() !== '' && Number.isFinite(portVal)) {
93
+ body.port = portVal;
94
+ }
95
+ try {
96
+ var res = await fetch('/api/daemons', {
97
+ method: 'POST',
98
+ headers: { 'Content-Type': 'application/json' },
99
+ body: JSON.stringify(body)
100
+ });
101
+ var data = await res.json();
102
+ if (data.ok) {
103
+ setResult('✓ daemon 启动成功' + (data.output ? ':\n' + data.output : ''), 'ok');
104
+ if (portInput) portInput.value = '';
105
+ await loadDaemons();
106
+ } else {
107
+ setResult('✗ ' + (data.error || '未知错误'), 'err');
108
+ }
109
+ } catch (e) {
110
+ setResult('✗ ' + (e.message || String(e)), 'err');
111
+ } finally {
112
+ if (addBtn) addBtn.disabled = false;
113
+ }
114
+ }
115
+
116
+ async function stopDaemon(id) {
117
+ if (!confirm('确认停止 daemon #' + id + '?\n只会标记为 drained(不再接新任务),需手动 kill 进程。')) return;
118
+ setResult('正在标记 daemon #' + id + ' 为 drained…', 'loading');
119
+ try {
120
+ var res = await fetch('/api/daemons/' + id, { method: 'DELETE' });
121
+ var data = await res.json();
122
+ if (data.ok) {
123
+ setResult('✓ daemon #' + id + ' 已标记为 drained', 'ok');
124
+ await loadDaemons();
125
+ } else {
126
+ setResult('✗ ' + (data.error || '未知错误'), 'err');
127
+ }
128
+ } catch (e) {
129
+ setResult('✗ ' + (e.message || String(e)), 'err');
130
+ }
131
+ }
132
+
133
+ if (addBtn) addBtn.addEventListener('click', addDaemon);
134
+ document.addEventListener('visibilitychange', function () {
135
+ if (document.hidden) {
136
+ if (refreshTimer) { clearInterval(refreshTimer); refreshTimer = null; }
137
+ } else if (!refreshTimer) {
138
+ loadDaemons();
139
+ refreshTimer = setInterval(loadDaemons, 10000);
140
+ }
141
+ });
142
+
143
+ loadDaemons();
144
+ refreshTimer = setInterval(loadDaemons, 10000);
145
+ })();
@@ -67,6 +67,21 @@ ${isMysql ? `<hr style="border:0;border-top:1px solid var(--border);margin:1rem
67
67
  </section>`;
68
68
  }
69
69
 
70
+ function buildDaemonSection(viewer: UserRow): string {
71
+ if (viewer.is_admin !== 1) return "";
72
+ return `<section class="sg daemon-section">
73
+ <h2>Daemon 集群</h2>
74
+ <p class="db-badge">运行中的 daemon 实例。新 daemon 通过 <code>ework-aio add-daemon</code> 启动;停止只标记为 drained,需手动结束进程。</p>
75
+ <div id="daemon-list" class="daemon-list">加载中…</div>
76
+ <div class="db-controls">
77
+ <label class="sf" style="margin:0"><span>端口(可选)</span><input type="number" id="daemon-port" min="1" max="65535" placeholder="自动分配" autocomplete="off"></label>
78
+ <button type="button" id="daemon-add">添加 Daemon</button>
79
+ </div>
80
+ <div id="daemon-result" class="db-result"></div>
81
+ <script src="/static/daemon-mgr.js"></script>
82
+ </section>`;
83
+ }
84
+
70
85
  export function buildSettingsPage(cfg: Config, saved: boolean, viewer: UserRow, models: CachedModel[]): { html: string } {
71
86
  const groups = SETTINGS_GROUPS.map(
72
87
  (g) =>
@@ -109,6 +124,20 @@ button.secondary{background:transparent;color:var(--text-muted);border:1px solid
109
124
  .db-result.db-loading{background:var(--bg);color:var(--text-muted)}
110
125
  .db-result.db-ok{background:rgba(40,167,69,.15);color:#5eb88a}
111
126
  .db-result.db-err{background:rgba(220,53,69,.15);color:#e87c7c}
127
+ .daemon-list{margin-bottom:.7rem}
128
+ .daemon-list table{width:100%;border-collapse:collapse;font-size:12px}
129
+ .daemon-list th,.daemon-list td{padding:.35rem .4rem;border-bottom:1px solid var(--border);text-align:left;vertical-align:top}
130
+ .daemon-list th{color:var(--text-muted);font-weight:normal;font-size:11px;text-transform:uppercase;letter-spacing:.03em}
131
+ .daemon-list td.endpoint{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11px;word-break:break-all}
132
+ .daemon-list td.cap{text-align:right;font-variant-numeric:tabular-nums}
133
+ .daemon-list td.heartbeat,.daemon-list td.registered{color:var(--text-muted);font-size:11px;white-space:nowrap}
134
+ .daemon-list .pill{display:inline-block;padding:.12rem .5rem;border-radius:99px;font-size:11px;line-height:1.4}
135
+ .daemon-list .pill.active{background:rgba(40,167,69,.18);color:#5eb88a}
136
+ .daemon-list .pill.drained{background:rgba(255,193,7,.18);color:#d4a942}
137
+ .daemon-list .pill.dead,.daemon-list .pill.unknown{background:rgba(220,53,69,.18);color:#e87c7c}
138
+ .daemon-list button.stop{padding:.25rem .6rem;font-size:11px;background:transparent;color:#e87c7c;border:1px solid rgba(220,53,69,.4)}
139
+ .daemon-list button.stop:hover{background:rgba(220,53,69,.12)}
140
+ .daemon-empty{color:var(--text-muted);font-size:13px;padding:.5rem 0}
112
141
  </style></head><body>
113
142
  <header class="nav"><a href="/" style="color:var(--header-text)">🏠 ework-web</a><span style="opacity:.8"> · 设置</span></header>
114
143
  <main class="wrap">
@@ -121,6 +150,7 @@ ${banner}
121
150
  ${modelRefreshForm}
122
151
  ${ttsLink}
123
152
  ${buildDbSection(viewer)}
153
+ ${buildDaemonSection(viewer)}
124
154
  </main></body></html>`;
125
155
  return { html };
126
156
  }