ework-web 0.9.1 → 0.10.1

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.9.1",
3
+ "version": "0.10.1",
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",
@@ -2,7 +2,7 @@ import { join } from "path";
2
2
  import { existsSync, readFileSync } from "fs";
3
3
  import { homedir } from "os";
4
4
 
5
- export interface DeployOpts {
5
+ export interface DeployTarget {
6
6
  sshHost: string;
7
7
  sshUser: string;
8
8
  sshPort: number;
@@ -11,14 +11,22 @@ export interface DeployOpts {
11
11
  mysqlHost: string;
12
12
  }
13
13
 
14
+ export interface DeployOpts extends DeployTarget {
15
+ timeoutMs: number;
16
+ onOutput?: (chunk: string) => void;
17
+ }
18
+
14
19
  export interface DeployResult {
15
20
  ok: boolean;
16
21
  output: string;
17
22
  error?: string;
18
23
  }
19
24
 
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.
25
+ export interface BatchTarget {
26
+ host: string;
27
+ daemonPort?: number;
28
+ }
29
+
22
30
  const FORWARD_KEYS = [
23
31
  "WORK_DB_DRIVER",
24
32
  "WORK_DB_USER",
@@ -63,21 +71,21 @@ function parseEnvFile(content: string): Map<string, string> {
63
71
  return out;
64
72
  }
65
73
 
66
- function buildEnvBlock(env: Map<string, string>, opts: DeployOpts): string {
74
+ function buildEnvBlock(env: Map<string, string>, target: DeployTarget): string {
67
75
  const lines: string[] = [];
68
76
  for (const k of FORWARD_KEYS) {
69
77
  const v = env.get(k);
70
78
  if (v === undefined) continue;
71
79
  lines.push(`${k}=${v}`);
72
80
  }
73
- lines.push(`WORK_DB_HOST=${opts.mysqlHost}`);
74
- lines.push(`DAEMON_PORT=${String(opts.daemonPort ?? 3101)}`);
81
+ lines.push(`WORK_DB_HOST=${target.mysqlHost}`);
82
+ lines.push(`DAEMON_PORT=${String(target.daemonPort ?? 3101)}`);
75
83
  lines.push(`DAEMON_HOST=0.0.0.0`);
76
84
  lines.push(`DAEMON_ENV=production`);
77
85
  return lines.join("\n");
78
86
  }
79
87
 
80
- function buildSetupScript(envBlock: string): string {
88
+ function buildSetupScript(envBlock: string, daemonPort: number): string {
81
89
  return [
82
90
  "set -e",
83
91
  `command -v npm >/dev/null 2>&1 || { curl -fsSL https://deb.nodesource.com/setup_22.x | sudo bash -; sudo apt-get install -y nodejs; }`,
@@ -87,12 +95,18 @@ function buildSetupScript(envBlock: string): string {
87
95
  `mkdir -p "$HOME/.local/lib"`,
88
96
  `npm config set prefix "$HOME/.local/lib"`,
89
97
  `grep -q '.local/lib/bin' "$HOME/.bashrc" 2>/dev/null || echo 'export PATH="$HOME/.local/lib/bin:$HOME/.bun/bin:$PATH"' >> "$HOME/.bashrc"`,
98
+ `echo "[1/4] installing ework-aio..."`,
90
99
  `npm install -g ework-aio`,
100
+ `echo "[2/4] writing daemon config..."`,
91
101
  `mkdir -p ~/.local/share/ework-aio/ework-daemon`,
92
102
  `cat > ~/.local/share/ework-aio/ework-daemon/.env <<'EWORK_DAEMON_ENV_EOF'`,
93
103
  envBlock,
94
104
  `EWORK_DAEMON_ENV_EOF`,
105
+ `echo "[3/4] starting daemon..."`,
95
106
  `ework-aio start daemon`,
107
+ `echo "[4/4] verifying daemon is alive..."`,
108
+ `sleep 2`,
109
+ `curl -sf --max-time 3 http://127.0.0.1:${String(daemonPort)}/api/status >/dev/null 2>&1 && echo "DAEMON_ALIVE" || echo "DAEMON_WARNING: status check failed (may need a moment to boot)"`,
96
110
  `echo DAEMON_STARTED`,
97
111
  ].join("\n");
98
112
  }
@@ -103,7 +117,7 @@ export async function deployRemoteDaemon(opts: DeployOpts): Promise<DeployResult
103
117
  return {
104
118
  ok: false,
105
119
  output: "",
106
- error: "找不到本地 daemon .env(设置 WORK_DAEMON_DATA_DIR 或将 .env 放到 ~/.local/share/ework-aio/ework-daemon/.env)",
120
+ error: "找不到本地 daemon .env",
107
121
  };
108
122
  }
109
123
  let envContent: string;
@@ -114,7 +128,7 @@ export async function deployRemoteDaemon(opts: DeployOpts): Promise<DeployResult
114
128
  }
115
129
  const env = parseEnvFile(envContent);
116
130
  const envBlock = buildEnvBlock(env, opts);
117
- const script = buildSetupScript(envBlock);
131
+ const script = buildSetupScript(envBlock, opts.daemonPort ?? 3101);
118
132
 
119
133
  const args = [
120
134
  "ssh",
@@ -140,23 +154,37 @@ export async function deployRemoteDaemon(opts: DeployOpts): Promise<DeployResult
140
154
  const timer = setTimeout(() => {
141
155
  ac.abort();
142
156
  try { proc.kill(); } catch { /* already dead */ }
143
- }, 60_000);
157
+ }, opts.timeoutMs);
144
158
 
145
- let stdoutText = "";
146
- let stderrText = "";
159
+ let output = "";
147
160
  let exitCode: number | null = null;
148
161
  let timedOut = false;
162
+
163
+ const decoder = new TextDecoder();
164
+ const readStream = async (stream: ReadableStream<Uint8Array>) => {
165
+ const reader = stream.getReader();
166
+ try {
167
+ while (true) {
168
+ const { done, value } = await reader.read();
169
+ if (done) break;
170
+ const chunk = decoder.decode(value, { stream: true });
171
+ output += chunk;
172
+ opts.onOutput?.(chunk);
173
+ }
174
+ } catch { /* stream closed */ }
175
+ };
176
+
149
177
  try {
150
178
  ac.signal.addEventListener("abort", () => { timedOut = true; });
151
- [stdoutText, stderrText, exitCode] = await Promise.all([
152
- new Response(proc.stdout).text(),
153
- new Response(proc.stderr).text(),
154
- proc.exited,
179
+ await Promise.all([
180
+ readStream(proc.stdout as ReadableStream<Uint8Array>),
181
+ readStream(proc.stderr as ReadableStream<Uint8Array>),
155
182
  ]);
183
+ exitCode = await proc.exited;
156
184
  } catch (e) {
157
185
  return {
158
186
  ok: false,
159
- output: stdoutText + stderrText,
187
+ output,
160
188
  error: e instanceof Error ? e.message : String(e),
161
189
  };
162
190
  } finally {
@@ -166,14 +194,35 @@ export async function deployRemoteDaemon(opts: DeployOpts): Promise<DeployResult
166
194
  if (timedOut) {
167
195
  return {
168
196
  ok: false,
169
- output: stdoutText + stderrText,
170
- error: "SSH 部署超时(60s)",
197
+ output,
198
+ error: `SSH 部署超时(${String(Math.trunc(opts.timeoutMs / 1000))}s)`,
171
199
  };
172
200
  }
173
- const combined = stdoutText + (stderrText ? "\n--- stderr ---\n" + stderrText : "");
174
201
  return {
175
202
  ok: exitCode === 0,
176
- output: combined,
203
+ output,
177
204
  error: exitCode === 0 ? undefined : `ssh 退出码 ${String(exitCode)}`,
178
205
  };
179
206
  }
207
+
208
+ export async function deployBatch(
209
+ targets: DeployTarget[],
210
+ timeoutMs: number,
211
+ onOutput: (target: string, chunk: string) => void,
212
+ ): Promise<Map<string, DeployResult>> {
213
+ const results = new Map<string, DeployResult>();
214
+ await Promise.all(
215
+ targets.map(async (t) => {
216
+ const label = `${t.sshUser}@${t.sshHost}:${String(t.sshPort)}`;
217
+ onOutput(label, `▶ 开始部署到 ${label}\n`);
218
+ const r = await deployRemoteDaemon({
219
+ ...t,
220
+ timeoutMs,
221
+ onOutput: (chunk) => onOutput(label, chunk),
222
+ });
223
+ results.set(label, r);
224
+ onOutput(label, r.ok ? `✓ ${label} 部署成功\n` : `✗ ${label} ${r.error ?? "失败"}\n`);
225
+ }),
226
+ );
227
+ return results;
228
+ }
package/src/index.ts CHANGED
@@ -93,7 +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
+ import { deployRemoteDaemon, deployBatch, type DeployTarget } from "./daemon-deploy";
97
97
 
98
98
  const __dirname = dirname(fileURLToPath(import.meta.url));
99
99
  const STATIC_DIR = join(__dirname, "static");
@@ -887,6 +887,35 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
887
887
  }
888
888
 
889
889
  const daemonIdRe = /^\/api\/daemons\/(\d+)$/;
890
+ const daemonRestartRe = /^\/api\/daemons\/(\d+)\/restart$/;
891
+ const daemonRemoveRe = /^\/api\/daemons\/(\d+)\/remove$/;
892
+
893
+ if (req.method === "POST" && daemonRestartRe.test(url.pathname)) {
894
+ if (!ctx.user || ctx.user.is_admin !== 1) return json({ error: "admin required" }, 403);
895
+ const match = url.pathname.match(daemonRestartRe);
896
+ const id = match ? Number(match[1]) : NaN;
897
+ if (!Number.isFinite(id)) return json({ error: "invalid id" }, 400);
898
+ const rows = await getDB().all<{ endpoint: string; status: string }>(
899
+ `SELECT internal_endpoint AS endpoint, status FROM {{d_daemons}} WHERE id = ?`, [id]
900
+ );
901
+ if (rows.length === 0) return json({ ok: false, error: "daemon not found" }, 404);
902
+ await getDB().run(`UPDATE {{d_daemons}} SET status = 'active', last_heartbeat = datetime('now') WHERE id = ?`, [id]);
903
+ return json({ ok: true, note: "reactivated — daemon process will re-register via heartbeat" });
904
+ }
905
+
906
+ if (req.method === "DELETE" && daemonRemoveRe.test(url.pathname)) {
907
+ if (!ctx.user || ctx.user.is_admin !== 1) return json({ error: "admin required" }, 403);
908
+ const match = url.pathname.match(daemonRemoveRe);
909
+ const id = match ? Number(match[1]) : NaN;
910
+ if (!Number.isFinite(id)) return json({ error: "invalid id" }, 400);
911
+ const rows = await getDB().all<{ status: string }>(`SELECT status FROM {{d_daemons}} WHERE id = ?`, [id]);
912
+ if (rows.length === 0) return json({ ok: false, error: "daemon not found" }, 404);
913
+ const st = String(rows[0]?.status ?? "").toLowerCase();
914
+ if (st === "active") return json({ ok: false, error: "stop (drain) the daemon first before removing" }, 400);
915
+ await getDB().run(`DELETE FROM {{d_daemons}} WHERE id = ?`, [id]);
916
+ return json({ ok: true });
917
+ }
918
+
890
919
  if (req.method === "DELETE" && daemonIdRe.test(url.pathname)) {
891
920
  if (!ctx.user || ctx.user.is_admin !== 1) return json({ error: "admin required" }, 403);
892
921
  const match = url.pathname.match(daemonIdRe);
@@ -902,25 +931,68 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
902
931
 
903
932
  if (req.method === "POST" && url.pathname === "/api/daemons/deploy") {
904
933
  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);
934
+ if (!rateLimit(`daemons-deploy:${ip}`, 10, 10 / 3600)) return json({ error: "rate limited" }, 429);
906
935
  const payload = await req.json().catch(() => ({} as unknown));
907
936
  if (!payload || typeof payload !== "object") return json({ ok: false, error: "invalid body" }, 400);
908
937
  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() : "";
938
+ const sshUser = typeof obj.sshUser === "string" ? obj.sshUser.trim() : "root";
911
939
  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
940
  const sshPort = typeof obj.sshPort === "number" && Number.isFinite(obj.sshPort) && obj.sshPort > 0 && obj.sshPort < 65536
916
941
  ? Math.trunc(obj.sshPort)
917
942
  : 22;
918
943
  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);
944
+ const timeoutMs = typeof obj.timeoutMs === "number" && Number.isFinite(obj.timeoutMs) && obj.timeoutMs >= 30_000 && obj.timeoutMs <= 600_000
945
+ ? Math.trunc(obj.timeoutMs)
946
+ : 180_000;
947
+
948
+ const targets: DeployTarget[] = [];
949
+ if (Array.isArray(obj.targets)) {
950
+ for (const t of obj.targets) {
951
+ if (!t || typeof t !== "object") continue;
952
+ const r = t as Record<string, unknown>;
953
+ const host = typeof r.host === "string" ? r.host.trim() : "";
954
+ if (!host) continue;
955
+ targets.push({
956
+ sshHost: host, sshUser, sshPort, sshKeyFile, mysqlHost,
957
+ daemonPort: typeof r.daemonPort === "number" ? Math.trunc(r.daemonPort) : undefined,
958
+ });
959
+ }
960
+ } else {
961
+ const sshHost = typeof obj.sshHost === "string" ? obj.sshHost.trim() : "";
962
+ if (sshHost) {
963
+ targets.push({
964
+ sshHost, sshUser, sshPort, sshKeyFile, mysqlHost,
965
+ daemonPort: typeof obj.daemonPort === "number" ? Math.trunc(obj.daemonPort) : undefined,
966
+ });
967
+ }
968
+ }
969
+
970
+ if (targets.length === 0 || !mysqlHost) {
971
+ return json({ ok: false, error: "至少需要一个目标 + mysqlHost" }, 400);
972
+ }
973
+ if (targets.length === 1) {
974
+ const [single] = targets;
975
+ if (!single) return json({ ok: false, error: "no target" }, 400);
976
+ const result = await deployRemoteDaemon({ ...single, timeoutMs });
977
+ return json(result, result.ok ? 200 : 422);
978
+ }
979
+
980
+ const { readable, writable } = new TransformStream();
981
+ const writer = writable.getWriter();
982
+ const enc = new TextEncoder();
983
+ const send = (s: string) => writer.write(enc.encode(`data: ${JSON.stringify(s)}\n\n`));
984
+
985
+ deployBatch(targets, timeoutMs, (label, chunk) => send(`[${label}] ${chunk}`))
986
+ .then((results) => {
987
+ const ok = [...results.values()].every((r) => r.ok);
988
+ send(`\n${ok ? "ALL OK" : "SOME FAILED"}: ${[...results.entries()].map(([k, v]) => `${k}=${v.ok ? "✓" : "✗"}`).join(", ")}`);
989
+ })
990
+ .catch((e) => send(`ERROR: ${e instanceof Error ? e.message : String(e)}`))
991
+ .finally(() => writer.close());
992
+
993
+ return new Response(readable, {
994
+ headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive" },
995
+ });
924
996
  }
925
997
 
926
998
  if (url.pathname === "/settings") {
@@ -52,9 +52,14 @@
52
52
  return;
53
53
  }
54
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
- : '';
55
+ var stRaw = String(d.status || 'unknown').toLowerCase();
56
+ var actions = '';
57
+ if (stRaw === 'active') {
58
+ actions = '<button type="button" class="stop" data-id="' + d.id + '">停止</button>';
59
+ } else {
60
+ actions = '<button type="button" class="restart" data-id="' + d.id + '">重启</button>' +
61
+ '<button type="button" class="remove" data-id="' + d.id + '">删除</button>';
62
+ }
58
63
  return '<tr>' +
59
64
  '<td class="cap">' + d.id + '</td>' +
60
65
  '<td class="loc">' + locationCell(d.endpoint) + '</td>' +
@@ -63,16 +68,21 @@
63
68
  '<td class="heartbeat">' + fmtTime(d.lastHeartbeat) + '</td>' +
64
69
  '<td class="registered">' + fmtTime(d.registeredAt) + '</td>' +
65
70
  '<td>' + statusPill(d.status) + '</td>' +
66
- '<td>' + stopBtn + '</td>' +
71
+ '<td>' + actions + '</td>' +
67
72
  '</tr>';
68
73
  }).join('');
69
74
  listEl.innerHTML =
70
75
  '<table><thead><tr>' +
71
- '<th>ID</th><th>位置</th><th>Endpoint</th><th>容量</th><th>心跳</th><th>注册于</th><th>状态</th><th></th>' +
76
+ '<th>ID</th><th>位置</th><th>Endpoint</th><th>容量</th><th>心跳</th><th>注册于</th><th>状态</th><th>操作</th>' +
72
77
  '</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
- });
78
+ var bindBtn = function (selector, fn) {
79
+ listEl.querySelectorAll(selector).forEach(function (b) {
80
+ b.addEventListener('click', function () { fn(Number(b.getAttribute('data-id'))); });
81
+ });
82
+ };
83
+ bindBtn('button.stop', stopDaemon);
84
+ bindBtn('button.restart', restartDaemon);
85
+ bindBtn('button.remove', removeDaemon);
76
86
  }
