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 +1 -1
- package/src/daemon-deploy.ts +70 -21
- package/src/index.ts +84 -12
- package/src/static/daemon-mgr.js +141 -27
- package/src/views/settings.ts +7 -4
package/package.json
CHANGED
package/src/daemon-deploy.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
21
|
-
|
|
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>,
|
|
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=${
|
|
74
|
-
lines.push(`DAEMON_PORT=${String(
|
|
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
|
|
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
|
-
},
|
|
157
|
+
}, opts.timeoutMs);
|
|
144
158
|
|
|
145
|
-
let
|
|
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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
|
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
|
|
170
|
-
error:
|
|
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
|
|
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}`,
|
|
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
|
|
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
|
|
920
|
-
? Math.trunc(obj.
|
|
921
|
-
:
|
|
922
|
-
|
|
923
|
-
|
|
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") {
|
package/src/static/daemon-mgr.js
CHANGED
|
@@ -52,9 +52,14 @@
|
|
|
52
52
|
return;
|
|
53
53
|
}
|
|
54
54
|
var rows = daemons.map(function (d) {
|
|
55
|
-
var
|
|
56
|
-
|
|
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>' +
|
|
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
|
|
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
|
-
|
|
74
|
-
|
|
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
|
-
|
|
162
|
-
var
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
|
|
173
|
-
if (
|
|
174
|
-
setDeployResult('✗
|
|
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
|
-
|
|
185
|
-
if (
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/views/settings.ts
CHANGED
|
@@ -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
|
-
<
|
|
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="每行一个,格式: 192.168.1.100 192.168.1.101:3201 10.0.0.5:3202 (不带端口则用默认 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>
|