ework-web 0.9.1 → 0.10.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 +1 -1
- package/src/daemon-deploy.ts +66 -19
- package/src/index.ts +55 -12
- package/src/static/daemon-mgr.js +90 -19
- package/src/views/settings.ts +4 -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,15 +71,15 @@ 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");
|
|
@@ -87,12 +95,16 @@ 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] done"`,
|
|
96
108
|
`echo DAEMON_STARTED`,
|
|
97
109
|
].join("\n");
|
|
98
110
|
}
|
|
@@ -103,7 +115,7 @@ export async function deployRemoteDaemon(opts: DeployOpts): Promise<DeployResult
|
|
|
103
115
|
return {
|
|
104
116
|
ok: false,
|
|
105
117
|
output: "",
|
|
106
|
-
error: "找不到本地 daemon .env
|
|
118
|
+
error: "找不到本地 daemon .env",
|
|
107
119
|
};
|
|
108
120
|
}
|
|
109
121
|
let envContent: string;
|
|
@@ -140,23 +152,37 @@ export async function deployRemoteDaemon(opts: DeployOpts): Promise<DeployResult
|
|
|
140
152
|
const timer = setTimeout(() => {
|
|
141
153
|
ac.abort();
|
|
142
154
|
try { proc.kill(); } catch { /* already dead */ }
|
|
143
|
-
},
|
|
155
|
+
}, opts.timeoutMs);
|
|
144
156
|
|
|
145
|
-
let
|
|
146
|
-
let stderrText = "";
|
|
157
|
+
let output = "";
|
|
147
158
|
let exitCode: number | null = null;
|
|
148
159
|
let timedOut = false;
|
|
160
|
+
|
|
161
|
+
const decoder = new TextDecoder();
|
|
162
|
+
const readStream = async (stream: ReadableStream<Uint8Array>) => {
|
|
163
|
+
const reader = stream.getReader();
|
|
164
|
+
try {
|
|
165
|
+
while (true) {
|
|
166
|
+
const { done, value } = await reader.read();
|
|
167
|
+
if (done) break;
|
|
168
|
+
const chunk = decoder.decode(value, { stream: true });
|
|
169
|
+
output += chunk;
|
|
170
|
+
opts.onOutput?.(chunk);
|
|
171
|
+
}
|
|
172
|
+
} catch { /* stream closed */ }
|
|
173
|
+
};
|
|
174
|
+
|
|
149
175
|
try {
|
|
150
176
|
ac.signal.addEventListener("abort", () => { timedOut = true; });
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
proc.exited,
|
|
177
|
+
await Promise.all([
|
|
178
|
+
readStream(proc.stdout as ReadableStream<Uint8Array>),
|
|
179
|
+
readStream(proc.stderr as ReadableStream<Uint8Array>),
|
|
155
180
|
]);
|
|
181
|
+
exitCode = await proc.exited;
|
|
156
182
|
} catch (e) {
|
|
157
183
|
return {
|
|
158
184
|
ok: false,
|
|
159
|
-
output
|
|
185
|
+
output,
|
|
160
186
|
error: e instanceof Error ? e.message : String(e),
|
|
161
187
|
};
|
|
162
188
|
} finally {
|
|
@@ -166,14 +192,35 @@ export async function deployRemoteDaemon(opts: DeployOpts): Promise<DeployResult
|
|
|
166
192
|
if (timedOut) {
|
|
167
193
|
return {
|
|
168
194
|
ok: false,
|
|
169
|
-
output
|
|
170
|
-
error:
|
|
195
|
+
output,
|
|
196
|
+
error: `SSH 部署超时(${String(Math.trunc(opts.timeoutMs / 1000))}s)`,
|
|
171
197
|
};
|
|
172
198
|
}
|
|
173
|
-
const combined = stdoutText + (stderrText ? "\n--- stderr ---\n" + stderrText : "");
|
|
174
199
|
return {
|
|
175
200
|
ok: exitCode === 0,
|
|
176
|
-
output
|
|
201
|
+
output,
|
|
177
202
|
error: exitCode === 0 ? undefined : `ssh 退出码 ${String(exitCode)}`,
|
|
178
203
|
};
|
|
179
204
|
}
|
|
205
|
+
|
|
206
|
+
export async function deployBatch(
|
|
207
|
+
targets: DeployTarget[],
|
|
208
|
+
timeoutMs: number,
|
|
209
|
+
onOutput: (target: string, chunk: string) => void,
|
|
210
|
+
): Promise<Map<string, DeployResult>> {
|
|
211
|
+
const results = new Map<string, DeployResult>();
|
|
212
|
+
await Promise.all(
|
|
213
|
+
targets.map(async (t) => {
|
|
214
|
+
const label = `${t.sshUser}@${t.sshHost}:${String(t.sshPort)}`;
|
|
215
|
+
onOutput(label, `▶ 开始部署到 ${label}\n`);
|
|
216
|
+
const r = await deployRemoteDaemon({
|
|
217
|
+
...t,
|
|
218
|
+
timeoutMs,
|
|
219
|
+
onOutput: (chunk) => onOutput(label, chunk),
|
|
220
|
+
});
|
|
221
|
+
results.set(label, r);
|
|
222
|
+
onOutput(label, r.ok ? `✓ ${label} 部署成功\n` : `✗ ${label} ${r.error ?? "失败"}\n`);
|
|
223
|
+
}),
|
|
224
|
+
);
|
|
225
|
+
return results;
|
|
226
|
+
}
|
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");
|
|
@@ -902,25 +902,68 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
|
|
|
902
902
|
|
|
903
903
|
if (req.method === "POST" && url.pathname === "/api/daemons/deploy") {
|
|
904
904
|
if (!ctx.user || ctx.user.is_admin !== 1) return json({ error: "admin required" }, 403);
|
|
905
|
-
if (!rateLimit(`daemons-deploy:${ip}`,
|
|
905
|
+
if (!rateLimit(`daemons-deploy:${ip}`, 10, 10 / 3600)) return json({ error: "rate limited" }, 429);
|
|
906
906
|
const payload = await req.json().catch(() => ({} as unknown));
|
|
907
907
|
if (!payload || typeof payload !== "object") return json({ ok: false, error: "invalid body" }, 400);
|
|
908
908
|
const obj = payload as Record<string, unknown>;
|
|
909
|
-
const
|
|
910
|
-
const sshUser = typeof obj.sshUser === "string" ? obj.sshUser.trim() : "";
|
|
909
|
+
const sshUser = typeof obj.sshUser === "string" ? obj.sshUser.trim() : "root";
|
|
911
910
|
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
911
|
const sshPort = typeof obj.sshPort === "number" && Number.isFinite(obj.sshPort) && obj.sshPort > 0 && obj.sshPort < 65536
|
|
916
912
|
? Math.trunc(obj.sshPort)
|
|
917
913
|
: 22;
|
|
918
914
|
const sshKeyFile = typeof obj.sshKeyFile === "string" && obj.sshKeyFile.trim() ? obj.sshKeyFile.trim() : undefined;
|
|
919
|
-
const
|
|
920
|
-
? Math.trunc(obj.
|
|
921
|
-
:
|
|
922
|
-
|
|
923
|
-
|
|
915
|
+
const timeoutMs = typeof obj.timeoutMs === "number" && Number.isFinite(obj.timeoutMs) && obj.timeoutMs >= 30_000 && obj.timeoutMs <= 600_000
|
|
916
|
+
? Math.trunc(obj.timeoutMs)
|
|
917
|
+
: 180_000;
|
|
918
|
+
|
|
919
|
+
const targets: DeployTarget[] = [];
|
|
920
|
+
if (Array.isArray(obj.targets)) {
|
|
921
|
+
for (const t of obj.targets) {
|
|
922
|
+
if (!t || typeof t !== "object") continue;
|
|
923
|
+
const r = t as Record<string, unknown>;
|
|
924
|
+
const host = typeof r.host === "string" ? r.host.trim() : "";
|
|
925
|
+
if (!host) continue;
|
|
926
|
+
targets.push({
|
|
927
|
+
sshHost: host, sshUser, sshPort, sshKeyFile, mysqlHost,
|
|
928
|
+
daemonPort: typeof r.daemonPort === "number" ? Math.trunc(r.daemonPort) : undefined,
|
|
929
|
+
});
|
|
930
|
+
}
|
|
931
|
+
} else {
|
|
932
|
+
const sshHost = typeof obj.sshHost === "string" ? obj.sshHost.trim() : "";
|
|
933
|
+
if (sshHost) {
|
|
934
|
+
targets.push({
|
|
935
|
+
sshHost, sshUser, sshPort, sshKeyFile, mysqlHost,
|
|
936
|
+
daemonPort: typeof obj.daemonPort === "number" ? Math.trunc(obj.daemonPort) : undefined,
|
|
937
|
+
});
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
if (targets.length === 0 || !mysqlHost) {
|
|
942
|
+
return json({ ok: false, error: "至少需要一个目标 + mysqlHost" }, 400);
|
|
943
|
+
}
|
|
944
|
+
if (targets.length === 1) {
|
|
945
|
+
const [single] = targets;
|
|
946
|
+
if (!single) return json({ ok: false, error: "no target" }, 400);
|
|
947
|
+
const result = await deployRemoteDaemon({ ...single, timeoutMs });
|
|
948
|
+
return json(result, result.ok ? 200 : 422);
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
const { readable, writable } = new TransformStream();
|
|
952
|
+
const writer = writable.getWriter();
|
|
953
|
+
const enc = new TextEncoder();
|
|
954
|
+
const send = (s: string) => writer.write(enc.encode(`data: ${JSON.stringify(s)}\n\n`));
|
|
955
|
+
|
|
956
|
+
deployBatch(targets, timeoutMs, (label, chunk) => send(`[${label}] ${chunk}`))
|
|
957
|
+
.then((results) => {
|
|
958
|
+
const ok = [...results.values()].every((r) => r.ok);
|
|
959
|
+
send(`\n${ok ? "ALL OK" : "SOME FAILED"}: ${[...results.entries()].map(([k, v]) => `${k}=${v.ok ? "✓" : "✗"}`).join(", ")}`);
|
|
960
|
+
})
|
|
961
|
+
.catch((e) => send(`ERROR: ${e instanceof Error ? e.message : String(e)}`))
|
|
962
|
+
.finally(() => writer.close());
|
|
963
|
+
|
|
964
|
+
return new Response(readable, {
|
|
965
|
+
headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive" },
|
|
966
|
+
});
|
|
924
967
|
}
|
|
925
968
|
|
|
926
969
|
if (url.pathname === "/settings") {
|
package/src/static/daemon-mgr.js
CHANGED
|
@@ -156,40 +156,111 @@
|
|
|
156
156
|
return el && el.value != null ? el.value.trim() : '';
|
|
157
157
|
}
|
|
158
158
|
|
|
159
|
+
function appendDeployLog(text) {
|
|
160
|
+
if (!deployResultEl) return;
|
|
161
|
+
deployResultEl.className = 'db-result db-loading';
|
|
162
|
+
deployResultEl.textContent += text;
|
|
163
|
+
deployResultEl.scrollTop = deployResultEl.scrollHeight;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function parseTargets(raw) {
|
|
167
|
+
var targets = [];
|
|
168
|
+
var lines = raw.split('\n');
|
|
169
|
+
for (var i = 0; i < lines.length; i++) {
|
|
170
|
+
var line = lines[i].trim();
|
|
171
|
+
if (!line || line.startsWith('#')) continue;
|
|
172
|
+
var parts = line.split(':');
|
|
173
|
+
var host = parts[0].trim();
|
|
174
|
+
var port = parts.length > 1 ? parseInt(parts[1].trim(), 10) : NaN;
|
|
175
|
+
if (host) {
|
|
176
|
+
var t = { host: host };
|
|
177
|
+
if (Number.isFinite(port) && port > 0) t.daemonPort = port;
|
|
178
|
+
targets.push(t);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return targets;
|
|
182
|
+
}
|
|
183
|
+
|
|
159
184
|
async function deployRemote() {
|
|
160
185
|
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;
|
|
186
|
+
var targets = parseTargets(val('deploy-targets'));
|
|
187
|
+
var mysqlHost = val('deploy-mysql-host');
|
|
188
|
+
var sshUser = val('deploy-user') || 'root';
|
|
189
|
+
var sshPort = Number(val('deploy-ssh-port')) || 22;
|
|
190
|
+
var timeoutMs = (Number(val('deploy-timeout')) || 180) * 1000;
|
|
171
191
|
var keyFile = val('deploy-key');
|
|
172
|
-
|
|
173
|
-
if (
|
|
174
|
-
setDeployResult('✗
|
|
192
|
+
|
|
193
|
+
if (targets.length === 0) {
|
|
194
|
+
setDeployResult('✗ 请填写至少一个目标机器', 'err');
|
|
195
|
+
if (deployBtn) deployBtn.disabled = false;
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
if (!mysqlHost) {
|
|
199
|
+
setDeployResult('✗ MySQL 主机必填', 'err');
|
|
175
200
|
if (deployBtn) deployBtn.disabled = false;
|
|
176
201
|
return;
|
|
177
202
|
}
|
|
203
|
+
|
|
204
|
+
deployResultEl.textContent = '';
|
|
205
|
+
appendDeployLog('▶ 部署 ' + targets.length + ' 个目标(超时 ' + (timeoutMs / 1000) + 's/个)...\n\n');
|
|
206
|
+
|
|
207
|
+
var body = {
|
|
208
|
+
targets: targets,
|
|
209
|
+
sshUser: sshUser,
|
|
210
|
+
sshPort: sshPort,
|
|
211
|
+
mysqlHost: mysqlHost,
|
|
212
|
+
timeoutMs: timeoutMs
|
|
213
|
+
};
|
|
214
|
+
if (keyFile) body.sshKeyFile = keyFile;
|
|
215
|
+
|
|
178
216
|
try {
|
|
179
217
|
var res = await fetch('/api/daemons/deploy', {
|
|
180
218
|
method: 'POST',
|
|
181
219
|
headers: { 'Content-Type': 'application/json' },
|
|
182
220
|
body: JSON.stringify(body)
|
|
183
221
|
});
|
|
184
|
-
|
|
185
|
-
if (
|
|
186
|
-
|
|
187
|
-
|
|
222
|
+
|
|
223
|
+
if (res.headers.get('Content-Type') && res.headers.get('Content-Type').includes('text/event-stream')) {
|
|
224
|
+
var reader = res.body.getReader();
|
|
225
|
+
var decoder = new TextDecoder();
|
|
226
|
+
var allOk = true;
|
|
227
|
+
while (true) {
|
|
228
|
+
var chunk = await reader.read();
|
|
229
|
+
if (chunk.done) break;
|
|
230
|
+
var text = decoder.decode(chunk.value, { stream: true });
|
|
231
|
+
var lines = text.split('\n');
|
|
232
|
+
for (var i = 0; i < lines.length; i++) {
|
|
233
|
+
var line = lines[i].trim();
|
|
234
|
+
if (!line.startsWith('data: ')) continue;
|
|
235
|
+
try {
|
|
236
|
+
var msg = JSON.parse(line.slice(6));
|
|
237
|
+
appendDeployLog(msg);
|
|
238
|
+
if (msg.indexOf('✗') >= 0) allOk = false;
|
|
239
|
+
} catch (e) { /* skip non-JSON */ }
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
if (allOk) {
|
|
243
|
+
deployResultEl.className = 'db-result db-ok';
|
|
244
|
+
await loadDaemons();
|
|
245
|
+
} else {
|
|
246
|
+
deployResultEl.className = 'db-result db-err';
|
|
247
|
+
}
|
|
188
248
|
} else {
|
|
189
|
-
|
|
249
|
+
var data = await res.json();
|
|
250
|
+
if (data.ok) {
|
|
251
|
+
appendDeployLog('✓ 部署成功\n');
|
|
252
|
+
if (data.output) appendDeployLog(data.output);
|
|
253
|
+
deployResultEl.className = 'db-result db-ok';
|
|
254
|
+
await loadDaemons();
|
|
255
|
+
} else {
|
|
256
|
+
appendDeployLog('✗ ' + (data.error || '未知错误') + '\n');
|
|
257
|
+
if (data.output) appendDeployLog(data.output);
|
|
258
|
+
deployResultEl.className = 'db-result db-err';
|
|
259
|
+
}
|
|
190
260
|
}
|
|
191
261
|
} catch (e) {
|
|
192
|
-
|
|
262
|
+
appendDeployLog('✗ ' + (e.message || String(e)) + '\n');
|
|
263
|
+
deployResultEl.className = 'db-result db-err';
|
|
193
264
|
} finally {
|
|
194
265
|
if (deployBtn) deployBtn.disabled = false;
|
|
195
266
|
}
|
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
|
}
|