77
87
 
78
88
  function setResult(text, kind) {
@@ -145,6 +155,39 @@
145
155
  }
146
156
  }
147
157
 
158
+ async function restartDaemon(id) {
159
+ setResult('正在重启 daemon #' + id + '…', 'loading');
160
+ try {
161
+ var res = await fetch('/api/daemons/' + id + '/restart', { method: 'POST' });
162
+ var data = await res.json();
163
+ if (data.ok) {
164
+ setResult('✓ daemon #' + id + ' 已重新激活', 'ok');
165
+ await loadDaemons();
166
+ } else {
167
+ setResult('✗ ' + (data.error || '未知错误'), 'err');
168
+ }
169
+ } catch (e) {
170
+ setResult('✗ ' + (e.message || String(e)), 'err');
171
+ }
172
+ }
173
+
174
+ async function removeDaemon(id) {
175
+ if (!confirm('确认删除 daemon #' + id + '?\n这会从数据库永久移除该记录。')) return;
176
+ setResult('正在删除 daemon #' + id + '…', 'loading');
177
+ try {
178
+ var res = await fetch('/api/daemons/' + id + '/remove', { method: 'DELETE' });
179
+ var data = await res.json();
180
+ if (data.ok) {
181
+ setResult('✓ daemon #' + id + ' 已删除', 'ok');
182
+ await loadDaemons();
183
+ } else {
184
+ setResult('✗ ' + (data.error || '未知错误'), 'err');
185
+ }
186
+ } catch (e) {
187
+ setResult('✗ ' + (e.message || String(e)), 'err');
188
+ }
189
+ }
190
+
148
191
  function setDeployResult(text, kind) {
149
192
  if (!deployResultEl) return;
150
193
  deployResultEl.className = 'db-result db-' + (kind === 'ok' ? 'ok' : kind === 'err' ? 'err' : 'loading');
@@ -156,40 +199,111 @@
156
199
  return el && el.value != null ? el.value.trim() : '';
157
200
  }
