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 +1 -1
- package/src/coordination.ts +38 -0
- package/src/daemon-deploy.ts +174 -0
- package/src/index.ts +94 -2
- package/src/static/daemon-mgr.js +211 -0
- package/src/views/settings.ts +47 -1
package/package.json
CHANGED
package/src/coordination.ts
CHANGED
|
@@ -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 { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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
|
+
})();
|
package/src/views/settings.ts
CHANGED
|
@@ -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
|
}
|