@dsh-plus/notify-email 0.1.6 → 0.1.8
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/lib/client.js +626 -650
- package/lib/index.d.ts +0 -3
- package/lib/index.js +70 -66
- package/package.json +8 -8
- package/src/client/card.tsx +169 -52
- package/src/client/client.ts +20 -8
- package/src/client/scope.ts +25 -3
- package/src/config.ts +15 -3
- package/src/index.ts +8 -3
- package/src/mailer.ts +2 -2
- package/src/service.ts +23 -17
- package/src/triggers/builtin.ts +5 -2
- package/src/watchers/turn-end.ts +4 -7
package/lib/index.d.ts
CHANGED
|
@@ -1,9 +1,6 @@
|
|
|
1
1
|
import z from "@deepseek-ai/schemastery";
|
|
2
|
-
import "@deepseek-ai/dsh-settings";
|
|
3
2
|
import { Context, Service } from "@deepseek-ai/cordis";
|
|
4
|
-
|
|
5
3
|
//#region src/config.d.ts
|
|
6
|
-
|
|
7
4
|
declare const Config: z<Schemastery.ObjectS<{
|
|
8
5
|
enabled: z<boolean, boolean>;
|
|
9
6
|
smtp: z<Schemastery.ObjectS<{
|
package/lib/index.js
CHANGED
|
@@ -1,26 +1,20 @@
|
|
|
1
|
-
import z from "@deepseek-ai/schemastery";
|
|
2
1
|
import { installSettingsSection, settingsNamespace } from "@deepseek-ai/dsh-settings";
|
|
2
|
+
import z from "@deepseek-ai/schemastery";
|
|
3
3
|
import { Context, Service } from "@deepseek-ai/cordis";
|
|
4
4
|
import { dshHomePath } from "@deepseek-ai/dsh-home-paths";
|
|
5
5
|
import { appendFile, mkdir } from "node:fs/promises";
|
|
6
6
|
import { dirname } from "node:path";
|
|
7
7
|
import nodemailer from "nodemailer";
|
|
8
|
-
|
|
9
|
-
//#region src/ns.ts
|
|
10
|
-
/**
|
|
11
|
-
* settings 命名空间字面量(纯常量,零依赖,浏览器半可安全引入)。
|
|
12
|
-
* 服务端经 settingsNamespace() 品牌化后使用(config.ts),浏览器半将其作为
|
|
13
|
-
* settings.plugin.item keyed 槽位的 key —— 同一字面量两处共用,防止漂移。
|
|
14
|
-
* 契约:dsh rc7 起该槽位按「卡片编辑的 settings 命名空间」作 key 分发,
|
|
15
|
-
* 官方配置页只渲染 key 命中 Host 已注册命名空间的卡片。
|
|
16
|
-
* @module notify-email/ns
|
|
17
|
-
*/
|
|
18
|
-
const SETTINGS_NS$1 = "dsh-plus-notify-email";
|
|
19
|
-
|
|
20
8
|
//#endregion
|
|
21
9
|
//#region src/config.ts
|
|
10
|
+
/**
|
|
11
|
+
* 配置单一事实源:cordis 行级 Config(组合默认值,dev/prod patch 层可覆盖)
|
|
12
|
+
* 与 settings namespace(用户层,经 dsh-settings-file 持久化到 $DSH_HOME/settings.yaml)
|
|
13
|
+
* 共用同一 schemastery schema。SMTP 密码标记 secret 角色,任何读取通道不得回传。
|
|
14
|
+
* @module notify-email/config
|
|
15
|
+
*/
|
|
22
16
|
/** settings 命名空间;webui 配置卡片与插件运行期读取同一份(字面量见 ./ns.ts)。 */
|
|
23
|
-
const SETTINGS_NS = settingsNamespace(
|
|
17
|
+
const SETTINGS_NS = settingsNamespace("dsh-plus-notify-email");
|
|
24
18
|
const SmtpSchema = z.object({
|
|
25
19
|
host: z.string().description("SMTP 服务器主机名").default(""),
|
|
26
20
|
port: z.natural().max(65535).description("SMTP 端口").default(465),
|
|
@@ -58,9 +52,14 @@ const Config = z.object({
|
|
|
58
52
|
maxBodyChars: z.natural().min(200).description("邮件正文内容截断长度").default(4e3),
|
|
59
53
|
dryRun: z.boolean().description("仅记录日志不真实发送(开发调试用)").default(false)
|
|
60
54
|
});
|
|
61
|
-
|
|
62
55
|
//#endregion
|
|
63
56
|
//#region src/audit.ts
|
|
57
|
+
/**
|
|
58
|
+
* 通知投递审计:每次发送尝试追加一行 JSONL 到 $DSH_HOME/logs/notify-email.jsonl。
|
|
59
|
+
* 让 dry-run 可观测(dev 实例 logger 不落盘),也为生产排查「为什么没收到邮件」留痕。
|
|
60
|
+
* 记录正文截断 2000 字符;永远不含 SMTP 凭据。写失败只告警不阻断。
|
|
61
|
+
* @module notify-email/audit
|
|
62
|
+
*/
|
|
64
63
|
const BODY_EXCERPT_CHARS = 2e3;
|
|
65
64
|
function buildAuditRecord(subject, text, to, result) {
|
|
66
65
|
return {
|
|
@@ -80,9 +79,51 @@ function createJsonlAuditSink(path, onError) {
|
|
|
80
79
|
});
|
|
81
80
|
};
|
|
82
81
|
}
|
|
83
|
-
|
|
82
|
+
//#endregion
|
|
83
|
+
//#region src/config-api.ts
|
|
84
|
+
const ROUTE_TEST = "/dsh-plus/notify-email/test";
|
|
85
|
+
function sendJson(res, status, body) {
|
|
86
|
+
res.writeHead(status, { "content-type": "application/json; charset=utf-8" });
|
|
87
|
+
res.end(JSON.stringify(body));
|
|
88
|
+
}
|
|
89
|
+
async function handleTest(service, res) {
|
|
90
|
+
const result = await service.sendTest();
|
|
91
|
+
sendJson(res, result.ok ? 200 : 502, result);
|
|
92
|
+
}
|
|
93
|
+
/** 注册测试发信路由;返回是否注册成功(webServer 缺失时由调用方保证不调用)。 */
|
|
94
|
+
function registerTestApi(ctx, service) {
|
|
95
|
+
const logger = ctx.logger("notify-email");
|
|
96
|
+
const guard = (handler) => {
|
|
97
|
+
return async (req, res) => {
|
|
98
|
+
try {
|
|
99
|
+
await handler(req, res);
|
|
100
|
+
} catch (error) {
|
|
101
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
102
|
+
logger.warn(`test api ${req.method ?? "?"} ${req.url ?? "?"} failed: ${message}`);
|
|
103
|
+
if (!res.headersSent) sendJson(res, 400, { error: message });
|
|
104
|
+
else res.end();
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
};
|
|
108
|
+
ctx.webServer.register({
|
|
109
|
+
kind: "exact",
|
|
110
|
+
path: ROUTE_TEST,
|
|
111
|
+
handler: guard(async (req, res) => {
|
|
112
|
+
if (req.method !== "POST") {
|
|
113
|
+
sendJson(res, 405, { error: "method not allowed" });
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
await handleTest(service, res);
|
|
117
|
+
})
|
|
118
|
+
});
|
|
119
|
+
}
|
|
84
120
|
//#endregion
|
|
85
121
|
//#region src/mailer.ts
|
|
122
|
+
/**
|
|
123
|
+
* 邮件发送:nodemailer 封装 + dry-run 兜底。
|
|
124
|
+
* Transport 抽象为函数注入,单元测试以假 transport 断言参数,零网络。
|
|
125
|
+
* @module notify-email/mailer
|
|
126
|
+
*/
|
|
86
127
|
/** nodemailer 连接/问候/套接字超时(毫秒),防 SMTP 不可达时挂住通知链路。 */
|
|
87
128
|
const CONNECTION_TIMEOUT_MS = 1e4;
|
|
88
129
|
const GREETING_TIMEOUT_MS = 1e4;
|
|
@@ -160,46 +201,6 @@ var Mailer = class {
|
|
|
160
201
|
return result;
|
|
161
202
|
}
|
|
162
203
|
};
|
|
163
|
-
|
|
164
|
-
//#endregion
|
|
165
|
-
//#region src/config-api.ts
|
|
166
|
-
const ROUTE_TEST = "/dsh-plus/notify-email/test";
|
|
167
|
-
function sendJson(res, status, body) {
|
|
168
|
-
res.writeHead(status, { "content-type": "application/json; charset=utf-8" });
|
|
169
|
-
res.end(JSON.stringify(body));
|
|
170
|
-
}
|
|
171
|
-
async function handleTest(service, res) {
|
|
172
|
-
const result = await service.sendTest();
|
|
173
|
-
sendJson(res, result.ok ? 200 : 502, result);
|
|
174
|
-
}
|
|
175
|
-
/** 注册测试发信路由;返回是否注册成功(webServer 缺失时由调用方保证不调用)。 */
|
|
176
|
-
function registerTestApi(ctx, service) {
|
|
177
|
-
const logger = ctx.logger("notify-email");
|
|
178
|
-
const guard = (handler) => {
|
|
179
|
-
return async (req, res) => {
|
|
180
|
-
try {
|
|
181
|
-
await handler(req, res);
|
|
182
|
-
} catch (error) {
|
|
183
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
184
|
-
logger.warn(`test api ${req.method ?? "?"} ${req.url ?? "?"} failed: ${message}`);
|
|
185
|
-
if (!res.headersSent) sendJson(res, 400, { error: message });
|
|
186
|
-
else res.end();
|
|
187
|
-
}
|
|
188
|
-
};
|
|
189
|
-
};
|
|
190
|
-
ctx.webServer.register({
|
|
191
|
-
kind: "exact",
|
|
192
|
-
path: ROUTE_TEST,
|
|
193
|
-
handler: guard(async (req, res) => {
|
|
194
|
-
if (req.method !== "POST") {
|
|
195
|
-
sendJson(res, 405, { error: "method not allowed" });
|
|
196
|
-
return;
|
|
197
|
-
}
|
|
198
|
-
await handleTest(service, res);
|
|
199
|
-
})
|
|
200
|
-
});
|
|
201
|
-
}
|
|
202
|
-
|
|
203
204
|
//#endregion
|
|
204
205
|
//#region src/triggers/render.ts
|
|
205
206
|
function truncate(text, maxChars) {
|
|
@@ -264,7 +265,6 @@ function renderAbortedNotice(info) {
|
|
|
264
265
|
text: header("任务被用户取消", info)
|
|
265
266
|
};
|
|
266
267
|
}
|
|
267
|
-
|
|
268
268
|
//#endregion
|
|
269
269
|
//#region src/triggers/builtin.ts
|
|
270
270
|
/** 官方决策工具名 → 渲染函数 + 对应 toggle。 */
|
|
@@ -289,7 +289,6 @@ function createTurnEndTrigger(resolveConfig) {
|
|
|
289
289
|
}
|
|
290
290
|
};
|
|
291
291
|
}
|
|
292
|
-
|
|
293
292
|
//#endregion
|
|
294
293
|
//#region src/watchers/decision.ts
|
|
295
294
|
function installDecisionWatcher(ctx, service) {
|
|
@@ -310,11 +309,10 @@ function installDecisionWatcher(ctx, service) {
|
|
|
310
309
|
return next();
|
|
311
310
|
});
|
|
312
311
|
}
|
|
313
|
-
|
|
314
312
|
//#endregion
|
|
315
313
|
//#region src/watchers/turn-end.ts
|
|
316
314
|
/** 只有这三类 turn 结束对用户有可决策价值;blocked/max-tokens/interrupted 不通知。 */
|
|
317
|
-
const NOTIFY_KINDS = new Set([
|
|
315
|
+
const NOTIFY_KINDS = /* @__PURE__ */ new Set([
|
|
318
316
|
"completed",
|
|
319
317
|
"error",
|
|
320
318
|
"aborted"
|
|
@@ -397,9 +395,16 @@ function installTurnEndWatcher(ctx, service, resolveConfig) {
|
|
|
397
395
|
watches.clear();
|
|
398
396
|
}, "notify-email: turn-end timers");
|
|
399
397
|
}
|
|
400
|
-
|
|
401
398
|
//#endregion
|
|
402
399
|
//#region src/service.ts
|
|
400
|
+
/**
|
|
401
|
+
* NotifyEmailService:通知编排中枢。
|
|
402
|
+
* - 配置:installSettingsSection 接入 settings 用户层($DSH_HOME/settings.yaml,热生效),
|
|
403
|
+
* 无 settings provider 时退化为 cordis 行级 config。
|
|
404
|
+
* - 触发器:registerTrigger 是第三方插件的扩展接口;内置官方适配器同路径注册。
|
|
405
|
+
* - 投递:首个非空 EmailNotice 经 Mailer 发送;单个触发器抛错不影响其余。
|
|
406
|
+
* @module @dsh-plus/notify-email
|
|
407
|
+
*/
|
|
403
408
|
var NotifyEmailService = class extends Service {
|
|
404
409
|
static [Context.inject] = ["agents", "tools"];
|
|
405
410
|
triggers = [];
|
|
@@ -415,7 +420,8 @@ var NotifyEmailService = class extends Service {
|
|
|
415
420
|
onChange: () => {}
|
|
416
421
|
});
|
|
417
422
|
const logger = ctx.logger("notify-email");
|
|
418
|
-
|
|
423
|
+
const audit = createJsonlAuditSink(dshHomePath("logs", "notify-email.jsonl"), (message) => logger.warn(`audit write failed: ${message}`));
|
|
424
|
+
this.mailer = new Mailer(() => this.current(), logger, void 0, audit);
|
|
419
425
|
this.registerTrigger(createDecisionTrigger(() => this.current()));
|
|
420
426
|
this.registerTrigger(createTurnEndTrigger(() => this.current()));
|
|
421
427
|
installDecisionWatcher(ctx, this);
|
|
@@ -456,7 +462,7 @@ var NotifyEmailService = class extends Service {
|
|
|
456
462
|
async sendNotice(notice) {
|
|
457
463
|
return this.mailer.send(notice);
|
|
458
464
|
}
|
|
459
|
-
async dispatch(kind,
|
|
465
|
+
async dispatch(kind, _payload, produce) {
|
|
460
466
|
for (const trigger of [...this.triggers]) {
|
|
461
467
|
let notice;
|
|
462
468
|
try {
|
|
@@ -472,7 +478,6 @@ var NotifyEmailService = class extends Service {
|
|
|
472
478
|
}
|
|
473
479
|
}
|
|
474
480
|
};
|
|
475
|
-
|
|
476
481
|
//#endregion
|
|
477
482
|
//#region src/index.ts
|
|
478
483
|
const name = "dsh-plus-notify-email";
|
|
@@ -480,6 +485,5 @@ const inject = ["agents", "tools"];
|
|
|
480
485
|
function apply(ctx, config) {
|
|
481
486
|
ctx.plugin(NotifyEmailService, config);
|
|
482
487
|
}
|
|
483
|
-
|
|
484
488
|
//#endregion
|
|
485
|
-
export { Config, NotifyEmailService, apply, inject, name };
|
|
489
|
+
export { Config, NotifyEmailService, apply, inject, name };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dsh-plus/notify-email",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.8",
|
|
4
4
|
"description": "dsh-plus service+ui plugin: 任务结束/等待决策/出错时向指定邮箱发送邮件通知",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -42,13 +42,13 @@
|
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
|
44
44
|
"@deepseek-ai/cordis": "4.0.1",
|
|
45
|
-
"@deepseek-ai/dsh-agent": "0.1.
|
|
46
|
-
"@deepseek-ai/dsh-home-paths": "0.1.
|
|
47
|
-
"@deepseek-ai/dsh-host-webserver": "0.1.
|
|
48
|
-
"@deepseek-ai/dsh-llm": "0.1.
|
|
49
|
-
"@deepseek-ai/dsh-session": "0.1.
|
|
50
|
-
"@deepseek-ai/dsh-settings": "0.1.
|
|
51
|
-
"@deepseek-ai/dsh-tools": "0.1.
|
|
45
|
+
"@deepseek-ai/dsh-agent": "0.1.1-rc.2",
|
|
46
|
+
"@deepseek-ai/dsh-home-paths": "0.1.1-rc.2",
|
|
47
|
+
"@deepseek-ai/dsh-host-webserver": "0.1.1-rc.2",
|
|
48
|
+
"@deepseek-ai/dsh-llm": "0.1.1-rc.2",
|
|
49
|
+
"@deepseek-ai/dsh-session": "0.1.1-rc.2",
|
|
50
|
+
"@deepseek-ai/dsh-settings": "0.1.1-rc.2",
|
|
51
|
+
"@deepseek-ai/dsh-tools": "0.1.1-rc.2",
|
|
52
52
|
"@deepseek-ai/schemastery": "3.18.1",
|
|
53
53
|
"@types/nodemailer": "8.0.1",
|
|
54
54
|
"@types/react": "~18.3.1",
|
package/src/client/card.tsx
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* 保存经 settings.update 深合并(空 pass 剔除 = 保持不变)。
|
|
8
8
|
* @module notify-email/client/card
|
|
9
9
|
*/
|
|
10
|
-
import { useEffect, useMemo,
|
|
10
|
+
import { type ReactElement, useEffect, useMemo, useState, useSyncExternalStore } from 'react'
|
|
11
11
|
|
|
12
12
|
import { SETTINGS_NS } from '../ns.ts'
|
|
13
13
|
import { sendTest } from './api.ts'
|
|
@@ -97,7 +97,10 @@ function toPatch(draft: Draft): Record<string, unknown> {
|
|
|
97
97
|
pass: draft.pass,
|
|
98
98
|
from: draft.from.trim(),
|
|
99
99
|
},
|
|
100
|
-
to: draft.toText
|
|
100
|
+
to: draft.toText
|
|
101
|
+
.split(',')
|
|
102
|
+
.map((s) => s.trim())
|
|
103
|
+
.filter((s) => s.length > 0),
|
|
101
104
|
triggers: { ...draft.triggers },
|
|
102
105
|
idleDebounceMs: Number(draft.idleDebounceMs),
|
|
103
106
|
maxBodyChars: Number(draft.maxBodyChars),
|
|
@@ -128,14 +131,17 @@ export function NotifyEmailCard(props: CardProps): ReactElement | null {
|
|
|
128
131
|
}, [value, draft])
|
|
129
132
|
|
|
130
133
|
// passConfigured 探测:scope 快照不含 secrets,经 describe 的 secrets 列表判断。
|
|
134
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: snapshot 变化(保存后 revision 推进)时刻意重探 secrets 状态
|
|
131
135
|
useEffect(() => {
|
|
132
136
|
let alive = true
|
|
133
|
-
api
|
|
137
|
+
api
|
|
138
|
+
.describe({})
|
|
134
139
|
.then((response) => {
|
|
135
140
|
if (!alive || !response.result.ok) return
|
|
136
141
|
const view = response.result.value?.namespaces.find((ns) => ns.ns === SETTINGS_NS)
|
|
137
142
|
setPassConfigured(
|
|
138
|
-
view?.secrets.some((secret) => secret.path.join('.') === 'smtp.pass' && secret.set) ??
|
|
143
|
+
view?.secrets.some((secret) => secret.path.join('.') === 'smtp.pass' && secret.set) ??
|
|
144
|
+
false,
|
|
139
145
|
)
|
|
140
146
|
})
|
|
141
147
|
.catch(() => {})
|
|
@@ -146,20 +152,27 @@ export function NotifyEmailCard(props: CardProps): ReactElement | null {
|
|
|
146
152
|
|
|
147
153
|
const dirty = useMemo(
|
|
148
154
|
() =>
|
|
149
|
-
value !== undefined &&
|
|
155
|
+
value !== undefined &&
|
|
156
|
+
draft !== null &&
|
|
150
157
|
JSON.stringify(toPatch(draft)) !== JSON.stringify(toPatch(draftFromValue(value))),
|
|
151
158
|
[value, draft],
|
|
152
159
|
)
|
|
153
160
|
const invalid = useMemo(
|
|
154
161
|
() =>
|
|
155
162
|
draft !== null &&
|
|
156
|
-
(!isPositiveInt(draft.port) ||
|
|
157
|
-
!isPositiveInt(draft.
|
|
163
|
+
(!isPositiveInt(draft.port) ||
|
|
164
|
+
!isPositiveInt(draft.idleDebounceMs) ||
|
|
165
|
+
!isPositiveInt(draft.maxBodyChars) ||
|
|
166
|
+
Number(draft.maxBodyChars) < 200),
|
|
158
167
|
[draft],
|
|
159
168
|
)
|
|
160
169
|
|
|
161
170
|
if (value === undefined || draft === null) {
|
|
162
|
-
return
|
|
171
|
+
return (
|
|
172
|
+
<li className="dne-card">
|
|
173
|
+
<p className="dne-readOnly">{t('loading')}</p>
|
|
174
|
+
</li>
|
|
175
|
+
)
|
|
163
176
|
}
|
|
164
177
|
const edit = <K extends keyof Draft>(key: K, editValue: Draft[K]): void => {
|
|
165
178
|
setDraft({ ...draft, [key]: editValue })
|
|
@@ -175,11 +188,12 @@ export function NotifyEmailCard(props: CardProps): ReactElement | null {
|
|
|
175
188
|
delete (patch['smtp'] as Record<string, unknown>)['pass']
|
|
176
189
|
}
|
|
177
190
|
const revision = scope.getSnapshot().revision
|
|
178
|
-
api
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
191
|
+
api
|
|
192
|
+
.update({
|
|
193
|
+
ns: SETTINGS_NS,
|
|
194
|
+
patch,
|
|
195
|
+
...(revision !== undefined ? { expectedRevision: revision } : {}),
|
|
196
|
+
})
|
|
183
197
|
.then(async (response) => {
|
|
184
198
|
if (!response.result.ok) {
|
|
185
199
|
throw new Error(response.result.error?.message ?? t('saveFailed'))
|
|
@@ -200,14 +214,20 @@ export function NotifyEmailCard(props: CardProps): ReactElement | null {
|
|
|
200
214
|
sendTest()
|
|
201
215
|
.then((result) => {
|
|
202
216
|
if (result.ok) {
|
|
203
|
-
setStatus({
|
|
217
|
+
setStatus({
|
|
218
|
+
kind: 'ok',
|
|
219
|
+
text: result.detail === 'dry-run' ? t('testDryRun') : t('testOk'),
|
|
220
|
+
})
|
|
204
221
|
} else {
|
|
205
222
|
const why = result.detail === 'incomplete' ? t('incomplete') : result.detail
|
|
206
223
|
setStatus({ kind: 'error', text: `${t('testFailed')}${why}` })
|
|
207
224
|
}
|
|
208
225
|
})
|
|
209
226
|
.catch((error: unknown) => {
|
|
210
|
-
setStatus({
|
|
227
|
+
setStatus({
|
|
228
|
+
kind: 'error',
|
|
229
|
+
text: `${t('testFailed')}${error instanceof Error ? error.message : ''}`,
|
|
230
|
+
})
|
|
211
231
|
})
|
|
212
232
|
.finally(() => setTesting(false))
|
|
213
233
|
}
|
|
@@ -231,58 +251,155 @@ export function NotifyEmailCard(props: CardProps): ReactElement | null {
|
|
|
231
251
|
</button>
|
|
232
252
|
{open ? (
|
|
233
253
|
<div className="dne-body">
|
|
234
|
-
{disabled ?
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
<
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
<TextField
|
|
254
|
+
{disabled ? (
|
|
255
|
+
<p className="dne-readOnly" role="status">
|
|
256
|
+
{t('readOnly')}
|
|
257
|
+
</p>
|
|
258
|
+
) : null}
|
|
259
|
+
<CheckRow
|
|
260
|
+
id="dne-enabled"
|
|
261
|
+
label={t('enabled')}
|
|
262
|
+
checked={draft.enabled}
|
|
263
|
+
disabled={disabled}
|
|
264
|
+
onEdit={(v) => edit('enabled', v)}
|
|
265
|
+
/>
|
|
266
|
+
<TextField
|
|
267
|
+
id="dne-host"
|
|
268
|
+
label={t('host')}
|
|
269
|
+
hint={t('hostHint')}
|
|
270
|
+
value={draft.host}
|
|
271
|
+
disabled={disabled}
|
|
272
|
+
onEdit={(v) => edit('host', v)}
|
|
273
|
+
/>
|
|
274
|
+
<TextField
|
|
275
|
+
id="dne-port"
|
|
276
|
+
label={t('port')}
|
|
277
|
+
hint={t('portHint')}
|
|
278
|
+
value={draft.port}
|
|
279
|
+
numeric
|
|
280
|
+
disabled={disabled}
|
|
281
|
+
invalid={!isPositiveInt(draft.port)}
|
|
282
|
+
invalidLabel={t('invalidNumber')}
|
|
283
|
+
onEdit={(v) => edit('port', v)}
|
|
284
|
+
/>
|
|
285
|
+
<CheckRow
|
|
286
|
+
id="dne-secure"
|
|
287
|
+
label={t('secure')}
|
|
288
|
+
checked={draft.secure}
|
|
289
|
+
disabled={disabled}
|
|
290
|
+
onEdit={(v) => edit('secure', v)}
|
|
291
|
+
/>
|
|
292
|
+
<TextField
|
|
293
|
+
id="dne-user"
|
|
294
|
+
label={t('user')}
|
|
295
|
+
hint={t('userHint')}
|
|
296
|
+
value={draft.user}
|
|
247
297
|
disabled={disabled}
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
<TextField
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
298
|
+
onEdit={(v) => edit('user', v)}
|
|
299
|
+
/>
|
|
300
|
+
<TextField
|
|
301
|
+
id="dne-pass"
|
|
302
|
+
label={t('pass')}
|
|
303
|
+
hint={t('passHint')}
|
|
304
|
+
value={draft.pass}
|
|
305
|
+
password
|
|
306
|
+
disabled={disabled}
|
|
307
|
+
badge={{
|
|
308
|
+
text: passConfigured ? t('passSet') : t('passUnset'),
|
|
309
|
+
set: passConfigured,
|
|
310
|
+
}}
|
|
311
|
+
onEdit={(v) => edit('pass', v)}
|
|
312
|
+
/>
|
|
313
|
+
<TextField
|
|
314
|
+
id="dne-from"
|
|
315
|
+
label={t('from')}
|
|
316
|
+
hint={t('fromHint')}
|
|
317
|
+
value={draft.from}
|
|
318
|
+
disabled={disabled}
|
|
319
|
+
onEdit={(v) => edit('from', v)}
|
|
320
|
+
/>
|
|
321
|
+
<TextField
|
|
322
|
+
id="dne-to"
|
|
323
|
+
label={t('to')}
|
|
324
|
+
hint={t('toHint')}
|
|
325
|
+
value={draft.toText}
|
|
326
|
+
disabled={disabled}
|
|
327
|
+
onEdit={(v) => edit('toText', v)}
|
|
328
|
+
/>
|
|
254
329
|
<p className="dne-groupLabel">{t('triggerGroup')}</p>
|
|
255
330
|
{TRIGGER_KEYS.map((key) => (
|
|
256
|
-
<CheckRow
|
|
257
|
-
|
|
331
|
+
<CheckRow
|
|
332
|
+
key={key}
|
|
333
|
+
id={`dne-${key}`}
|
|
334
|
+
label={t(key)}
|
|
335
|
+
checked={draft.triggers[key]}
|
|
336
|
+
disabled={disabled}
|
|
337
|
+
onEdit={(v) => editTrigger(key, v)}
|
|
338
|
+
/>
|
|
258
339
|
))}
|
|
259
|
-
<TextField
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
340
|
+
<TextField
|
|
341
|
+
id="dne-debounce"
|
|
342
|
+
label={t('idleDebounceMs')}
|
|
343
|
+
hint={t('idleDebounceMsHint')}
|
|
344
|
+
value={draft.idleDebounceMs}
|
|
345
|
+
numeric
|
|
346
|
+
disabled={disabled}
|
|
347
|
+
invalid={!isPositiveInt(draft.idleDebounceMs)}
|
|
348
|
+
invalidLabel={t('invalidNumber')}
|
|
349
|
+
onEdit={(v) => edit('idleDebounceMs', v)}
|
|
350
|
+
/>
|
|
351
|
+
<TextField
|
|
352
|
+
id="dne-maxchars"
|
|
353
|
+
label={t('maxBodyChars')}
|
|
354
|
+
hint={t('maxBodyCharsHint')}
|
|
355
|
+
value={draft.maxBodyChars}
|
|
356
|
+
numeric
|
|
357
|
+
disabled={disabled}
|
|
265
358
|
invalid={!isPositiveInt(draft.maxBodyChars) || Number(draft.maxBodyChars) < 200}
|
|
266
359
|
invalidLabel={t('invalidNumber')}
|
|
267
|
-
onEdit={(v) => edit('maxBodyChars', v)}
|
|
268
|
-
|
|
269
|
-
|
|
360
|
+
onEdit={(v) => edit('maxBodyChars', v)}
|
|
361
|
+
/>
|
|
362
|
+
<CheckRow
|
|
363
|
+
id="dne-dryrun"
|
|
364
|
+
label={t('dryRun')}
|
|
365
|
+
checked={draft.dryRun}
|
|
366
|
+
disabled={disabled}
|
|
367
|
+
onEdit={(v) => edit('dryRun', v)}
|
|
368
|
+
/>
|
|
270
369
|
<div className="dne-footer">
|
|
271
370
|
{status.kind !== 'idle' ? (
|
|
272
|
-
<p
|
|
371
|
+
<p
|
|
372
|
+
className={`dne-status${status.kind === 'error' ? ' dne-statusError' : ''}`}
|
|
373
|
+
role="status"
|
|
374
|
+
>
|
|
273
375
|
{status.text}
|
|
274
376
|
</p>
|
|
275
377
|
) : null}
|
|
276
|
-
<button
|
|
277
|
-
|
|
378
|
+
<button
|
|
379
|
+
type="button"
|
|
380
|
+
className="dne-btn dne-btnGhost"
|
|
381
|
+
disabled={testing}
|
|
382
|
+
onClick={onTest}
|
|
383
|
+
>
|
|
278
384
|
{t(testing ? 'testing' : 'test')}
|
|
279
385
|
</button>
|
|
280
|
-
<button
|
|
281
|
-
|
|
386
|
+
<button
|
|
387
|
+
type="button"
|
|
388
|
+
className="dne-btn dne-btnGhost"
|
|
389
|
+
disabled={!dirty || saving}
|
|
390
|
+
onClick={() => {
|
|
391
|
+
setDraft(draftFromValue(value))
|
|
392
|
+
setStatus(IDLE_STATUS)
|
|
393
|
+
}}
|
|
394
|
+
>
|
|
282
395
|
{t('discard')}
|
|
283
396
|
</button>
|
|
284
|
-
<button
|
|
285
|
-
|
|
397
|
+
<button
|
|
398
|
+
type="button"
|
|
399
|
+
className="dne-btn dne-btnPrimary"
|
|
400
|
+
disabled={!dirty || invalid || saving || disabled}
|
|
401
|
+
onClick={onSave}
|
|
402
|
+
>
|
|
286
403
|
{t(saving ? 'saving' : 'save')}
|
|
287
404
|
</button>
|
|
288
405
|
</div>
|
package/src/client/client.ts
CHANGED
|
@@ -63,7 +63,12 @@ interface ClientContext {
|
|
|
63
63
|
* (loopback 或 tailnet 信任域名)下行为一致。
|
|
64
64
|
*/
|
|
65
65
|
function createApiScope(api: SettingsApi, ns: string, c: ClientContext): Scope {
|
|
66
|
-
let state: ScopeSnapshot = {
|
|
66
|
+
let state: ScopeSnapshot = {
|
|
67
|
+
status: 'loading',
|
|
68
|
+
value: undefined,
|
|
69
|
+
revision: undefined,
|
|
70
|
+
writable: false,
|
|
71
|
+
}
|
|
67
72
|
const listeners = new Set<() => void>()
|
|
68
73
|
let generation = 0
|
|
69
74
|
const publish = (next: ScopeSnapshot): void => {
|
|
@@ -72,7 +77,7 @@ function createApiScope(api: SettingsApi, ns: string, c: ClientContext): Scope {
|
|
|
72
77
|
}
|
|
73
78
|
const load = async (): Promise<void> => {
|
|
74
79
|
const gen = ++generation
|
|
75
|
-
let response
|
|
80
|
+
let response: Awaited<ReturnType<SettingsApi['describe']>>
|
|
76
81
|
try {
|
|
77
82
|
response = await api.describe({})
|
|
78
83
|
} catch {
|
|
@@ -82,10 +87,20 @@ function createApiScope(api: SettingsApi, ns: string, c: ClientContext): Scope {
|
|
|
82
87
|
const writable = response.result.value?.writable ?? false
|
|
83
88
|
const view = response.result.value?.namespaces.find((candidate) => candidate.ns === ns)
|
|
84
89
|
if (view === undefined) {
|
|
85
|
-
publish({
|
|
90
|
+
publish({
|
|
91
|
+
status: 'unavailable',
|
|
92
|
+
value: undefined,
|
|
93
|
+
revision: undefined,
|
|
94
|
+
writable,
|
|
95
|
+
})
|
|
86
96
|
return
|
|
87
97
|
}
|
|
88
|
-
publish({
|
|
98
|
+
publish({
|
|
99
|
+
status: 'ready',
|
|
100
|
+
value: view.value,
|
|
101
|
+
revision: view.revision,
|
|
102
|
+
writable,
|
|
103
|
+
})
|
|
89
104
|
}
|
|
90
105
|
const refresh = (namespace?: unknown): void => {
|
|
91
106
|
if (namespace !== undefined && namespace !== ns) return
|
|
@@ -125,10 +140,7 @@ export function apply(ctx: Context): void {
|
|
|
125
140
|
},
|
|
126
141
|
'notify-email: style',
|
|
127
142
|
)
|
|
128
|
-
c.effect(
|
|
129
|
-
() => c.locale.register(NS, { zh, en }),
|
|
130
|
-
'notify-email: locale',
|
|
131
|
-
)
|
|
143
|
+
c.effect(() => c.locale.register(NS, { zh, en }), 'notify-email: locale')
|
|
132
144
|
const api = c.get('connection').api.settings
|
|
133
145
|
const scope = createApiScope(api, SETTINGS_NS, c)
|
|
134
146
|
c.slots.inject('settings.plugin.item', () =>
|
package/src/client/scope.ts
CHANGED
|
@@ -31,7 +31,29 @@ export interface Scope {
|
|
|
31
31
|
|
|
32
32
|
/** connection api.settings 的 RPC 面(本插件用到的三个方法)。 */
|
|
33
33
|
export interface SettingsApi {
|
|
34
|
-
describe(payload?: Record<string, never>): Promise<{
|
|
35
|
-
|
|
36
|
-
|
|
34
|
+
describe(payload?: Record<string, never>): Promise<{
|
|
35
|
+
result: {
|
|
36
|
+
ok: boolean
|
|
37
|
+
value?: {
|
|
38
|
+
writable: boolean
|
|
39
|
+
namespaces: Array<{
|
|
40
|
+
ns: string
|
|
41
|
+
value: unknown
|
|
42
|
+
revision: number
|
|
43
|
+
secrets: Array<{ path: string[]; set: boolean }>
|
|
44
|
+
}>
|
|
45
|
+
}
|
|
46
|
+
error?: { message?: string }
|
|
47
|
+
}
|
|
48
|
+
}>
|
|
49
|
+
update(request: {
|
|
50
|
+
ns: string
|
|
51
|
+
patch: Record<string, unknown>
|
|
52
|
+
expectedRevision?: number
|
|
53
|
+
}): Promise<{ result: { ok: boolean; error?: { message?: string } } }>
|
|
54
|
+
replace(request: {
|
|
55
|
+
ns: string
|
|
56
|
+
section: Record<string, unknown>
|
|
57
|
+
expectedRevision?: number
|
|
58
|
+
}): Promise<{ result: { ok: boolean; error?: { message?: string } } }>
|
|
37
59
|
}
|