158
201
 
202
+ function appendDeployLog(text) {
203
+ if (!deployResultEl) return;
204
+ deployResultEl.className = 'db-result db-loading';
205
+ deployResultEl.textContent += text;
206
+ deployResultEl.scrollTop = deployResultEl.scrollHeight;
207
+ }
208
+
209
+ function parseTargets(raw) {
210
+ var targets = [];
211
+ var lines = raw.split('\n');
212
+ for (var i = 0; i < lines.length; i++) {
213
+ var line = lines[i].trim();
214
+ if (!line || line.startsWith('#')) continue;
215
+ var parts = line.split(':');
216
+ var host = parts[0].trim();
217
+ var port = parts.length > 1 ? parseInt(parts[1].trim(), 10) : NaN;
218
+ if (host) {
219
+ var t = { host: host };
220
+ if (Number.isFinite(port) && port > 0) t.daemonPort = port;
221
+ targets.push(t);
222
+ }
223
+ }
224
+ return targets;
225
+ }
226
+
159
227
  async function deployRemote() {
160
228
  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;
229
+ var targets = parseTargets(val('deploy-targets'));
230
+ var mysqlHost = val('deploy-mysql-host');
231
+ var sshUser = val('deploy-user') || 'root';
232
+ var sshPort = Number(val('deploy-ssh-port')) || 22;
233
+ var timeoutMs = (Number(val('deploy-timeout')) || 180) * 1000;
171
234
  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');
235
+
236
+ if (targets.length === 0) {
237
+ setDeployResult('✗ 请填写至少一个目标机器', 'err');
175
238
  if (deployBtn) deployBtn.disabled = false;
176
239
  return;
177
240
  }
241
+ if (!mysqlHost) {
242
+ setDeployResult('✗ MySQL 主机必填', 'err');
243
+ if (deployBtn) deployBtn.disabled = false;
244
+ return;
245
+ }
246
+
247
+ deployResultEl.textContent = '';
248
+ appendDeployLog('▶ 部署 ' + targets.length + ' 个目标(超时 ' + (timeoutMs / 1000) + 's/个)...\n\n');
249
+
250
+ var body = {
251
+ targets: targets,
252
+ sshUser: sshUser,
253
+ sshPort: sshPort,
254
+ mysqlHost: mysqlHost,
255
+ timeoutMs: timeoutMs
256
+ };
257
+ if (keyFile) body.sshKeyFile = keyFile;
258
+
178
259
  try {
179
260
  var res = await fetch('/api/daemons/deploy', {
180
261
  method: 'POST',
181
262
  headers: { 'Content-Type': 'application/json' },
182
263
  body: JSON.stringify(body)
183
264
  });
184
- var data = await res.json();
185
- if (data.ok) {
186
- setDeployResult('✓ 部署成功' + (data.output ? '\n' + data.output : ''), 'ok');
187
- await loadDaemons();
265
+
266
+ if (res.headers.get('Content-Type') && res.headers.get('Content-Type').includes('text/event-stream')) {
267
+ var reader = res.body.getReader();
268
+ var decoder = new TextDecoder();
269
+ var allOk = true;
270
+ while (true) {
271
+ var chunk = await reader.read();
272
+ if (chunk.done) break;
273
+ var text = decoder.decode(chunk.value, { stream: true });
274
+ var lines = text.split('\n');
275
+ for (var i = 0; i < lines.length; i++) {
276
+ var line = lines[i].trim();
277
+ if (!line.startsWith('data: ')) continue;
278
+ try {
279
+ var msg = JSON.parse(line.slice(6));
280
+ appendDeployLog(msg);
281
+ if (msg.indexOf('✗') >= 0) allOk = false;
282
+ } catch (e) { /* skip non-JSON */ }
283
+ }
284
+ }
285
+ if (allOk) {
286
+ deployResultEl.className = 'db-result db-ok';
287
+ await loadDaemons();
288
+ } else {
289
+ deployResultEl.className = 'db-result db-err';
290
+ }
188
291
  } else {
189
- setDeployResult('✗ ' + (data.error || '未知错误') + (data.output ? '\n' + data.output : ''), 'err');
292
+ var data = await res.json();
293
+ if (data.ok) {
294
+ appendDeployLog('✓ 部署成功\n');
295
+ if (data.output) appendDeployLog(data.output);
296
+ deployResultEl.className = 'db-result db-ok';
297
+ await loadDaemons();
298
+ } else {
299
+ appendDeployLog('✗ ' + (data.error || '未知错误') + '\n');
300
+ if (data.output) appendDeployLog(data.output);
301
+ deployResultEl.className = 'db-result db-err';
302
+ }
190
303
  }
191
304
  } catch (e) {
192
- setDeployResult('✗ ' + (e.message || String(e)), 'err');
305
+ appendDeployLog('✗ ' + (e.message || String(e)) + '\n');
306
+ deployResultEl.className = 'db-result db-err';
193
307
  } finally {
194
308
  if (deployBtn) deployBtn.disabled = false;
195
309
  }
@@ -81,16 +81,16 @@ function buildDaemonSection(viewer: UserRow): string {
81
81
  <details style="margin-top:.7rem">
82
82
  <summary style="cursor:pointer;font-size:13px;color:var(--text-muted)">📡 SSH 远程部署</summary>
83
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
84
  <label class="sf"><span>SSH 用户</span><input type="text" id="deploy-user" placeholder="root" value="root" autocomplete="off"></label>
86
85
  <label class="sf"><span>SSH 端口</span><input type="number" id="deploy-ssh-port" value="22" min="1" max="65535" autocomplete="off"></label>
87
86
  <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
87
  <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>
88
+ <label class="sf"><span>超时(秒)</span><input type="number" id="deploy-timeout" value="180" min="30" max="600" autocomplete="off"></label>
89
+ <label class="sf" style="align-items:flex-start"><span>目标机器</span><textarea id="deploy-targets" rows="4" style="flex:1;padding:.35rem .5rem;border:1px solid var(--border);border-radius:6px;background:var(--bg);color:var(--text);font-size:12px;font-family:ui-monospace,monospace" placeholder="每行一个,格式:&#10;192.168.1.100&#10;192.168.1.101:3201&#10;10.0.0.5:3202&#10;(不带端口则用默认 3101)"></textarea></label>
90
+ <div class="db-controls"><button type="button" id="daemon-deploy">部署(支持批量)</button></div>
91
91
  </div>
92
92
  </details>
93
- <div id="deploy-result" class="db-result"></div>
93
+ <div id="deploy-result" class="db-result" style="max-height:400px;overflow-y:auto;font-family:ui-monospace,monospace;font-size:11px"></div>
94
94
  <script src="/static/daemon-mgr.js"></script>
95
95
  </section>`;
96
96
  }
@@ -153,6 +153,9 @@ button.secondary{background:transparent;color:var(--text-muted);border:1px solid
153
153
  .daemon-list td.loc{white-space:nowrap}
154
154
  .daemon-list button.stop{padding:.25rem .6rem;font-size:11px;background:transparent;color:#e87c7c;border:1px solid rgba(220,53,69,.4)}
155
155
  .daemon-list button.stop:hover{background:rgba(220,53,69,.12)}
156
+ .daemon-list button.restart{padding:.25rem .6rem;font-size:11px;background:transparent;color:#5eb88a;border:1px solid rgba(40,167,69,.4);margin-right:.3rem}
157
+ .daemon-list button.restart:hover{background:rgba(40,167,69,.12)}
158
+ .daemon-list button.remove{padding:.25rem .6rem;font-size:11px;background:transparent;color:var(--text-muted);border:1px solid var(--border)}
156
159
  .daemon-empty{color:var(--text-muted);font-size:13px;padding:.5rem 0}
157
160
  </style></head><body>
158
161
  <header class="nav"><a href="/" style="color:var(--header-text)">🏠 ework-web</a><span style="opacity:.8"> · 设置</span></header>