ework-web 0.7.0 → 0.9.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.7.0",
3
+ "version": "0.9.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
+ }
@@ -0,0 +1,174 @@
1
+ import { join } from "path";
2
+ import { existsSync, readFileSync } from "fs";
3
+ import { homedir } from "os";
4
+
5
+ export interface DeployOpts {
6
+ sshHost: string;
7
+ sshUser: string;
8
+ sshPort: number;
9
+ sshKeyFile?: string;
10
+ daemonPort?: number;
11
+ mysqlHost: string;
12
+ }
13
+
14
+ export interface DeployResult {
15
+ ok: boolean;
16
+ output: string;
17
+ error?: string;
18
+ }
19
+
20
+ // Keys forwarded from the local daemon .env to the remote one. Secrets are
21
+ // never logged — only written to the remote .env over SSH.
22
+ const FORWARD_KEYS = [
23
+ "WORK_DB_DRIVER",
24
+ "WORK_DB_USER",
25
+ "WORK_DB_PASSWORD",
26
+ "WORK_DB_NAME",
27
+ "WORK_DB_PREFIX",
28
+ "GITEA_URL",
29
+ "GITEA_TOKEN",
30
+ "BOT_TOKEN",
31
+ "BOT_USERNAME",
32
+ "GITEA_WEBHOOK_SECRET",
33
+ "OPENCODE_BASE_WORKDIR",
34
+ "OPENCODE_BINARY",
35
+ ] as const;
36
+
37
+ function resolveDaemonEnvPath(): string | null {
38
+ const dataDir = process.env.WORK_DAEMON_DATA_DIR;
39
+ if (dataDir) {
40
+ const p = join(dataDir, ".env");
41
+ return existsSync(p) ? p : null;
42
+ }
43
+ const candidates = [
44
+ join(homedir(), ".local", "share", "ework-aio", "ework-daemon", ".env"),
45
+ join(homedir(), ".local", "share", "ework-daemon", ".env"),
46
+ ];
47
+ for (const c of candidates) {
48
+ if (existsSync(c)) return c;
49
+ }
50
+ return null;
51
+ }
52
+
53
+ function parseEnvFile(content: string): Map<string, string> {
54
+ const out = new Map<string, string>();
55
+ for (const line of content.split("\n")) {
56
+ const eq = line.indexOf("=");
57
+ if (eq <= 0) continue;
58
+ const key = line.slice(0, eq).trim();
59
+ if (!key || key.startsWith("#")) continue;
60
+ const val = line.slice(eq + 1).replace(/^["']|["']$/g, "");
61
+ out.set(key, val);
62
+ }
63
+ return out;
64
+ }
65
+
66
+ function buildEnvBlock(env: Map<string, string>, opts: DeployOpts): string {
67
+ const lines: string[] = [];
68
+ for (const k of FORWARD_KEYS) {
69
+ const v = env.get(k);
70
+ if (v === undefined) continue;
71
+ lines.push(`${k}=${v}`);
72
+ }
73
+ lines.push(`WORK_DB_HOST=${opts.mysqlHost}`);
74
+ lines.push(`DAEMON_PORT=${String(opts.daemonPort ?? 3101)}`);
75
+ lines.push(`DAEMON_HOST=0.0.0.0`);
76
+ lines.push(`DAEMON_ENV=production`);
77
+ return lines.join("\n");
78
+ }
79
+
80
+ function buildSetupScript(envBlock: string): string {
81
+ return [
82
+ "set -e",
83
+ `command -v npm >/dev/null 2>&1 || { curl -fsSL https://deb.nodesource.com/setup_22.x | sudo bash -; sudo apt-get install -y nodejs; }`,
84
+ `command -v bun >/dev/null 2>&1 || { curl -fsSL https://bun.sh/install | bash; export BUN_INSTALL="$HOME/.bun"; export PATH="$BUN_INSTALL/bin:$PATH"; }`,
85
+ `npm install -g ework-aio`,
86
+ `mkdir -p ~/.local/share/ework-aio/ework-daemon`,
87
+ `cat > ~/.local/share/ework-aio/ework-daemon/.env <<'EWORK_DAEMON_ENV_EOF'`,
88
+ envBlock,
89
+ `EWORK_DAEMON_ENV_EOF`,
90
+ `ework-aio start daemon`,
91
+ `echo DAEMON_STARTED`,
92
+ ].join("\n");
93
+ }
94
+
95
+ export async function deployRemoteDaemon(opts: DeployOpts): Promise<DeployResult> {
96
+ const envPath = resolveDaemonEnvPath();
97
+ if (!envPath) {
98
+ return {
99
+ ok: false,
100
+ output: "",
101
+ error: "找不到本地 daemon .env(设置 WORK_DAEMON_DATA_DIR 或将 .env 放到 ~/.local/share/ework-aio/ework-daemon/.env)",
102
+ };
103
+ }
104
+ let envContent: string;
105
+ try {
106
+ envContent = readFileSync(envPath, "utf8");
107
+ } catch (e) {
108
+ return { ok: false, output: "", error: e instanceof Error ? e.message : String(e) };
109
+ }
110
+ const env = parseEnvFile(envContent);
111
+ const envBlock = buildEnvBlock(env, opts);
112
+ const script = buildSetupScript(envBlock);
113
+
114
+ const args = [
115
+ "ssh",
116
+ "-o", "StrictHostKeyChecking=accept-new",
117
+ "-p", String(opts.sshPort),
118
+ "-i", opts.sshKeyFile ?? "~/.ssh/id_rsa",
119
+ `${opts.sshUser}@${opts.sshHost}`,
120
+ "bash -s",
121
+ ];
122
+
123
+ let proc;
124
+ try {
125
+ proc = Bun.spawn(args, {
126
+ stdin: new TextEncoder().encode(script),
127
+ stdout: "pipe",
128
+ stderr: "pipe",
129
+ });
130
+ } catch (e) {
131
+ return { ok: false, output: "", error: e instanceof Error ? e.message : String(e) };
132
+ }
133
+
134
+ const ac = new AbortController();
135
+ const timer = setTimeout(() => {
136
+ ac.abort();
137
+ try { proc.kill(); } catch { /* already dead */ }
138
+ }, 60_000);
139
+
140
+ let stdoutText = "";
141
+ let stderrText = "";
142
+ let exitCode: number | null = null;
143
+ let timedOut = false;
144
+ try {
145
+ ac.signal.addEventListener("abort", () => { timedOut = true; });
146
+ [stdoutText, stderrText, exitCode] = await Promise.all([
147
+ new Response(proc.stdout).text(),
148
+ new Response(proc.stderr).text(),
149
+ proc.exited,
150
+ ]);
151
+ } catch (e) {
152
+ return {
153
+ ok: false,
154
+ output: stdoutText + stderrText,
155
+ error: e instanceof Error ? e.message : String(e),
156
+ };
157
+ } finally {
158
+ clearTimeout(timer);
159
+ }
160
+
161
+ if (timedOut) {
162
+ return {
163
+ ok: false,
164
+ output: stdoutText + stderrText,
165
+ error: "SSH 部署超时(60s)",
166
+ };
167
+ }
168
+ const combined = stdoutText + (stderrText ? "\n--- stderr ---\n" + stderrText : "");
169
+ return {
170
+ ok: exitCode === 0,
171
+ output: combined,
172
+ error: exitCode === 0 ? undefined : `ssh 退出码 ${String(exitCode)}`,
173
+ };
174
+ }
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";
@@ -93,6 +93,7 @@ import { buildProjectMembersPage } from "./views/projectMembers";
93
93
  import { buildProjectUpstreamsPage, trySetUpstreamUrls } from "./views/projectUpstreams";
94
94
  import { buildProjectModelPage } from "./views/projectModel";
95
95
  import { handleGiteaApi } from "./giteaApi";
96
+ import { deployRemoteDaemon } from "./daemon-deploy";
96
97
 
97
98
  const __dirname = dirname(fileURLToPath(import.meta.url));
98
99
  const STATIC_DIR = join(__dirname, "static");
@@ -411,6 +412,7 @@ function chunkTextTTS(text: string, max = 120): string[] {
411
412
  async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean; user: UserRow | null }): Promise<Response> {
412
413
  if (url.pathname === "/static/app.js") return staticAsset("app.js", "text/javascript; charset=utf-8", req);
413
414
  if (url.pathname === "/static/db-wizard.js") return staticAsset("db-wizard.js", "text/javascript; charset=utf-8", req);
415
+ if (url.pathname === "/static/daemon-mgr.js") return staticAsset("daemon-mgr.js", "text/javascript; charset=utf-8", req);
414
416
  if (url.pathname === "/static/session.js") return staticAsset("session.js", "text/javascript; charset=utf-8", req);
415
417
  if (url.pathname === "/static/file.js") return staticAsset("file.js", "text/javascript; charset=utf-8", req);
416
418
  if (url.pathname === "/static/tts.js") return staticAsset("tts.js", "text/javascript; charset=utf-8", req);
@@ -831,6 +833,96 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
831
833
  return json({ ...result, targetPath, restarting: true });
832
834
  }
833
835
 
836
+ if (req.method === "GET" && url.pathname === "/api/daemons") {
837
+ if (!ctx.user || ctx.user.is_admin !== 1) return json({ error: "admin required" }, 403);
838
+ return json(await listAllDaemons());
839
+ }
840
+
841
+ if (req.method === "POST" && url.pathname === "/api/daemons") {
842
+ if (!ctx.user || ctx.user.is_admin !== 1) return json({ error: "admin required" }, 403);
843
+ if (!rateLimit(`daemons-add:${ip}`, 5, 5 / 3600)) return json({ error: "rate limited" }, 429);
844
+ const payload = await req.json().catch(() => ({} as unknown));
845
+ const port = (payload && typeof payload === "object" && "port" in payload
846
+ ? (payload as { port?: unknown }).port
847
+ : undefined);
848
+ const args = ["add-daemon"];
849
+ if (typeof port === "number" && Number.isFinite(port) && port > 0 && port < 65536) {
850
+ args.push(String(Math.trunc(port)));
851
+ } else if (port !== undefined && port !== null) {
852
+ return json({ ok: false, error: "invalid port" }, 400);
853
+ }
854
+ let child: import("child_process").ChildProcessByStdio<null, import("stream").Readable, import("stream").Readable>;
855
+ try {
856
+ child = spawn("ework-aio", args, { stdio: ["ignore", "pipe", "pipe"] });
857
+ } catch {
858
+ return json({ ok: false, error: "ework-aio not found" });
859
+ }
860
+ const result = await new Promise<{ ok: boolean; output?: string; error?: string }>((resolve) => {
861
+ const ac = new AbortController();
862
+ const timer = setTimeout(() => {
863
+ ac.abort();
864
+ try { child.kill("SIGKILL"); } catch { /* already dead */ }
865
+ }, 30_000);
866
+ let stdout = "";
867
+ let stderr = "";
868
+ let settled = false;
869
+ const finish = (r: { ok: boolean; output?: string; error?: string }) => {
870
+ if (settled) return;
871
+ settled = true;
872
+ clearTimeout(timer);
873
+ resolve(r);
874
+ };
875
+ child.stdout.on("data", (b: Buffer) => { stdout += b.toString("utf-8"); });
876
+ child.stderr.on("data", (b: Buffer) => { stderr += b.toString("utf-8"); });
877
+ child.once("error", () => finish({ ok: false, error: "ework-aio not found" }));
878
+ child.once("exit", (code) => {
879
+ if (code === 0) finish({ ok: true, output: stdout });
880
+ else finish({ ok: false, error: stderr.trim() || `ework-aio add-daemon exited with code ${code}` });
881
+ });
882
+ ac.signal.addEventListener("abort", () => {
883
+ finish({ ok: false, error: "timeout: ework-aio add-daemon did not exit within 30s" });
884
+ });
885
+ });
886
+ return json(result, result.ok ? 200 : 422);
887
+ }
888
+
889
+ const daemonIdRe = /^\/api\/daemons\/(\d+)$/;
890
+ if (req.method === "DELETE" && daemonIdRe.test(url.pathname)) {
891
+ if (!ctx.user || ctx.user.is_admin !== 1) return json({ error: "admin required" }, 403);
892
+ const match = url.pathname.match(daemonIdRe);
893
+ const id = match ? Number(match[1]) : NaN;
894
+ if (!Number.isFinite(id)) return json({ error: "invalid id" }, 400);
895
+ try {
896
+ await getDB().run(`UPDATE {{d_daemons}} SET status = 'drained' WHERE id = ?`, [id]);
897
+ } catch (e) {
898
+ return json({ ok: false, error: errMsg(e) });
899
+ }
900
+ return json({ ok: true });
901
+ }
902
+
903
+ if (req.method === "POST" && url.pathname === "/api/daemons/deploy") {
904
+ if (!ctx.user || ctx.user.is_admin !== 1) return json({ error: "admin required" }, 403);
905
+ if (!rateLimit(`daemons-deploy:${ip}`, 3, 3 / 3600)) return json({ error: "rate limited" }, 429);
906
+ const payload = await req.json().catch(() => ({} as unknown));
907
+ if (!payload || typeof payload !== "object") return json({ ok: false, error: "invalid body" }, 400);
908
+ const obj = payload as Record<string, unknown>;
909
+ const sshHost = typeof obj.sshHost === "string" ? obj.sshHost.trim() : "";
910
+ const sshUser = typeof obj.sshUser === "string" ? obj.sshUser.trim() : "";
911
+ const mysqlHost = typeof obj.mysqlHost === "string" ? obj.mysqlHost.trim() : "";
912
+ if (!sshHost || !sshUser || !mysqlHost) {
913
+ return json({ ok: false, error: "sshHost, sshUser, mysqlHost 必填" }, 400);
914
+ }
915
+ const sshPort = typeof obj.sshPort === "number" && Number.isFinite(obj.sshPort) && obj.sshPort > 0 && obj.sshPort < 65536
916
+ ? Math.trunc(obj.sshPort)
917
+ : 22;
918
+ const sshKeyFile = typeof obj.sshKeyFile === "string" && obj.sshKeyFile.trim() ? obj.sshKeyFile.trim() : undefined;
919
+ const daemonPort = typeof obj.daemonPort === "number" && Number.isFinite(obj.daemonPort) && obj.daemonPort > 0 && obj.daemonPort < 65536
920
+ ? Math.trunc(obj.daemonPort)
921
+ : undefined;
922
+ const result = await deployRemoteDaemon({ sshHost, sshUser, sshPort, sshKeyFile, daemonPort, mysqlHost });
923
+ return json(result, result.ok ? 200 : 422);
924
+ }
925
+
834
926
  if (url.pathname === "/settings") {
835
927
  if (req.method === "GET") {
836
928
  return html(buildSettingsPage(cfg, url.searchParams.get("saved") === "1", ctx.user!, await listCachedModels()).html);
@@ -0,0 +1,211 @@
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 deployBtn = document.getElementById('daemon-deploy');
11
+ var deployResultEl = document.getElementById('deploy-result');
12
+ var refreshTimer = null;
13
+ var inFlight = false;
14
+
15
+ function esc(s) {
16
+ return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) {
17
+ return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c];
18
+ });
19
+ }
20
+
21
+ function fmtTime(s) {
22
+ if (!s) return '—';
23
+ var t = Date.parse(String(s).includes('T') ? s : String(s).replace(' ', 'T') + 'Z');
24
+ if (Number.isNaN(t)) return esc(s);
25
+ var d = new Date(t);
26
+ var pad = function (n) { return n < 10 ? '0' + n : '' + n; };
27
+ return esc(d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) +
28
+ ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds()));
29
+ }
30
+
31
+ function statusPill(status) {
32
+ var raw = String(status || 'unknown').toLowerCase();
33
+ var cls = raw === 'active' ? 'active' : raw === 'drained' ? 'drained' : (raw === 'dead' ? 'dead' : 'unknown');
34
+ return '<span class="pill ' + cls + '">' + esc(raw) + '</span>';
35
+ }
36
+
37
+ function isLocalhostEndpoint(endpoint) {
38
+ var host = String(endpoint || '').replace(/^https?:\/\//, '').split(':')[0] || '';
39
+ return /^(127\.|localhost$|0\.0\.0\.0$|::1$|\[::1\]$)/.test(host);
40
+ }
41
+
42
+ function locationCell(endpoint) {
43
+ if (isLocalhostEndpoint(endpoint)) {
44
+ return '<span class="pill loc-local">本机</span>';
45
+ }
46
+ return '<span class="pill loc-remote">🌐 远程</span>';
47
+ }
48
+
49
+ function render(daemons) {
50
+ if (!Array.isArray(daemons) || daemons.length === 0) {
51
+ listEl.innerHTML = '<div class="daemon-empty">暂无 daemon 注册。可在下方启动新实例。</div>';
52
+ return;
53
+ }
54
+ var rows = daemons.map(function (d) {
55
+ var stopBtn = String(d.status || '').toLowerCase() === 'active'
56
+ ? '<button type="button" class="stop" data-id="' + d.id + '">停止</button>'
57
+ : '';
58
+ return '<tr>' +
59
+ '<td class="cap">' + d.id + '</td>' +
60
+ '<td class="loc">' + locationCell(d.endpoint) + '</td>' +
61
+ '<td class="endpoint">' + esc(d.endpoint || '') + '</td>' +
62
+ '<td class="cap">' + esc(d.capacity != null ? d.capacity : '') + '</td>' +
63
+ '<td class="heartbeat">' + fmtTime(d.lastHeartbeat) + '</td>' +
64
+ '<td class="registered">' + fmtTime(d.registeredAt) + '</td>' +
65
+ '<td>' + statusPill(d.status) + '</td>' +
66
+ '<td>' + stopBtn + '</td>' +
67
+ '</tr>';
68
+ }).join('');
69
+ listEl.innerHTML =
70
+ '<table><thead><tr>' +
71
+ '<th>ID</th><th>位置</th><th>Endpoint</th><th>容量</th><th>心跳</th><th>注册于</th><th>状态</th><th></th>' +
72
+ '</tr></thead><tbody>' + rows + '</tbody></table>';
73
+ listEl.querySelectorAll('button.stop').forEach(function (b) {
74
+ b.addEventListener('click', function () { stopDaemon(Number(b.getAttribute('data-id'))); });
75
+ });
76
+ }
77
+
78
+ function setResult(text, kind) {
79
+ if (!resultEl) return;
80
+ resultEl.className = 'db-result db-' + (kind === 'ok' ? 'ok' : kind === 'err' ? 'err' : 'loading');
81
+ resultEl.textContent = text;
82
+ }
83
+
84
+ async function loadDaemons() {
85
+ if (inFlight) return;
86
+ inFlight = true;
87
+ try {
88
+ var res = await fetch('/api/daemons', { headers: { 'Accept': 'application/json' } });
89
+ if (!res.ok) {
90
+ var errBody = await res.json().catch(function () { return { error: 'HTTP ' + res.status }; });
91
+ listEl.innerHTML = '<div class="daemon-empty">加载失败:' + esc(errBody.error || res.status) + '</div>';
92
+ return;
93
+ }
94
+ render(await res.json());
95
+ } catch (e) {
96
+ listEl.innerHTML = '<div class="daemon-empty">加载失败:' + esc(e.message || String(e)) + '</div>';
97
+ } finally {
98
+ inFlight = false;
99
+ }
100
+ }
101
+
102
+ async function addDaemon() {
103
+ if (addBtn) addBtn.disabled = true;
104
+ setResult('正在启动 daemon…', 'loading');
105
+ var body = {};
106
+ var portVal = portInput && portInput.value ? Number(portInput.value) : NaN;
107
+ if (portInput && portInput.value.trim() !== '' && Number.isFinite(portVal)) {
108
+ body.port = portVal;
109
+ }
110
+ try {
111
+ var res = await fetch('/api/daemons', {
112
+ method: 'POST',
113
+ headers: { 'Content-Type': 'application/json' },
114
+ body: JSON.stringify(body)
115
+ });
116
+ var data = await res.json();
117
+ if (data.ok) {
118
+ setResult('✓ daemon 启动成功' + (data.output ? ':\n' + data.output : ''), 'ok');
119
+ if (portInput) portInput.value = '';
120
+ await loadDaemons();
121
+ } else {
122
+ setResult('✗ ' + (data.error || '未知错误'), 'err');
123
+ }
124
+ } catch (e) {
125
+ setResult('✗ ' + (e.message || String(e)), 'err');
126
+ } finally {
127
+ if (addBtn) addBtn.disabled = false;
128
+ }
129
+ }
130
+
131
+ async function stopDaemon(id) {
132
+ if (!confirm('确认停止 daemon #' + id + '?\n只会标记为 drained(不再接新任务),需手动 kill 进程。')) return;
133
+ setResult('正在标记 daemon #' + id + ' 为 drained…', 'loading');
134
+ try {
135
+ var res = await fetch('/api/daemons/' + id, { method: 'DELETE' });
136
+ var data = await res.json();
137
+ if (data.ok) {
138
+ setResult('✓ daemon #' + id + ' 已标记为 drained', 'ok');
139
+ await loadDaemons();
140
+ } else {
141
+ setResult('✗ ' + (data.error || '未知错误'), 'err');
142
+ }
143
+ } catch (e) {
144
+ setResult('✗ ' + (e.message || String(e)), 'err');
145
+ }
146
+ }
147
+
148
+ function setDeployResult(text, kind) {
149
+ if (!deployResultEl) return;
150
+ deployResultEl.className = 'db-result db-' + (kind === 'ok' ? 'ok' : kind === 'err' ? 'err' : 'loading');
151
+ deployResultEl.textContent = text;
152
+ }
153
+
154
+ function val(id) {
155
+ var el = document.getElementById(id);
156
+ return el && el.value != null ? el.value.trim() : '';
157
+ }
158
+
159
+ async function deployRemote() {
160
+ if (deployBtn) deployBtn.disabled = true;
161
+ setDeployResult('正在通过 SSH 部署(最长 60s)…', 'loading');
162
+ var body = {
163
+ sshHost: val('deploy-host'),
164
+ sshUser: val('deploy-user'),
165
+ mysqlHost: val('deploy-mysql-host')
166
+ };
167
+ var sshPort = Number(val('deploy-ssh-port'));
168
+ if (Number.isFinite(sshPort) && sshPort > 0) body.sshPort = sshPort;
169
+ var dPort = Number(val('deploy-daemon-port'));
170
+ if (Number.isFinite(dPort) && dPort > 0) body.daemonPort = dPort;
171
+ var keyFile = val('deploy-key');
172
+ if (keyFile) body.sshKeyFile = keyFile;
173
+ if (!body.sshHost || !body.sshUser || !body.mysqlHost) {
174
+ setDeployResult('✗ SSH 主机、SSH 用户、MySQL 主机 必填', 'err');
175
+ if (deployBtn) deployBtn.disabled = false;
176
+ return;
177
+ }
178
+ try {
179
+ var res = await fetch('/api/daemons/deploy', {
180
+ method: 'POST',
181
+ headers: { 'Content-Type': 'application/json' },
182
+ body: JSON.stringify(body)
183
+ });
184
+ var data = await res.json();
185
+ if (data.ok) {
186
+ setDeployResult('✓ 部署成功' + (data.output ? '\n' + data.output : ''), 'ok');
187
+ await loadDaemons();
188
+ } else {
189
+ setDeployResult('✗ ' + (data.error || '未知错误') + (data.output ? '\n' + data.output : ''), 'err');
190
+ }
191
+ } catch (e) {
192
+ setDeployResult('✗ ' + (e.message || String(e)), 'err');
193
+ } finally {
194
+ if (deployBtn) deployBtn.disabled = false;
195
+ }
196
+ }
197
+
198
+ if (addBtn) addBtn.addEventListener('click', addDaemon);
199
+ if (deployBtn) deployBtn.addEventListener('click', deployRemote);
200
+ document.addEventListener('visibilitychange', function () {
201
+ if (document.hidden) {
202
+ if (refreshTimer) { clearInterval(refreshTimer); refreshTimer = null; }
203
+ } else if (!refreshTimer) {
204
+ loadDaemons();
205
+ refreshTimer = setInterval(loadDaemons, 10000);
206
+ }
207
+ });
208
+
209
+ loadDaemons();
210
+ refreshTimer = setInterval(loadDaemons, 10000);
211
+ })();
@@ -67,6 +67,34 @@ ${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
+ <details style="margin-top:.7rem">
82
+ <summary style="cursor:pointer;font-size:13px;color:var(--text-muted)">📡 SSH 远程部署</summary>
83
+ <div style="margin-top:.5rem">
84
+ <label class="sf"><span>SSH 主机</span><input type="text" id="deploy-host" placeholder="192.168.1.100" autocomplete="off"></label>
85
+ <label class="sf"><span>SSH 用户</span><input type="text" id="deploy-user" placeholder="root" value="root" autocomplete="off"></label>
86
+ <label class="sf"><span>SSH 端口</span><input type="number" id="deploy-ssh-port" value="22" min="1" max="65535" autocomplete="off"></label>
87
+ <label class="sf"><span>SSH 密钥</span><input type="text" id="deploy-key" placeholder="~/.ssh/id_rsa" autocomplete="off"></label>
88
+ <label class="sf"><span>Daemon 端口</span><input type="number" id="deploy-daemon-port" placeholder="3101" min="1" max="65535" autocomplete="off"></label>
89
+ <label class="sf"><span>MySQL 主机(远程可见)</span><input type="text" id="deploy-mysql-host" placeholder="192.168.1.1" autocomplete="off"></label>
90
+ <div class="db-controls"><button type="button" id="daemon-deploy">部署到远程</button></div>
91
+ </div>
92
+ </details>
93
+ <div id="deploy-result" class="db-result"></div>
94
+ <script src="/static/daemon-mgr.js"></script>
95
+ </section>`;
96
+ }
97
+
70
98
  export function buildSettingsPage(cfg: Config, saved: boolean, viewer: UserRow, models: CachedModel[]): { html: string } {
71
99
  const groups = SETTINGS_GROUPS.map(
72
100
  (g) =>
@@ -109,6 +137,23 @@ button.secondary{background:transparent;color:var(--text-muted);border:1px solid
109
137
  .db-result.db-loading{background:var(--bg);color:var(--text-muted)}
110
138
  .db-result.db-ok{background:rgba(40,167,69,.15);color:#5eb88a}
111
139
  .db-result.db-err{background:rgba(220,53,69,.15);color:#e87c7c}
140
+ .daemon-list{margin-bottom:.7rem}
141
+ .daemon-list table{width:100%;border-collapse:collapse;font-size:12px}
142
+ .daemon-list th,.daemon-list td{padding:.35rem .4rem;border-bottom:1px solid var(--border);text-align:left;vertical-align:top}
143
+ .daemon-list th{color:var(--text-muted);font-weight:normal;font-size:11px;text-transform:uppercase;letter-spacing:.03em}
144
+ .daemon-list td.endpoint{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11px;word-break:break-all}
145
+ .daemon-list td.cap{text-align:right;font-variant-numeric:tabular-nums}
146
+ .daemon-list td.heartbeat,.daemon-list td.registered{color:var(--text-muted);font-size:11px;white-space:nowrap}
147
+ .daemon-list .pill{display:inline-block;padding:.12rem .5rem;border-radius:99px;font-size:11px;line-height:1.4}
148
+ .daemon-list .pill.active{background:rgba(40,167,69,.18);color:#5eb88a}
149
+ .daemon-list .pill.drained{background:rgba(255,193,7,.18);color:#d4a942}
150
+ .daemon-list .pill.dead,.daemon-list .pill.unknown{background:rgba(220,53,69,.18);color:#e87c7c}
151
+ .daemon-list .pill.loc-local{background:rgba(40,167,69,.18);color:#5eb88a}
152
+ .daemon-list .pill.loc-remote{background:rgba(13,110,253,.18);color:#6ea8fe}
153
+ .daemon-list td.loc{white-space:nowrap}
154
+ .daemon-list button.stop{padding:.25rem .6rem;font-size:11px;background:transparent;color:#e87c7c;border:1px solid rgba(220,53,69,.4)}
155
+ .daemon-list button.stop:hover{background:rgba(220,53,69,.12)}
156
+ .daemon-empty{color:var(--text-muted);font-size:13px;padding:.5rem 0}
112
157
  </style></head><body>
113
158
  <header class="nav"><a href="/" style="color:var(--header-text)">🏠 ework-web</a><span style="opacity:.8"> · 设置</span></header>
114
159
  <main class="wrap">
@@ -116,11 +161,12 @@ button.secondary{background:transparent;color:var(--text-muted);border:1px solid
116
161
  <p class="hint">改完保存立即生效,无需重启。密钥/启动项(token、端口等)仍在 <code>.env</code>,不在此处。</p>
117
162
  ${banner}
118
163
  <form method="POST" action="/settings">${groups}
164
+ ${modelRefreshForm}
119
165
  <div class="bar"><button type="submit">保存</button><a class="a-back" href="/">返回</a></div>
120
166
  </form>
121
- ${modelRefreshForm}
122
167
  ${ttsLink}
123
168
  ${buildDbSection(viewer)}
169
+ ${buildDaemonSection(viewer)}
124
170
  </main></body></html>`;
125
171
  return { html };
126
172
  }