ework-web 0.7.0 → 0.8.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/index.ts +70 -2
- package/src/static/daemon-mgr.js +145 -0
- package/src/views/settings.ts +30 -0
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
|
+
}
|
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";
|
|
@@ -411,6 +411,7 @@ function chunkTextTTS(text: string, max = 120): string[] {
|
|
|
411
411
|
async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean; user: UserRow | null }): Promise<Response> {
|
|
412
412
|
if (url.pathname === "/static/app.js") return staticAsset("app.js", "text/javascript; charset=utf-8", req);
|
|
413
413
|
if (url.pathname === "/static/db-wizard.js") return staticAsset("db-wizard.js", "text/javascript; charset=utf-8", req);
|
|
414
|
+
if (url.pathname === "/static/daemon-mgr.js") return staticAsset("daemon-mgr.js", "text/javascript; charset=utf-8", req);
|
|
414
415
|
if (url.pathname === "/static/session.js") return staticAsset("session.js", "text/javascript; charset=utf-8", req);
|
|
415
416
|
if (url.pathname === "/static/file.js") return staticAsset("file.js", "text/javascript; charset=utf-8", req);
|
|
416
417
|
if (url.pathname === "/static/tts.js") return staticAsset("tts.js", "text/javascript; charset=utf-8", req);
|
|
@@ -831,6 +832,73 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
|
|
|
831
832
|
return json({ ...result, targetPath, restarting: true });
|
|
832
833
|
}
|
|
833
834
|
|
|
835
|
+
if (req.method === "GET" && url.pathname === "/api/daemons") {
|
|
836
|
+
if (!ctx.user || ctx.user.is_admin !== 1) return json({ error: "admin required" }, 403);
|
|
837
|
+
return json(await listAllDaemons());
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
if (req.method === "POST" && url.pathname === "/api/daemons") {
|
|
841
|
+
if (!ctx.user || ctx.user.is_admin !== 1) return json({ error: "admin required" }, 403);
|
|
842
|
+
if (!rateLimit(`daemons-add:${ip}`, 5, 5 / 3600)) return json({ error: "rate limited" }, 429);
|
|
843
|
+
const payload = await req.json().catch(() => ({} as unknown));
|
|
844
|
+
const port = (payload && typeof payload === "object" && "port" in payload
|
|
845
|
+
? (payload as { port?: unknown }).port
|
|
846
|
+
: undefined);
|
|
847
|
+
const args = ["add-daemon"];
|
|
848
|
+
if (typeof port === "number" && Number.isFinite(port) && port > 0 && port < 65536) {
|
|
849
|
+
args.push(String(Math.trunc(port)));
|
|
850
|
+
} else if (port !== undefined && port !== null) {
|
|
851
|
+
return json({ ok: false, error: "invalid port" }, 400);
|
|
852
|
+
}
|
|
853
|
+
let child: import("child_process").ChildProcessByStdio<null, import("stream").Readable, import("stream").Readable>;
|
|
854
|
+
try {
|
|
855
|
+
child = spawn("ework-aio", args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
856
|
+
} catch {
|
|
857
|
+
return json({ ok: false, error: "ework-aio not found" });
|
|
858
|
+
}
|
|
859
|
+
const result = await new Promise<{ ok: boolean; output?: string; error?: string }>((resolve) => {
|
|
860
|
+
const ac = new AbortController();
|
|
861
|
+
const timer = setTimeout(() => {
|
|
862
|
+
ac.abort();
|
|
863
|
+
try { child.kill("SIGKILL"); } catch { /* already dead */ }
|
|
864
|
+
}, 30_000);
|
|
865
|
+
let stdout = "";
|
|
866
|
+
let stderr = "";
|
|
867
|
+
let settled = false;
|
|
868
|
+
const finish = (r: { ok: boolean; output?: string; error?: string }) => {
|
|
869
|
+
if (settled) return;
|
|
870
|
+
settled = true;
|
|
871
|
+
clearTimeout(timer);
|
|
872
|
+
resolve(r);
|
|
873
|
+
};
|
|
874
|
+
child.stdout.on("data", (b: Buffer) => { stdout += b.toString("utf-8"); });
|
|
875
|
+
child.stderr.on("data", (b: Buffer) => { stderr += b.toString("utf-8"); });
|
|
876
|
+
child.once("error", () => finish({ ok: false, error: "ework-aio not found" }));
|
|
877
|
+
child.once("exit", (code) => {
|
|
878
|
+
if (code === 0) finish({ ok: true, output: stdout });
|
|
879
|
+
else finish({ ok: false, error: stderr.trim() || `ework-aio add-daemon exited with code ${code}` });
|
|
880
|
+
});
|
|
881
|
+
ac.signal.addEventListener("abort", () => {
|
|
882
|
+
finish({ ok: false, error: "timeout: ework-aio add-daemon did not exit within 30s" });
|
|
883
|
+
});
|
|
884
|
+
});
|
|
885
|
+
return json(result, result.ok ? 200 : 422);
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
const daemonIdRe = /^\/api\/daemons\/(\d+)$/;
|
|
889
|
+
if (req.method === "DELETE" && daemonIdRe.test(url.pathname)) {
|
|
890
|
+
if (!ctx.user || ctx.user.is_admin !== 1) return json({ error: "admin required" }, 403);
|
|
891
|
+
const match = url.pathname.match(daemonIdRe);
|
|
892
|
+
const id = match ? Number(match[1]) : NaN;
|
|
893
|
+
if (!Number.isFinite(id)) return json({ error: "invalid id" }, 400);
|
|
894
|
+
try {
|
|
895
|
+
await getDB().run(`UPDATE {{d_daemons}} SET status = 'drained' WHERE id = ?`, [id]);
|
|
896
|
+
} catch (e) {
|
|
897
|
+
return json({ ok: false, error: errMsg(e) });
|
|
898
|
+
}
|
|
899
|
+
return json({ ok: true });
|
|
900
|
+
}
|
|
901
|
+
|
|
834
902
|
if (url.pathname === "/settings") {
|
|
835
903
|
if (req.method === "GET") {
|
|
836
904
|
return html(buildSettingsPage(cfg, url.searchParams.get("saved") === "1", ctx.user!, await listCachedModels()).html);
|
|
@@ -0,0 +1,145 @@
|
|
|
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 refreshTimer = null;
|
|
11
|
+
var inFlight = false;
|
|
12
|
+
|
|
13
|
+
function esc(s) {
|
|
14
|
+
return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) {
|
|
15
|
+
return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c];
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function fmtTime(s) {
|
|
20
|
+
if (!s) return '—';
|
|
21
|
+
var t = Date.parse(String(s).includes('T') ? s : String(s).replace(' ', 'T') + 'Z');
|
|
22
|
+
if (Number.isNaN(t)) return esc(s);
|
|
23
|
+
var d = new Date(t);
|
|
24
|
+
var pad = function (n) { return n < 10 ? '0' + n : '' + n; };
|
|
25
|
+
return esc(d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) +
|
|
26
|
+
' ' + pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds()));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function statusPill(status) {
|
|
30
|
+
var raw = String(status || 'unknown').toLowerCase();
|
|
31
|
+
var cls = raw === 'active' ? 'active' : raw === 'drained' ? 'drained' : (raw === 'dead' ? 'dead' : 'unknown');
|
|
32
|
+
return '<span class="pill ' + cls + '">' + esc(raw) + '</span>';
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function render(daemons) {
|
|
36
|
+
if (!Array.isArray(daemons) || daemons.length === 0) {
|
|
37
|
+
listEl.innerHTML = '<div class="daemon-empty">暂无 daemon 注册。可在下方启动新实例。</div>';
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
var rows = daemons.map(function (d) {
|
|
41
|
+
var stopBtn = String(d.status || '').toLowerCase() === 'active'
|
|
42
|
+
? '<button type="button" class="stop" data-id="' + d.id + '">停止</button>'
|
|
43
|
+
: '';
|
|
44
|
+
return '<tr>' +
|
|
45
|
+
'<td class="cap">' + d.id + '</td>' +
|
|
46
|
+
'<td class="endpoint">' + esc(d.endpoint || '') + '</td>' +
|
|
47
|
+
'<td class="cap">' + esc(d.capacity != null ? d.capacity : '') + '</td>' +
|
|
48
|
+
'<td class="heartbeat">' + fmtTime(d.lastHeartbeat) + '</td>' +
|
|
49
|
+
'<td class="registered">' + fmtTime(d.registeredAt) + '</td>' +
|
|
50
|
+
'<td>' + statusPill(d.status) + '</td>' +
|
|
51
|
+
'<td>' + stopBtn + '</td>' +
|
|
52
|
+
'</tr>';
|
|
53
|
+
}).join('');
|
|
54
|
+
listEl.innerHTML =
|
|
55
|
+
'<table><thead><tr>' +
|
|
56
|
+
'<th>ID</th><th>Endpoint</th><th>容量</th><th>心跳</th><th>注册于</th><th>状态</th><th></th>' +
|
|
57
|
+
'</tr></thead><tbody>' + rows + '</tbody></table>';
|
|
58
|
+
listEl.querySelectorAll('button.stop').forEach(function (b) {
|
|
59
|
+
b.addEventListener('click', function () { stopDaemon(Number(b.getAttribute('data-id'))); });
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function setResult(text, kind) {
|
|
64
|
+
if (!resultEl) return;
|
|
65
|
+
resultEl.className = 'db-result db-' + (kind === 'ok' ? 'ok' : kind === 'err' ? 'err' : 'loading');
|
|
66
|
+
resultEl.textContent = text;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function loadDaemons() {
|
|
70
|
+
if (inFlight) return;
|
|
71
|
+
inFlight = true;
|
|
72
|
+
try {
|
|
73
|
+
var res = await fetch('/api/daemons', { headers: { 'Accept': 'application/json' } });
|
|
74
|
+
if (!res.ok) {
|
|
75
|
+
var errBody = await res.json().catch(function () { return { error: 'HTTP ' + res.status }; });
|
|
76
|
+
listEl.innerHTML = '<div class="daemon-empty">加载失败:' + esc(errBody.error || res.status) + '</div>';
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
render(await res.json());
|
|
80
|
+
} catch (e) {
|
|
81
|
+
listEl.innerHTML = '<div class="daemon-empty">加载失败:' + esc(e.message || String(e)) + '</div>';
|
|
82
|
+
} finally {
|
|
83
|
+
inFlight = false;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function addDaemon() {
|
|
88
|
+
if (addBtn) addBtn.disabled = true;
|
|
89
|
+
setResult('正在启动 daemon…', 'loading');
|
|
90
|
+
var body = {};
|
|
91
|
+
var portVal = portInput && portInput.value ? Number(portInput.value) : NaN;
|
|
92
|
+
if (portInput && portInput.value.trim() !== '' && Number.isFinite(portVal)) {
|
|
93
|
+
body.port = portVal;
|
|
94
|
+
}
|
|
95
|
+
try {
|
|
96
|
+
var res = await fetch('/api/daemons', {
|
|
97
|
+
method: 'POST',
|
|
98
|
+
headers: { 'Content-Type': 'application/json' },
|
|
99
|
+
body: JSON.stringify(body)
|
|
100
|
+
});
|
|
101
|
+
var data = await res.json();
|
|
102
|
+
if (data.ok) {
|
|
103
|
+
setResult('✓ daemon 启动成功' + (data.output ? ':\n' + data.output : ''), 'ok');
|
|
104
|
+
if (portInput) portInput.value = '';
|
|
105
|
+
await loadDaemons();
|
|
106
|
+
} else {
|
|
107
|
+
setResult('✗ ' + (data.error || '未知错误'), 'err');
|
|
108
|
+
}
|
|
109
|
+
} catch (e) {
|
|
110
|
+
setResult('✗ ' + (e.message || String(e)), 'err');
|
|
111
|
+
} finally {
|
|
112
|
+
if (addBtn) addBtn.disabled = false;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function stopDaemon(id) {
|
|
117
|
+
if (!confirm('确认停止 daemon #' + id + '?\n只会标记为 drained(不再接新任务),需手动 kill 进程。')) return;
|
|
118
|
+
setResult('正在标记 daemon #' + id + ' 为 drained…', 'loading');
|
|
119
|
+
try {
|
|
120
|
+
var res = await fetch('/api/daemons/' + id, { method: 'DELETE' });
|
|
121
|
+
var data = await res.json();
|
|
122
|
+
if (data.ok) {
|
|
123
|
+
setResult('✓ daemon #' + id + ' 已标记为 drained', 'ok');
|
|
124
|
+
await loadDaemons();
|
|
125
|
+
} else {
|
|
126
|
+
setResult('✗ ' + (data.error || '未知错误'), 'err');
|
|
127
|
+
}
|
|
128
|
+
} catch (e) {
|
|
129
|
+
setResult('✗ ' + (e.message || String(e)), 'err');
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (addBtn) addBtn.addEventListener('click', addDaemon);
|
|
134
|
+
document.addEventListener('visibilitychange', function () {
|
|
135
|
+
if (document.hidden) {
|
|
136
|
+
if (refreshTimer) { clearInterval(refreshTimer); refreshTimer = null; }
|
|
137
|
+
} else if (!refreshTimer) {
|
|
138
|
+
loadDaemons();
|
|
139
|
+
refreshTimer = setInterval(loadDaemons, 10000);
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
loadDaemons();
|
|
144
|
+
refreshTimer = setInterval(loadDaemons, 10000);
|
|
145
|
+
})();
|
package/src/views/settings.ts
CHANGED
|
@@ -67,6 +67,21 @@ ${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
|
+
<script src="/static/daemon-mgr.js"></script>
|
|
82
|
+
</section>`;
|
|
83
|
+
}
|
|
84
|
+
|
|
70
85
|
export function buildSettingsPage(cfg: Config, saved: boolean, viewer: UserRow, models: CachedModel[]): { html: string } {
|
|
71
86
|
const groups = SETTINGS_GROUPS.map(
|
|
72
87
|
(g) =>
|
|
@@ -109,6 +124,20 @@ button.secondary{background:transparent;color:var(--text-muted);border:1px solid
|
|
|
109
124
|
.db-result.db-loading{background:var(--bg);color:var(--text-muted)}
|
|
110
125
|
.db-result.db-ok{background:rgba(40,167,69,.15);color:#5eb88a}
|
|
111
126
|
.db-result.db-err{background:rgba(220,53,69,.15);color:#e87c7c}
|
|
127
|
+
.daemon-list{margin-bottom:.7rem}
|
|
128
|
+
.daemon-list table{width:100%;border-collapse:collapse;font-size:12px}
|
|
129
|
+
.daemon-list th,.daemon-list td{padding:.35rem .4rem;border-bottom:1px solid var(--border);text-align:left;vertical-align:top}
|
|
130
|
+
.daemon-list th{color:var(--text-muted);font-weight:normal;font-size:11px;text-transform:uppercase;letter-spacing:.03em}
|
|
131
|
+
.daemon-list td.endpoint{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11px;word-break:break-all}
|
|
132
|
+
.daemon-list td.cap{text-align:right;font-variant-numeric:tabular-nums}
|
|
133
|
+
.daemon-list td.heartbeat,.daemon-list td.registered{color:var(--text-muted);font-size:11px;white-space:nowrap}
|
|
134
|
+
.daemon-list .pill{display:inline-block;padding:.12rem .5rem;border-radius:99px;font-size:11px;line-height:1.4}
|
|
135
|
+
.daemon-list .pill.active{background:rgba(40,167,69,.18);color:#5eb88a}
|
|
136
|
+
.daemon-list .pill.drained{background:rgba(255,193,7,.18);color:#d4a942}
|
|
137
|
+
.daemon-list .pill.dead,.daemon-list .pill.unknown{background:rgba(220,53,69,.18);color:#e87c7c}
|
|
138
|
+
.daemon-list button.stop{padding:.25rem .6rem;font-size:11px;background:transparent;color:#e87c7c;border:1px solid rgba(220,53,69,.4)}
|
|
139
|
+
.daemon-list button.stop:hover{background:rgba(220,53,69,.12)}
|
|
140
|
+
.daemon-empty{color:var(--text-muted);font-size:13px;padding:.5rem 0}
|
|
112
141
|
</style></head><body>
|
|
113
142
|
<header class="nav"><a href="/" style="color:var(--header-text)">🏠 ework-web</a><span style="opacity:.8"> · 设置</span></header>
|
|
114
143
|
<main class="wrap">
|
|
@@ -121,6 +150,7 @@ ${banner}
|
|
|
121
150
|
${modelRefreshForm}
|
|
122
151
|
${ttsLink}
|
|
123
152
|
${buildDbSection(viewer)}
|
|
153
|
+
${buildDaemonSection(viewer)}
|
|
124
154
|
</main></body></html>`;
|
|
125
155
|
return { html };
|
|
126
156
|
}
|