@dsh-ft/dsh-fft-settings 0.0.0-stage → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +395 -2
- package/cordis.patch.yml +18 -0
- package/docs/compatibility-dsh-0.1.6-alpha.1.md +58 -0
- package/docs/extension-service-design.md +351 -0
- package/docs/extension-service-sequences.html +545 -0
- package/docs/ftshare-settings-er.html +139 -0
- package/docs/ftshare-settings-er.svg +113 -0
- package/docs/ftshare-settings-results-er.html +148 -0
- package/docs/ftshare-settings-results-er.svg +122 -0
- package/docs/kline-integration.md +258 -0
- package/lib/agent.d.ts +24 -0
- package/lib/agent.js +26 -0
- package/lib/brand.d.ts +53 -0
- package/lib/brand.js +50 -0
- package/lib/client.d.ts +8 -0
- package/lib/client.js +806 -0
- package/lib/client.js.map +1 -0
- package/lib/contract.d.ts +14 -0
- package/lib/contract.js +14 -0
- package/lib/core/client-transport.d.ts +15 -0
- package/lib/core/client-transport.js +31 -0
- package/lib/core/contract.d.ts +62 -0
- package/lib/core/contract.js +28 -0
- package/lib/core/module-registry.d.ts +54 -0
- package/lib/core/module-registry.js +68 -0
- package/lib/core/rpc-core.d.ts +69 -0
- package/lib/core/rpc-core.js +88 -0
- package/lib/core/shell.d.ts +59 -0
- package/lib/core/shell.js +28 -0
- package/lib/core/styles.d.ts +31 -0
- package/lib/core/styles.js +43 -0
- package/lib/core/transport.d.ts +19 -0
- package/lib/core/transport.js +80 -0
- package/lib/default-preset.d.ts +52 -0
- package/lib/default-preset.js +121 -0
- package/lib/features/api-key/card.d.ts +24 -0
- package/lib/features/api-key/card.js +39 -0
- package/lib/features/api-key/client-api.d.ts +4 -0
- package/lib/features/api-key/client-api.js +11 -0
- package/lib/features/api-key/contract.d.ts +69 -0
- package/lib/features/api-key/contract.js +24 -0
- package/lib/features/api-key/host.d.ts +17 -0
- package/lib/features/api-key/host.js +32 -0
- package/lib/features/api-key/index.d.ts +13 -0
- package/lib/features/api-key/index.js +12 -0
- package/lib/features/api-key/logging.d.ts +15 -0
- package/lib/features/api-key/logging.js +1 -0
- package/lib/features/api-key/model.d.ts +24 -0
- package/lib/features/api-key/model.js +105 -0
- package/lib/features/api-key/probe.d.ts +17 -0
- package/lib/features/api-key/probe.js +148 -0
- package/lib/features/api-key/rpc.d.ts +4 -0
- package/lib/features/api-key/rpc.js +17 -0
- package/lib/features/api-key/service.d.ts +21 -0
- package/lib/features/api-key/service.js +181 -0
- package/lib/features/api-key/styles.d.ts +9 -0
- package/lib/features/api-key/styles.js +24 -0
- package/lib/features/logs/archive.d.ts +87 -0
- package/lib/features/logs/archive.js +156 -0
- package/lib/features/logs/card.d.ts +22 -0
- package/lib/features/logs/card.js +107 -0
- package/lib/features/logs/client-api.d.ts +4 -0
- package/lib/features/logs/client-api.js +11 -0
- package/lib/features/logs/collector.d.ts +91 -0
- package/lib/features/logs/collector.js +258 -0
- package/lib/features/logs/config.d.ts +70 -0
- package/lib/features/logs/config.js +97 -0
- package/lib/features/logs/contract.d.ts +130 -0
- package/lib/features/logs/contract.js +67 -0
- package/lib/features/logs/export.d.ts +92 -0
- package/lib/features/logs/export.js +212 -0
- package/lib/features/logs/format.d.ts +42 -0
- package/lib/features/logs/format.js +172 -0
- package/lib/features/logs/home.d.ts +18 -0
- package/lib/features/logs/home.js +39 -0
- package/lib/features/logs/host.d.ts +19 -0
- package/lib/features/logs/host.js +139 -0
- package/lib/features/logs/index.d.ts +12 -0
- package/lib/features/logs/index.js +12 -0
- package/lib/features/logs/rpc.d.ts +6 -0
- package/lib/features/logs/rpc.js +27 -0
- package/lib/features/logs/styles.d.ts +9 -0
- package/lib/features/logs/styles.js +13 -0
- package/lib/guidance.d.ts +41 -0
- package/lib/guidance.js +88 -0
- package/lib/index.d.ts +67 -0
- package/lib/index.js +50 -0
- package/lib/rpc.d.ts +10 -0
- package/lib/rpc.js +10 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +108 -4
- package/src/agent.ts +31 -0
- package/src/brand.ts +79 -0
- package/src/client.tsx +21 -0
- package/src/contract.ts +14 -0
- package/src/core/client-transport.ts +37 -0
- package/src/core/contract.ts +57 -0
- package/src/core/module-registry.ts +102 -0
- package/src/core/rpc-core.ts +107 -0
- package/src/core/shell.tsx +81 -0
- package/src/core/styles.ts +43 -0
- package/src/core/transport.ts +82 -0
- package/src/default-preset.ts +151 -0
- package/src/features/api-key/card.tsx +77 -0
- package/src/features/api-key/client-api.ts +12 -0
- package/src/features/api-key/contract.ts +63 -0
- package/src/features/api-key/host.ts +36 -0
- package/src/features/api-key/index.ts +13 -0
- package/src/features/api-key/logging.ts +15 -0
- package/src/features/api-key/model.ts +63 -0
- package/src/features/api-key/probe.ts +125 -0
- package/src/features/api-key/rpc.ts +18 -0
- package/src/features/api-key/service.ts +157 -0
- package/src/features/api-key/styles.ts +24 -0
- package/src/features/logs/archive.ts +230 -0
- package/src/features/logs/card.tsx +169 -0
- package/src/features/logs/client-api.ts +20 -0
- package/src/features/logs/collector.ts +313 -0
- package/src/features/logs/config.ts +115 -0
- package/src/features/logs/contract.ts +149 -0
- package/src/features/logs/export.ts +274 -0
- package/src/features/logs/format.ts +188 -0
- package/src/features/logs/home.ts +43 -0
- package/src/features/logs/host.ts +153 -0
- package/src/features/logs/index.ts +12 -0
- package/src/features/logs/rpc.ts +29 -0
- package/src/features/logs/styles.ts +13 -0
- package/src/guidance.ts +114 -0
- package/src/index.ts +59 -0
- package/src/rpc.ts +10 -0
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 导出物组装(Host 侧):读主日志与配置的 sources,拼成单个 `.log` 文本。
|
|
3
|
+
*
|
|
4
|
+
* 体积上限按规格 §9:单来源 2 MiB、合计 8 MiB、目录最多 20 个 `*.log`;
|
|
5
|
+
* 超限按“保留最新”截断,并在 header 标注 `truncated=true`。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { open, readdir, readFile, stat } from 'node:fs/promises'
|
|
9
|
+
import { basename, join } from 'node:path'
|
|
10
|
+
import {
|
|
11
|
+
LOGS_EXPORT_MAX_BYTES,
|
|
12
|
+
LOGS_FILE_SUFFIX,
|
|
13
|
+
LOGS_MAX_SOURCE_FILES,
|
|
14
|
+
LOGS_SECTION_RULE,
|
|
15
|
+
LOGS_SOURCE_MAX_BYTES,
|
|
16
|
+
type LogsSourceSummary,
|
|
17
|
+
type LogsSourceState,
|
|
18
|
+
} from './contract.js'
|
|
19
|
+
import { byteLength, formatDownloadFileName, formatLogTimestamp } from './format.js'
|
|
20
|
+
import { resolveSourcePath } from './home.js'
|
|
21
|
+
|
|
22
|
+
const TIMESTAMP_PREFIX = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}[+-]\d{2}:\d{2})\s/
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* 单文件导出选项:来源定义(与诊断包共用的 {@link LoadExportOptions})加上 header 元信息。
|
|
26
|
+
*/
|
|
27
|
+
export interface ExportOptions extends LoadExportOptions {
|
|
28
|
+
readonly homeDisplay: string
|
|
29
|
+
readonly pluginVersion: string
|
|
30
|
+
readonly now?: () => number
|
|
31
|
+
readonly environment?: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface ExportResult {
|
|
35
|
+
readonly text: string
|
|
36
|
+
readonly fileName: string
|
|
37
|
+
readonly bytes: number
|
|
38
|
+
readonly truncated: boolean
|
|
39
|
+
readonly summaries: readonly LogsSourceSummary[]
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** 读来源只用到这两项;诊断包复用同一加载过程。 */
|
|
43
|
+
export interface LoadExportOptions {
|
|
44
|
+
/** 主日志文件(`current.log`)的绝对路径。 */
|
|
45
|
+
readonly filePath: string
|
|
46
|
+
/** 配置里的额外来源(原样,可能带 `~`)。 */
|
|
47
|
+
readonly sources: readonly string[]
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** 一次封装后的来源内容;`content` 已按上限截断。 */
|
|
51
|
+
export interface LoadedSource {
|
|
52
|
+
readonly summary: LogsSourceSummary
|
|
53
|
+
readonly content: string
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** 加载结果:来源清单加正文,以及整体是否被截断。 */
|
|
57
|
+
export interface LoadedExport {
|
|
58
|
+
readonly loaded: readonly LoadedSource[]
|
|
59
|
+
readonly truncated: boolean
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** 渲染导出文本所需的元信息(时间戳由调用方给定,保证同一份导出只有一个生成时间)。 */
|
|
63
|
+
export interface RenderExportOptions {
|
|
64
|
+
readonly loaded: readonly LoadedSource[]
|
|
65
|
+
readonly truncated: boolean
|
|
66
|
+
readonly generatedAt: number
|
|
67
|
+
readonly homeDisplay: string
|
|
68
|
+
readonly pluginVersion: string
|
|
69
|
+
readonly environment?: string
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** 尾读一个文件,最多 `maxBytes` 字节;超出时丢掉首个不完整行。 */
|
|
73
|
+
export async function readTail(path: string, maxBytes: number): Promise<{ text: string; truncated: boolean; size: number }> {
|
|
74
|
+
const size = (await stat(path)).size
|
|
75
|
+
if (size <= maxBytes) return { text: await readFile(path, 'utf8'), truncated: false, size }
|
|
76
|
+
const handle = await open(path, 'r')
|
|
77
|
+
try {
|
|
78
|
+
const buffer = new Uint8Array(maxBytes)
|
|
79
|
+
await handle.read(buffer, 0, maxBytes, size - maxBytes)
|
|
80
|
+
const text = new TextDecoder('utf-8', { fatal: false }).decode(buffer)
|
|
81
|
+
const firstBreak = text.indexOf('\n')
|
|
82
|
+
return { text: firstBreak === -1 ? text : text.slice(firstBreak + 1), truncated: true, size }
|
|
83
|
+
} finally {
|
|
84
|
+
await handle.close()
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** 读文件尾部最近的 N 行;同时受字节上限约束。 */
|
|
89
|
+
export async function readTailLines(path: string, lines: number, maxBytes: number): Promise<{ lines: string[]; bytes: number; truncated: boolean; fileBytes: number }> {
|
|
90
|
+
const { text, truncated, size } = await readTail(path, maxBytes)
|
|
91
|
+
const all = text.split('\n').filter(line => line.length > 0)
|
|
92
|
+
const kept = all.slice(Math.max(0, all.length - lines))
|
|
93
|
+
return {
|
|
94
|
+
lines: kept,
|
|
95
|
+
bytes: kept.reduce((total, line) => total + byteLength(line) + 1, 0),
|
|
96
|
+
truncated: truncated || kept.length < all.length,
|
|
97
|
+
fileBytes: size,
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** 统计正文的行数与时间范围(时间戳取行首北京时间)。 */
|
|
102
|
+
export function summarizeContent(text: string): { lines: number; firstAt?: string; lastAt?: string } {
|
|
103
|
+
let lines = 0
|
|
104
|
+
let firstAt: string | undefined
|
|
105
|
+
let lastAt: string | undefined
|
|
106
|
+
for (const line of text.split('\n')) {
|
|
107
|
+
if (line.length === 0) continue
|
|
108
|
+
lines += 1
|
|
109
|
+
const match = TIMESTAMP_PREFIX.exec(line)
|
|
110
|
+
if (match === null) continue
|
|
111
|
+
firstAt ??= match[1]
|
|
112
|
+
lastAt = match[1]
|
|
113
|
+
}
|
|
114
|
+
return { lines, firstAt, lastAt }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** 目录展开成 `*.log` 文件(按修改时间倒序,最多 20 个)。 */
|
|
118
|
+
async function expandDirectory(directory: string): Promise<string[]> {
|
|
119
|
+
const names = (await readdir(directory)).filter(name => name.endsWith(LOGS_FILE_SUFFIX))
|
|
120
|
+
const stamped = await Promise.all(names.map(async name => {
|
|
121
|
+
const path = join(directory, name)
|
|
122
|
+
try {
|
|
123
|
+
return { path, mtime: (await stat(path)).mtimeMs }
|
|
124
|
+
} catch {
|
|
125
|
+
return { path, mtime: 0 }
|
|
126
|
+
}
|
|
127
|
+
}))
|
|
128
|
+
stamped.sort((left, right) => right.mtime - left.mtime)
|
|
129
|
+
return stamped.slice(0, LOGS_MAX_SOURCE_FILES).map(item => item.path)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** 判断配置的来源当前形态;目录会被展开成文件清单(最多 20 个)。 */
|
|
133
|
+
export async function resolveSource(rawPath: string): Promise<{ state: LogsSourceState; files: readonly string[] }> {
|
|
134
|
+
const path = resolveSourcePath(rawPath)
|
|
135
|
+
try {
|
|
136
|
+
const info = await stat(path)
|
|
137
|
+
if (info.isDirectory()) {
|
|
138
|
+
const files = await expandDirectory(path)
|
|
139
|
+
return { state: { path, kind: 'directory', files: files.length }, files }
|
|
140
|
+
}
|
|
141
|
+
return { state: { path, kind: 'file', bytes: info.size }, files: [path] }
|
|
142
|
+
} catch {
|
|
143
|
+
return { state: { path, kind: 'missing' }, files: [] }
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** 各来源的存在性快照,供设置页展示。 */
|
|
148
|
+
export async function describeSources(sources: readonly string[]): Promise<LogsSourceState[]> {
|
|
149
|
+
return await Promise.all(sources.map(async source => (await resolveSource(source)).state))
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** 导出物 header 与 manifest 共用的四条覆盖盲区(规格 §5)。 */
|
|
153
|
+
export const LOG_BLIND_SPOTS = [
|
|
154
|
+
'采集器挂载前的极短窗口(毫秒级,只有模块加载期日志):只回填了 cordis 缓冲里残留的 error/info,warn/debug 不可恢复。',
|
|
155
|
+
'没有桌面壳 log bridge 的环境(纯 dsh web):MCP/Python 子进程的 stderr 只在启动它的终端里,不含在导出物中。',
|
|
156
|
+
'渲染进程(浏览器)日志:DSH 未提供回传通道,不含。',
|
|
157
|
+
'不走 logger 的失败:直接 console.*、只通过事件或只体现在返回值的失败,logger 通道看不到。',
|
|
158
|
+
]
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* 按规格 §9 的封顶语义读取主日志与额外来源:单来源 2 MiB、合计 8 MiB、目录最多 20 个 `*.log`。
|
|
162
|
+
*
|
|
163
|
+
* 单文件导出与诊断包共用这一步,两者的 `truncated` 语义因此完全一致。
|
|
164
|
+
* @param options - 主日志路径与配置的额外来源。
|
|
165
|
+
* @returns 每个来源的统计与正文,以及整体是否截断。
|
|
166
|
+
*/
|
|
167
|
+
export async function loadLogExport(options: LoadExportOptions): Promise<LoadedExport> {
|
|
168
|
+
const primaryLabel = 'FT 插件日志(本进程 cordis)'
|
|
169
|
+
const loaded: LoadedSource[] = []
|
|
170
|
+
let truncated = false
|
|
171
|
+
let remaining = LOGS_EXPORT_MAX_BYTES
|
|
172
|
+
|
|
173
|
+
const push = async (label: string, path: string, missing = false): Promise<void> => {
|
|
174
|
+
if (missing) {
|
|
175
|
+
loaded.push({
|
|
176
|
+
summary: { label, path, lines: 0, bytes: 0, truncated: false, missing: true },
|
|
177
|
+
content: '',
|
|
178
|
+
})
|
|
179
|
+
return
|
|
180
|
+
}
|
|
181
|
+
let tail: { text: string; truncated: boolean }
|
|
182
|
+
try {
|
|
183
|
+
tail = await readTail(path, Math.min(LOGS_SOURCE_MAX_BYTES, Math.max(0, remaining)))
|
|
184
|
+
} catch {
|
|
185
|
+
// 文件不存在(例如本次进程还没写过日志)不是错误:按空来源处理。
|
|
186
|
+
loaded.push({ summary: { label, path, lines: 0, bytes: 0, truncated: false, missing: true }, content: '' })
|
|
187
|
+
return
|
|
188
|
+
}
|
|
189
|
+
const { text, truncated: cut } = tail
|
|
190
|
+
const size = byteLength(text)
|
|
191
|
+
const { lines, firstAt, lastAt } = summarizeContent(text)
|
|
192
|
+
if (cut) truncated = true
|
|
193
|
+
remaining -= size
|
|
194
|
+
loaded.push({
|
|
195
|
+
summary: { label, path, lines, bytes: size, firstAt, lastAt, truncated: cut },
|
|
196
|
+
content: text,
|
|
197
|
+
})
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
await push(primaryLabel, options.filePath)
|
|
201
|
+
for (const source of options.sources) {
|
|
202
|
+
const { state, files } = await resolveSource(source)
|
|
203
|
+
if (remaining <= 0) {
|
|
204
|
+
truncated = true
|
|
205
|
+
loaded.push({
|
|
206
|
+
summary: { label: `额外来源(未纳入)`, path: state.path, lines: 0, bytes: 0, truncated: true },
|
|
207
|
+
content: '',
|
|
208
|
+
})
|
|
209
|
+
continue
|
|
210
|
+
}
|
|
211
|
+
if (state.kind === 'missing') {
|
|
212
|
+
await push('额外来源(文件不存在)', state.path, true)
|
|
213
|
+
continue
|
|
214
|
+
}
|
|
215
|
+
if (state.kind === 'directory' && files.length === 0) {
|
|
216
|
+
await push('额外来源(目录下没有 *.log)', state.path, true)
|
|
217
|
+
continue
|
|
218
|
+
}
|
|
219
|
+
for (const file of files) await push(`额外来源${files.length > 1 ? `(目录 ${basename(state.path)})` : ''}`, file)
|
|
220
|
+
}
|
|
221
|
+
return { loaded, truncated }
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** 拼装导出文本:header(来源清单、条数、时间范围、truncated、盲区)+ 来源段落。 */
|
|
225
|
+
export function renderLogExport(options: RenderExportOptions): { text: string; bytes: number } {
|
|
226
|
+
const header = [
|
|
227
|
+
'# ===== FT 运行日志 =====',
|
|
228
|
+
`# 生成时间:${formatLogTimestamp(options.generatedAt)}`,
|
|
229
|
+
`# 插件版本:@dsh-ft/dsh-fft-settings ${options.pluginVersion}`,
|
|
230
|
+
`# 运行时:${options.environment ?? process.version}`,
|
|
231
|
+
`# 日志目录:${options.homeDisplay}/logs/ft`,
|
|
232
|
+
`# 导出上限:单来源 ${LOGS_SOURCE_MAX_BYTES / 1024 / 1024} MiB、合计 ${LOGS_EXPORT_MAX_BYTES / 1024 / 1024} MiB、目录最多 ${LOGS_MAX_SOURCE_FILES} 个 *${LOGS_FILE_SUFFIX}`,
|
|
233
|
+
`# truncated=${options.truncated}`,
|
|
234
|
+
'# 来源:',
|
|
235
|
+
...options.loaded.map((item, index) => {
|
|
236
|
+
const s = item.summary
|
|
237
|
+
if (s.missing === true) return `# ${index + 1}. ${s.label} ${s.path}(缺失)`
|
|
238
|
+
const range = s.firstAt === undefined ? '时间范围=空' : `时间范围=${s.firstAt} ~ ${s.lastAt}`
|
|
239
|
+
return `# ${index + 1}. ${s.label} ${s.path} 条数=${s.lines} 字节=${s.bytes} ${range}${s.truncated ? ' truncated=true' : ''}`
|
|
240
|
+
}),
|
|
241
|
+
'# 覆盖盲区:',
|
|
242
|
+
LOG_BLIND_SPOTS.map(line => `# - ${line}`).join('\n'),
|
|
243
|
+
'',
|
|
244
|
+
].join('\n')
|
|
245
|
+
|
|
246
|
+
const body = options.loaded
|
|
247
|
+
.filter(item => item.summary.missing !== true)
|
|
248
|
+
.map(item => `${LOGS_SECTION_RULE} ${item.summary.label}: ${item.summary.path} ${LOGS_SECTION_RULE}\n${item.content.replace(/\n?$/, '\n')}`)
|
|
249
|
+
.join('\n')
|
|
250
|
+
|
|
251
|
+
const text = `${header}${body}`
|
|
252
|
+
return { text, bytes: byteLength(text) }
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** 单文件导出:加载 + 渲染,附文件名与各来源统计。 */
|
|
256
|
+
export async function buildLogExport(options: ExportOptions): Promise<ExportResult> {
|
|
257
|
+
const generatedAt = (options.now ?? Date.now)()
|
|
258
|
+
const { loaded, truncated } = await loadLogExport(options)
|
|
259
|
+
const { text, bytes } = renderLogExport({
|
|
260
|
+
loaded,
|
|
261
|
+
truncated,
|
|
262
|
+
generatedAt,
|
|
263
|
+
homeDisplay: options.homeDisplay,
|
|
264
|
+
pluginVersion: options.pluginVersion,
|
|
265
|
+
environment: options.environment,
|
|
266
|
+
})
|
|
267
|
+
return {
|
|
268
|
+
text,
|
|
269
|
+
fileName: formatDownloadFileName(generatedAt),
|
|
270
|
+
bytes,
|
|
271
|
+
truncated,
|
|
272
|
+
summaries: loaded.map(item => item.summary),
|
|
273
|
+
}
|
|
274
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 日志行格式化与脱敏(纯函数,无 Node 依赖,浏览器半边也可导入)。
|
|
3
|
+
*
|
|
4
|
+
* 行格式(规格 §8):
|
|
5
|
+
* `<ISO8601+08:00> <LEVEL> <plugin-name> <message> <k=v …>`,单行上限 8 KiB。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
LOGS_EXPORT_FILE_PREFIX,
|
|
10
|
+
LOGS_LINE_MAX_BYTES,
|
|
11
|
+
LOGS_MAX_FIELD_DEPTH,
|
|
12
|
+
LOGS_MAX_FIELD_LENGTH,
|
|
13
|
+
LOGS_ROTATED_PREFIX,
|
|
14
|
+
LOGS_TRUNCATED_MARK,
|
|
15
|
+
type LogLevelName,
|
|
16
|
+
} from './contract.js'
|
|
17
|
+
|
|
18
|
+
const SHANGHAI = new Intl.DateTimeFormat('en-CA', {
|
|
19
|
+
timeZone: 'Asia/Shanghai',
|
|
20
|
+
year: 'numeric',
|
|
21
|
+
month: '2-digit',
|
|
22
|
+
day: '2-digit',
|
|
23
|
+
hour: '2-digit',
|
|
24
|
+
minute: '2-digit',
|
|
25
|
+
second: '2-digit',
|
|
26
|
+
hourCycle: 'h23',
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
const encoder = new TextEncoder()
|
|
30
|
+
const decoder = new TextDecoder('utf-8', { fatal: false })
|
|
31
|
+
|
|
32
|
+
/** 规格固定写 +08:00,不跟随宿主机时区。 */
|
|
33
|
+
function shanghaiParts(ts: number): Record<string, string> {
|
|
34
|
+
const parts: Record<string, string> = {}
|
|
35
|
+
for (const { type, value } of SHANGHAI.formatToParts(new Date(ts))) parts[type] = value
|
|
36
|
+
return parts
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function millis(ts: number): string {
|
|
40
|
+
return String(new Date(ts).getMilliseconds()).padStart(3, '0')
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** 行首时间戳:`2026-10-12T15:04:05.123+08:00`。 */
|
|
44
|
+
export function formatLogTimestamp(ts: number): string {
|
|
45
|
+
const p = shanghaiParts(ts)
|
|
46
|
+
return `${p.year}-${p.month}-${p.day}T${p.hour}:${p.minute}:${p.second}.${millis(ts)}+08:00`
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** 下载文件名时间戳:`yyyyMMdd-HHmmss`。 */
|
|
50
|
+
export function formatCompactStamp(ts: number): string {
|
|
51
|
+
const p = shanghaiParts(ts)
|
|
52
|
+
return `${p.year}${p.month}${p.day}-${p.hour}${p.minute}${p.second}`
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** 轮转文件名时间戳:`2026-10-12T15-04-05-123+0800`(去掉文件系统不友好的 `:`/`.`)。 */
|
|
56
|
+
export function formatRotationStamp(ts: number): string {
|
|
57
|
+
const p = shanghaiParts(ts)
|
|
58
|
+
return `${p.year}-${p.month}-${p.day}T${p.hour}-${p.minute}-${p.second}-${millis(ts)}+0800`
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** 导出物文件名:`ft-runtime-<yyyyMMdd-HHmmss>.log`。 */
|
|
62
|
+
export function formatDownloadFileName(ts: number): string {
|
|
63
|
+
return `${LOGS_EXPORT_FILE_PREFIX}${formatCompactStamp(ts)}.log`
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** 诊断包文件名:`ft-runtime-<yyyyMMdd-HHmmss>.zip`(与单文件导出同一时间戳口径)。 */
|
|
67
|
+
export function formatArchiveFileName(ts: number): string {
|
|
68
|
+
return `${LOGS_EXPORT_FILE_PREFIX}${formatCompactStamp(ts)}.zip`
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** 轮转文件名:`ft-<ISO时间戳>.log`(与当前文件名 `current.log` 不冲突)。 */
|
|
72
|
+
export function formatRotationFileName(ts: number): string {
|
|
73
|
+
return `${LOGS_ROTATED_PREFIX}${formatRotationStamp(ts)}.log`
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** 文本 UTF-8 字节数(不依赖 Buffer,浏览器可用)。 */
|
|
77
|
+
export function byteLength(text: string): number {
|
|
78
|
+
return encoder.encode(text).length
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** 按 UTF-8 字节截断单行,并在上限内补 `...[已截断]`。 */
|
|
82
|
+
export function truncateLine(line: string, maxBytes = LOGS_LINE_MAX_BYTES): string {
|
|
83
|
+
if (byteLength(line) <= maxBytes) return line
|
|
84
|
+
const budget = Math.max(0, maxBytes - byteLength(LOGS_TRUNCATED_MARK))
|
|
85
|
+
let text = decoder.decode(encoder.encode(line).subarray(0, budget))
|
|
86
|
+
// 截断点可能落在多字节字符中间,去掉 UTF-8 替换字符。
|
|
87
|
+
while (text.endsWith('\uFFFD')) text = text.slice(0, -1)
|
|
88
|
+
return text + LOGS_TRUNCATED_MARK
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** 把可能的多行文本压成单行(换行转义),保持「一条记录一行」。 */
|
|
92
|
+
export function singleLine(text: string): string {
|
|
93
|
+
return text.replace(/\r\n|\r|\n/g, '\\n')
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function clip(value: string): string {
|
|
97
|
+
return value.length > LOGS_MAX_FIELD_LENGTH ? `${value.slice(0, LOGS_MAX_FIELD_LENGTH)}…` : value
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** 把任意值渲染成短、稳定、可读的文本;键按字典序排列以便节流合并可比。 */
|
|
101
|
+
export function renderValue(value: unknown, depth = 0): string {
|
|
102
|
+
if (value === null) return 'null'
|
|
103
|
+
if (value === undefined) return 'undefined'
|
|
104
|
+
switch (typeof value) {
|
|
105
|
+
case 'string': return clip(value)
|
|
106
|
+
case 'number':
|
|
107
|
+
case 'boolean':
|
|
108
|
+
case 'bigint': return String(value)
|
|
109
|
+
case 'symbol': return clip(value.toString())
|
|
110
|
+
case 'function': return `[Function ${value.name || 'anonymous'}]`
|
|
111
|
+
default: break
|
|
112
|
+
}
|
|
113
|
+
// 到这里只剩对象;Error 取 message,避免整段堆栈(堆栈由 Logger 侧的字符串参数提供)。
|
|
114
|
+
if (value instanceof Error) return clip(value.message || value.name)
|
|
115
|
+
if (depth >= LOGS_MAX_FIELD_DEPTH) return '[…]'
|
|
116
|
+
if (Array.isArray(value)) return clip(value.map(item => renderValue(item, depth + 1)).join(','))
|
|
117
|
+
const entries: string[] = []
|
|
118
|
+
for (const key of Object.keys(value as object).sort()) {
|
|
119
|
+
entries.push(`${key}=${renderValue((value as Record<string, unknown>)[key], depth + 1)}`)
|
|
120
|
+
}
|
|
121
|
+
return clip(entries.join(' '))
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const PLACEHOLDER = /%([a-zA-Z%])/g
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* 渲染一条记录的正文,语义对齐 cordis `Logger.format`:
|
|
128
|
+
* 首参是 Error 时取 message,首参非字符串时当作字段对象;`%s/%d/%i/%f/%o/%O/%c/%%` 生效,
|
|
129
|
+
* 对象参数统一渲染为 `k=v`(而不是 JSON),且结果恒为单行。
|
|
130
|
+
*/
|
|
131
|
+
export function formatLogMessage(args: readonly unknown[]): string {
|
|
132
|
+
const rest = [...args]
|
|
133
|
+
if (rest[0] instanceof Error) {
|
|
134
|
+
rest[0] = rest[0].message || rest[0].name
|
|
135
|
+
rest.unshift('%s')
|
|
136
|
+
} else if (typeof rest[0] !== 'string') {
|
|
137
|
+
rest.unshift('%o')
|
|
138
|
+
}
|
|
139
|
+
let format = String(rest.shift())
|
|
140
|
+
format = format.replace(PLACEHOLDER, (match, char: string) => {
|
|
141
|
+
if (match === '%%') return '%'
|
|
142
|
+
if (!'sdifoOcC'.includes(char)) return match
|
|
143
|
+
const value = rest.shift()
|
|
144
|
+
switch (char) {
|
|
145
|
+
case 's': case 'c': return String(value ?? '')
|
|
146
|
+
case 'C': return String(value ?? '')
|
|
147
|
+
case 'd': case 'i': return String(Math.trunc(Number(value)))
|
|
148
|
+
case 'f': return String(Number(value))
|
|
149
|
+
default: return renderValue(value)
|
|
150
|
+
}
|
|
151
|
+
})
|
|
152
|
+
for (const arg of rest) {
|
|
153
|
+
format += typeof arg === 'object' && arg !== null ? ` ${renderValue(arg)}` : ` ${String(arg)}`
|
|
154
|
+
}
|
|
155
|
+
return singleLine(format)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
interface RedactionRule { readonly pattern: RegExp; readonly replacement: string }
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* 脱敏规则:`Bearer`、`Authorization:`、`Cookie:`、`sk-` 形态、长 hex、`*_KEY=*` 形态。
|
|
162
|
+
* 命中即整段打码,宁可多打不可漏。
|
|
163
|
+
*/
|
|
164
|
+
const REDACTION_RULES: readonly RedactionRule[] = [
|
|
165
|
+
{ pattern: /(bearer\s+)[A-Za-z0-9._~+/=-]{6,}/gi, replacement: '$1***' },
|
|
166
|
+
// 只吃掉「可选 scheme + 凭据」这一段,后面的结构化字段仍保留可读性。
|
|
167
|
+
{ pattern: /((?:proxy-)?authorization\s*[:=]\s*)(?:\S+\s+)?\S+/gi, replacement: '$1***' },
|
|
168
|
+
{ pattern: /((?:set-)?cookie\s*[:=]\s*)[^\n]*/gi, replacement: '$1***' },
|
|
169
|
+
{ pattern: /sk-[A-Za-z0-9_-]{6,}/g, replacement: 'sk-***' },
|
|
170
|
+
{ pattern: /\b[0-9a-fA-F]{32,}\b/g, replacement: '***' },
|
|
171
|
+
{ pattern: /([A-Za-z0-9_-]*(?:KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL)[A-Za-z0-9_-]*\s*[:=]\s*)[^\s,;'"]+/gi, replacement: '$1***' },
|
|
172
|
+
]
|
|
173
|
+
|
|
174
|
+
/** 当前 FTShare Key 等已知密钥的精确值替换(长度过短的值不参与,避免误伤普通文本)。 */
|
|
175
|
+
export function redactSecrets(text: string, secrets: readonly string[] = []): string {
|
|
176
|
+
let output = text
|
|
177
|
+
for (const secret of secrets) {
|
|
178
|
+
if (secret.length < 6) continue
|
|
179
|
+
output = output.split(secret).join('***')
|
|
180
|
+
}
|
|
181
|
+
for (const rule of REDACTION_RULES) output = output.replace(rule.pattern, rule.replacement)
|
|
182
|
+
return output
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** 组装一行:`<时间> <LEVEL> <name> <正文>`(`WARN` 之类按 5 字符对齐)。 */
|
|
186
|
+
export function formatLogLine(record: { ts: number; type: LogLevelName; name: string; args: readonly unknown[] }): string {
|
|
187
|
+
return `${formatLogTimestamp(record.ts)} ${record.type.toUpperCase().padEnd(5)} ${record.name} ${formatLogMessage(record.args)}`
|
|
188
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `$DSH_HOME` 解析(Host 侧)。
|
|
3
|
+
*
|
|
4
|
+
* 与 `@deepseek-ai/dsh-home-paths` 的 `resolveDshHome` 语义一致:显式配置 → `$DSH_HOME` →
|
|
5
|
+
* `~/.dsh`。此处自己实现,避免为一个纯路径函数新增运行时依赖与锁文件改动。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { homedir } from 'node:os'
|
|
9
|
+
import { isAbsolute, join, normalize, resolve } from 'node:path'
|
|
10
|
+
import { LOGS_DIR_SEGMENTS } from './contract.js'
|
|
11
|
+
|
|
12
|
+
export const DSH_HOME_ENV = 'DSH_HOME'
|
|
13
|
+
export const DSH_HOME_DIR_NAME = '.dsh'
|
|
14
|
+
|
|
15
|
+
/** 解析 DSH 根目录;空白 `$DSH_HOME` 视为未设置,`~` 前缀按官方语义展开。 */
|
|
16
|
+
export function resolveDshHome(env: Readonly<Record<string, string | undefined>> = process.env): string {
|
|
17
|
+
const configured = env[DSH_HOME_ENV]?.trim()
|
|
18
|
+
if (configured) return resolve(expandHomePath(configured))
|
|
19
|
+
return join(homedir(), DSH_HOME_DIR_NAME)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** 面向用户的展示形式:默认目录写 `~/.dsh`,自定义目录写 `$DSH_HOME`。 */
|
|
23
|
+
export function displayDshHome(home: string): string {
|
|
24
|
+
return normalize(home) === normalize(join(homedir(), DSH_HOME_DIR_NAME)) ? '~/.dsh' : '$DSH_HOME'
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** 展开 `~`、`~/`、`~\` 前缀;不做其他 shell 展开。 */
|
|
28
|
+
export function expandHomePath(value: string, home = homedir()): string {
|
|
29
|
+
if (value === '~') return home
|
|
30
|
+
if (value.startsWith('~/') || value.startsWith('~\\')) return join(home, value.slice(2))
|
|
31
|
+
return value
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** 解析配置里的来源路径为绝对路径(相对路径按当前工作目录)。 */
|
|
35
|
+
export function resolveSourcePath(value: string, home = homedir()): string {
|
|
36
|
+
const expanded = expandHomePath(value, home)
|
|
37
|
+
return isAbsolute(expanded) ? normalize(expanded) : resolve(expanded)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** 日志目录:`$DSH_HOME/logs/ft`。 */
|
|
41
|
+
export function logsDirectory(home = resolveDshHome()): string {
|
|
42
|
+
return join(home, ...LOGS_DIR_SEGMENTS)
|
|
43
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `logs` 命名空间 Host 半边:常驻采集器 + 导出器。
|
|
3
|
+
*
|
|
4
|
+
* 采集器在 `create()` 里**无条件启动**(`apply()` 期注册即执行,与设置页是否渲染无关);
|
|
5
|
+
* exporter 由 cordis 绑定当前 fiber,另用 `ctx.effect` 明确关闭文件句柄与队列。
|
|
6
|
+
*
|
|
7
|
+
* 工厂化:dev 的接缝里 `create(ctx)` 不再收配置,模块由 `apply()` 用一个闭包把
|
|
8
|
+
* 已解析的 `logs` 配置带进来(`createLogsModule(logsConfig)`)。这样采集器仍然在
|
|
9
|
+
* 注册期、而不是等到设置页打开才启动。
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { credentialRef } from '@deepseek-ai/dsh-credentials'
|
|
13
|
+
import { FTSHARE_API_KEY_REF } from '../api-key/contract.js'
|
|
14
|
+
import { defineSettingsModule, type SettingsModule } from '../../core/module-registry.js'
|
|
15
|
+
import { createLogsArchiveRoute, type LogsArchiveRouteRegistry } from './archive.js'
|
|
16
|
+
import { FtLogCollector } from './collector.js'
|
|
17
|
+
import {
|
|
18
|
+
LOGS_DEFAULT_PREVIEW_LINES,
|
|
19
|
+
LOGS_MAX_PREVIEW_LINES,
|
|
20
|
+
LOGS_PREVIEW_MAX_BYTES,
|
|
21
|
+
type LogsPreview,
|
|
22
|
+
type LogsSettings,
|
|
23
|
+
} from './contract.js'
|
|
24
|
+
import type { LogsConfig } from './config.js'
|
|
25
|
+
import { buildLogExport, describeSources, readTailLines } from './export.js'
|
|
26
|
+
import { formatLogTimestamp } from './format.js'
|
|
27
|
+
import { displayDshHome, logsDirectory, resolveDshHome } from './home.js'
|
|
28
|
+
import { createLogsRpcMethods } from './rpc.js'
|
|
29
|
+
|
|
30
|
+
/** 从本包 `package.json` 读版本;读不到时退回 `unknown`,不影响导出。 */
|
|
31
|
+
async function readPluginVersion(): Promise<string> {
|
|
32
|
+
try {
|
|
33
|
+
const url = new URL('../../../package.json', import.meta.url)
|
|
34
|
+
const { readFile } = await import('node:fs/promises')
|
|
35
|
+
const parsed = JSON.parse(await readFile(url, 'utf8')) as { version?: unknown }
|
|
36
|
+
return typeof parsed.version === 'string' ? parsed.version : 'unknown'
|
|
37
|
+
} catch {
|
|
38
|
+
return 'unknown'
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* 运行时描述串:header 与 manifest 共用一个口径。
|
|
44
|
+
* @returns 形如 `node v22.19.0 darwin arm64`。
|
|
45
|
+
*/
|
|
46
|
+
function runtimeDescription(): string {
|
|
47
|
+
return `node ${process.version} ${process.platform} ${process.arch}`
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* 构建 `logs` 模块:把已解析的配置闭包进 `create`。
|
|
52
|
+
* @param config - 本 feature 的 `logs` 配置(`resolveLogsConfig` 的输出,永远完整)。
|
|
53
|
+
* @returns 可交给 `ctx.ftshareSettings.modules.register` 的模块。
|
|
54
|
+
*/
|
|
55
|
+
export function createLogsModule(config: LogsConfig): SettingsModule<'logs', LogsSettings> {
|
|
56
|
+
return defineSettingsModule<'logs', LogsSettings>({
|
|
57
|
+
namespace: 'logs',
|
|
58
|
+
inject: ['credentials'],
|
|
59
|
+
create(ctx) {
|
|
60
|
+
const home = resolveDshHome()
|
|
61
|
+
const directory = logsDirectory(home)
|
|
62
|
+
const collector = new FtLogCollector({
|
|
63
|
+
directory,
|
|
64
|
+
level: config.level,
|
|
65
|
+
maxBytes: config.maxBytes,
|
|
66
|
+
keep: config.keep,
|
|
67
|
+
// 当前 FTShare Key 的精确值替换:取不到就退化为纯模式脱敏。
|
|
68
|
+
secrets: async () => {
|
|
69
|
+
const ref = credentialRef(FTSHARE_API_KEY_REF)
|
|
70
|
+
const resolved = await ctx.credentials.resolve(ref)
|
|
71
|
+
return resolved?.value === undefined ? [] : [resolved.value]
|
|
72
|
+
},
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
const version = readPluginVersion()
|
|
76
|
+
const service: LogsSettings = {
|
|
77
|
+
filePath: collector.filePath,
|
|
78
|
+
flush: () => collector.flush(),
|
|
79
|
+
dispose: () => collector.dispose(),
|
|
80
|
+
stats: async () => {
|
|
81
|
+
const snapshot = collector.stats()
|
|
82
|
+
return {
|
|
83
|
+
file: snapshot.filePath,
|
|
84
|
+
fileBytes: snapshot.fileBytes,
|
|
85
|
+
level: config.level,
|
|
86
|
+
maxBytes: config.maxBytes,
|
|
87
|
+
keep: config.keep,
|
|
88
|
+
records: snapshot.records,
|
|
89
|
+
dropped: snapshot.dropped,
|
|
90
|
+
rotations: snapshot.rotations,
|
|
91
|
+
writeFailures: snapshot.writeFailures,
|
|
92
|
+
firstAt: snapshot.firstTs === undefined ? undefined : formatLogTimestamp(snapshot.firstTs),
|
|
93
|
+
lastAt: snapshot.lastTs === undefined ? undefined : formatLogTimestamp(snapshot.lastTs),
|
|
94
|
+
byName: snapshot.byName,
|
|
95
|
+
sources: await describeSources(config.sources),
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
preview: async (lines = LOGS_DEFAULT_PREVIEW_LINES): Promise<LogsPreview> => {
|
|
99
|
+
await collector.flush()
|
|
100
|
+
const wanted = Math.min(LOGS_MAX_PREVIEW_LINES, Math.max(1, Math.trunc(lines)))
|
|
101
|
+
try {
|
|
102
|
+
const tail = await readTailLines(collector.filePath, wanted, LOGS_PREVIEW_MAX_BYTES)
|
|
103
|
+
return { lines: tail.lines, bytes: tail.bytes, truncated: tail.truncated, fileBytes: tail.fileBytes }
|
|
104
|
+
} catch {
|
|
105
|
+
// 还没有任何日志时不是错误:返回空预览。
|
|
106
|
+
return { lines: [], bytes: 0, truncated: false, fileBytes: 0 }
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
export: async () => {
|
|
110
|
+
await collector.flush()
|
|
111
|
+
const result = await buildLogExport({
|
|
112
|
+
filePath: collector.filePath,
|
|
113
|
+
sources: config.sources,
|
|
114
|
+
homeDisplay: displayDshHome(home),
|
|
115
|
+
pluginVersion: await version,
|
|
116
|
+
environment: runtimeDescription(),
|
|
117
|
+
})
|
|
118
|
+
return { fileName: result.fileName, text: result.text, bytes: result.bytes, truncated: result.truncated, sources: result.summaries }
|
|
119
|
+
},
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// 诊断包由本 feature 自己挂路由(core 不认识 /logs.zip):与插件入口同一条惯用法,
|
|
123
|
+
// register 返回的 disposer 交给 scoped.effect,owner fiber 释放时路由随之撤销。
|
|
124
|
+
ctx.inject(['connection'], scoped => {
|
|
125
|
+
const connection = scoped.get('connection') as { fetch: LogsArchiveRouteRegistry }
|
|
126
|
+
scoped.effect(() => connection.fetch.register(createLogsArchiveRoute({
|
|
127
|
+
filePath: collector.filePath,
|
|
128
|
+
sources: config.sources,
|
|
129
|
+
homeDisplay: displayDshHome(home),
|
|
130
|
+
pluginVersion: version,
|
|
131
|
+
environment: runtimeDescription(),
|
|
132
|
+
flush: () => collector.flush(),
|
|
133
|
+
})), 'ftshare-settings: /logs.zip route')
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
const disposeExporter = ctx.logger.exporter({
|
|
137
|
+
// levels 是接收上限:warn=2 收 error/info/warn,debug=3 全收。
|
|
138
|
+
levels: { default: collector.threshold },
|
|
139
|
+
export: message => collector.append(message),
|
|
140
|
+
})
|
|
141
|
+
ctx.effect(() => () => {
|
|
142
|
+
disposeExporter()
|
|
143
|
+
void collector.dispose()
|
|
144
|
+
}, 'ftshare-settings: FT 日志采集器')
|
|
145
|
+
|
|
146
|
+
// 回填内置缓冲里残留的 error/info(warn/debug 已被默认门槛丢弃,不可恢复)。
|
|
147
|
+
const buffered = ctx.logger.buffer.filter(message => message.type === 'error' || message.type === 'info')
|
|
148
|
+
void collector.backfill(buffered)
|
|
149
|
+
|
|
150
|
+
return { service, rpcMethods: createLogsRpcMethods(service) }
|
|
151
|
+
},
|
|
152
|
+
})
|
|
153
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host half of the `logs` feature: the entry a host assembly imports.
|
|
3
|
+
*
|
|
4
|
+
* Like every feature directory, this file is the host entry, `./contract.js`
|
|
5
|
+
* its shared contract, and `./card.js` the browser entry browser code imports
|
|
6
|
+
* directly instead of going through here (a browser half must never pull the
|
|
7
|
+
* collector, the file system or the config schema).
|
|
8
|
+
*/
|
|
9
|
+
export * from './contract.js'
|
|
10
|
+
export { buildLogArchive, createLogsArchiveRoute, type LogsArchive, type LogsArchiveManifest, type LogsArchiveOptions, type LogsArchiveRoute } from './archive.js'
|
|
11
|
+
export { createLogsModule } from './host.js'
|
|
12
|
+
export { createLogsRpcMethods } from './rpc.js'
|