@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,157 @@
|
|
|
1
|
+
import { type ApiKeySettings, type CredentialStatus, type ConfigureResult, type ValidationResult, normalizeApiKey, untested, FTSHARE_API_KEY_REF } from './contract.js'
|
|
2
|
+
import type { FeatureLogger } from './logging.js'
|
|
3
|
+
import { probeApiKey } from './probe.js'
|
|
4
|
+
|
|
5
|
+
export interface CredentialStore {
|
|
6
|
+
resolve(): Promise<string | undefined>
|
|
7
|
+
describe(): Promise<CredentialStatus>
|
|
8
|
+
set(value: string): Promise<void>
|
|
9
|
+
clear(): Promise<void>
|
|
10
|
+
}
|
|
11
|
+
export type Validator = (key: string | undefined, signal: AbortSignal) => Promise<ValidationResult>
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* One Host instance owns validation state; the DSH provider owns all secrets.
|
|
15
|
+
*
|
|
16
|
+
* `logger` is optional so a directly constructed instance (tests, embeddings)
|
|
17
|
+
* stays silent: the plugin entry passes `ctx.logger()` in through
|
|
18
|
+
* `createApiKeyModule`. Fields never carry the Key or any token value.
|
|
19
|
+
*/
|
|
20
|
+
export function createApiKeySettings(store: CredentialStore, validator?: Validator, logger?: FeatureLogger) {
|
|
21
|
+
// 默认校验器 = 真实探测:只有这条路径才需要把 logger 传进 probe 记请求级失败。
|
|
22
|
+
const validate: Validator = validator ?? ((key, signal) => probeApiKey(key, signal, fetch, Date.now(), logger))
|
|
23
|
+
let revision = 0
|
|
24
|
+
let fingerprint: string | undefined
|
|
25
|
+
let validation = untested()
|
|
26
|
+
let active: { revision: number; controller: AbortController; promise: Promise<ValidationResult> } | undefined
|
|
27
|
+
let writes: Promise<unknown> = Promise.resolve()
|
|
28
|
+
let reads: Promise<unknown> = Promise.resolve()
|
|
29
|
+
let disposed = false
|
|
30
|
+
const listeners = new Set<() => void>()
|
|
31
|
+
function invalidate() {
|
|
32
|
+
revision++
|
|
33
|
+
fingerprint = undefined
|
|
34
|
+
validation = untested()
|
|
35
|
+
active?.controller.abort()
|
|
36
|
+
active = undefined
|
|
37
|
+
for (const listener of listeners) {
|
|
38
|
+
try { listener() } catch { /* Observers cannot turn a committed write into a failure. */ }
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
function current(): Promise<string | undefined> {
|
|
42
|
+
const read = reads.then(async () => {
|
|
43
|
+
while (true) {
|
|
44
|
+
if (disposed) throw new Error('FTShare settings service is disposed')
|
|
45
|
+
const started = revision
|
|
46
|
+
const key = await store.resolve()
|
|
47
|
+
const hash = key === undefined ? 'absent' : Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(key))), b => b.toString(16).padStart(2, '0')).join('')
|
|
48
|
+
if (started !== revision) continue
|
|
49
|
+
if (fingerprint !== undefined && fingerprint !== hash) invalidate()
|
|
50
|
+
fingerprint = hash
|
|
51
|
+
return key
|
|
52
|
+
}
|
|
53
|
+
})
|
|
54
|
+
reads = read.catch(() => {})
|
|
55
|
+
return read
|
|
56
|
+
}
|
|
57
|
+
function enqueue<T>(operation: () => Promise<T>): Promise<T> {
|
|
58
|
+
const promise = writes.then(() => {
|
|
59
|
+
if (disposed) throw new Error('FTShare settings service is disposed')
|
|
60
|
+
return operation()
|
|
61
|
+
})
|
|
62
|
+
writes = promise.catch(() => {})
|
|
63
|
+
return promise
|
|
64
|
+
}
|
|
65
|
+
async function write(value?: string) {
|
|
66
|
+
if (!(await store.describe()).writable) throw new Error('当前配置来源为只读')
|
|
67
|
+
const started = revision
|
|
68
|
+
if (value === undefined) await store.clear()
|
|
69
|
+
else await store.set(value)
|
|
70
|
+
// Also covers providers without update events.
|
|
71
|
+
if (started === revision) invalidate()
|
|
72
|
+
}
|
|
73
|
+
function testSnapshot(key: string | undefined, expectedRevision: number): Promise<ValidationResult> {
|
|
74
|
+
if (disposed || revision !== expectedRevision) return Promise.resolve(untested('superseded', '凭据已变化,请重新测试'))
|
|
75
|
+
if (active?.revision === revision) return active.promise.then(value => structuredClone(value))
|
|
76
|
+
const controller = new AbortController()
|
|
77
|
+
const run = { revision, controller, promise: Promise.resolve(untested()) }
|
|
78
|
+
run.promise = (async () => {
|
|
79
|
+
let result: ValidationResult
|
|
80
|
+
try { result = await validate(key, controller.signal) } catch {
|
|
81
|
+
logger?.warn('FTShare 验证服务异常', { stage: 'validate', code: 'validator_failed' })
|
|
82
|
+
result = { ...untested('not_run', '验证服务异常,请重试'), status: 'failed' }
|
|
83
|
+
}
|
|
84
|
+
try { if (!disposed) await current() } catch {
|
|
85
|
+
logger?.warn('FTShare 凭据不可确认', { stage: 'resolve', code: 'credential_unavailable' })
|
|
86
|
+
return { ...untested('not_run', '无法确认当前凭据,请重新测试'), status: 'failed' as const }
|
|
87
|
+
}
|
|
88
|
+
if (disposed || controller.signal.aborted || revision !== run.revision) return untested('superseded', '凭据已变化,旧测试结果已丢弃')
|
|
89
|
+
validation = structuredClone(result)
|
|
90
|
+
return structuredClone(result)
|
|
91
|
+
})().finally(() => { if (active === run) active = undefined })
|
|
92
|
+
active = run
|
|
93
|
+
return run.promise.then(value => structuredClone(value))
|
|
94
|
+
}
|
|
95
|
+
const api: ApiKeySettings = {
|
|
96
|
+
credentialRef: FTSHARE_API_KEY_REF,
|
|
97
|
+
async describe() {
|
|
98
|
+
while (true) {
|
|
99
|
+
const key = await current()
|
|
100
|
+
const started = revision
|
|
101
|
+
const { configured, writable, source } = await store.describe()
|
|
102
|
+
if (started !== revision) continue
|
|
103
|
+
const characters = Array.from(key ?? '')
|
|
104
|
+
// Short/invalid keys must not be exposed in full by the suffix preview.
|
|
105
|
+
const maskedValue = configured ? '••••••••' + (characters.length > 4 ? characters.slice(-4).join('') : '') : undefined
|
|
106
|
+
return { configured, writable, ...(source === undefined ? {} : { source }), ...(maskedValue === undefined ? {} : { maskedValue }), revision, validation: structuredClone(validation) }
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
resolve: current,
|
|
110
|
+
async set(input) { const value = normalizeApiKey(input); await enqueue(() => write(value)) },
|
|
111
|
+
async clear() { await enqueue(() => write()) },
|
|
112
|
+
async createEnvironment(base = {}) {
|
|
113
|
+
const env = { ...base }
|
|
114
|
+
delete env[FTSHARE_API_KEY_REF]
|
|
115
|
+
const key = await current()
|
|
116
|
+
if (key !== undefined) env[FTSHARE_API_KEY_REF] = key
|
|
117
|
+
return env
|
|
118
|
+
},
|
|
119
|
+
subscribe(listener) { listeners.add(listener); return () => { listeners.delete(listener) } },
|
|
120
|
+
async getValidationStatus() { await current(); return structuredClone(validation) },
|
|
121
|
+
async testConnection() {
|
|
122
|
+
await writes
|
|
123
|
+
const key = await current()
|
|
124
|
+
return testSnapshot(key, revision)
|
|
125
|
+
},
|
|
126
|
+
async configureAndTest(input): Promise<ConfigureResult> {
|
|
127
|
+
let value: string
|
|
128
|
+
try { value = normalizeApiKey(input) } catch {
|
|
129
|
+
return { save: { status: 'failed', code: 'invalid_input', message: 'API Key 格式不正确' }, validation: untested('not_run', '保存失败,未执行测试') }
|
|
130
|
+
}
|
|
131
|
+
const saved = await enqueue(async () => {
|
|
132
|
+
try {
|
|
133
|
+
if (!(await store.describe()).writable) return { code: 'read_only' as const }
|
|
134
|
+
await write(value)
|
|
135
|
+
} catch {
|
|
136
|
+
logger?.warn('FTShare API Key 保存失败', { code: 'save_failed' })
|
|
137
|
+
return { code: 'save_failed' as const }
|
|
138
|
+
}
|
|
139
|
+
// Reading/testing can fail after a committed save. Preserve that fact.
|
|
140
|
+
try {
|
|
141
|
+
const key = await current()
|
|
142
|
+
return { revision: key === value ? revision : -1, key }
|
|
143
|
+
} catch { return { unavailable: true } }
|
|
144
|
+
})
|
|
145
|
+
if ('code' in saved) return {
|
|
146
|
+
save: { status: 'failed', code: saved.code, message: saved.code === 'read_only' ? '当前配置来源为只读' : '保存失败,请重试' },
|
|
147
|
+
validation: untested('not_run', '保存失败,未执行测试'),
|
|
148
|
+
}
|
|
149
|
+
if ('unavailable' in saved) return { save: { status: 'saved', message: '已保存到 DSH 凭据存储' }, validation: untested('not_run', '已保存,但无法读取生效凭据,请重试') }
|
|
150
|
+
return { save: { status: 'saved', message: '已保存到 DSH 凭据存储' }, validation: await testSnapshot(saved.key, saved.revision) }
|
|
151
|
+
},
|
|
152
|
+
}
|
|
153
|
+
return {
|
|
154
|
+
api, invalidate,
|
|
155
|
+
dispose() { disposed = true; active?.controller.abort(); active = undefined; listeners.clear() },
|
|
156
|
+
}
|
|
157
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The API Key card's own styles, rendered by the card component's `<style>`.
|
|
3
|
+
*
|
|
4
|
+
* A card owns everything past the section shell: the core declares the
|
|
5
|
+
* `--ftshare-*` tokens and the section container, never card internals.
|
|
6
|
+
* Every selector stays scoped under `.ftshare-settings`, so a card never
|
|
7
|
+
* styles another card's markup.
|
|
8
|
+
*/
|
|
9
|
+
export const API_KEY_CARD_CSS = `
|
|
10
|
+
.ftshare-settings .ftshare-eyebrow { color:var(--ftshare-accent-on-surface); font-size:11px; letter-spacing:.12em; }
|
|
11
|
+
.ftshare-settings .ftshare-card { padding:22px; background:var(--ftshare-panel); border:0.5px solid var(--ftshare-border); border-radius:var(--dsw-radius-xl); margin-top:16px; }
|
|
12
|
+
.ftshare-settings label { display:block; font-weight:500; }
|
|
13
|
+
.ftshare-settings input { width:100%; height:32px; border:0.5px solid var(--ftshare-control); border-radius:var(--dsw-radius-md); padding:0 12px; background:var(--ftshare-input); color:inherit; font:inherit; }
|
|
14
|
+
.ftshare-settings input::placeholder { color:var(--ftshare-muted); opacity:1; }
|
|
15
|
+
.ftshare-settings .ftshare-actions { display:flex; flex-wrap:wrap; gap:10px; align-items:center; margin-top:16px; }
|
|
16
|
+
.ftshare-settings button { min-height:36px; border:0.5px solid var(--ftshare-control); border-radius:var(--dsw-radius-md); padding:0 14px; font:inherit; background:transparent; color:inherit; cursor:pointer; }
|
|
17
|
+
.ftshare-settings button.primary { background:var(--ftshare-accent); color:var(--ftshare-accent-ink); border-color:var(--ftshare-accent); font-weight:500; }
|
|
18
|
+
.ftshare-settings button:disabled { opacity:.5; cursor:not-allowed; }
|
|
19
|
+
.ftshare-settings dl { margin:0; }
|
|
20
|
+
.ftshare-settings .ftshare-result { display:grid; grid-template-columns:130px 1fr; gap:12px; padding:10px 0; border-top:0.5px solid var(--ftshare-border); }
|
|
21
|
+
.ftshare-settings dd { margin:0; overflow-wrap:anywhere; }
|
|
22
|
+
.ftshare-settings .ftshare-error { color:var(--ftshare-error); }
|
|
23
|
+
@media(max-width:540px) { .ftshare-settings .ftshare-card { padding:16px; } .ftshare-settings .ftshare-result { grid-template-columns:1fr; gap:4px; } }
|
|
24
|
+
`
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 诊断包(P2,规格 §9 / §12 决策 3):把单文件导出与额外来源原文打成一个 zip。
|
|
3
|
+
*
|
|
4
|
+
* 归档结构与封顶语义:
|
|
5
|
+
*
|
|
6
|
+
* - `manifest.json`:生成时间、插件版本、运行时、`$DSH_HOME` 展示名、上限、`truncated`、
|
|
7
|
+
* 各来源统计(label/path/lines/bytes/firstAt/lastAt/missing/truncated)、四条覆盖盲区;
|
|
8
|
+
* - `ft-runtime.log`:与单文件导出**完全相同**的文本(复用 `loadLogExport` + `renderLogExport`);
|
|
9
|
+
* - `sources/<n>-<basename>`:配置的额外来源文件原文,编号稳定、按路径去重;缺失来源只进 manifest。
|
|
10
|
+
*
|
|
11
|
+
* 封顶沿用规格 §9:单来源 2 MiB、合计 8 MiB、目录最多 20 个 `*.log`;归档以**未压缩字节**
|
|
12
|
+
* 计入同一个 8 MiB 预算(`ft-runtime.log` 先算,剩余额度再分给 `sources/*`),超限截断并在
|
|
13
|
+
* manifest 标 `truncated: true`。
|
|
14
|
+
*
|
|
15
|
+
* 本文件是 Host 半边(Node 文件系统 + `fflate`),不得被浏览器半边引用:卡片只用
|
|
16
|
+
* `./contract.js` 里的 `LOGS_ARCHIVE_ROUTE` 与 `<a download>`。
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { zipSync } from 'fflate'
|
|
20
|
+
import type { SettingsRoute } from '../../core/rpc-core.js'
|
|
21
|
+
import {
|
|
22
|
+
LOGS_ARCHIVE_LOG,
|
|
23
|
+
LOGS_ARCHIVE_MANIFEST,
|
|
24
|
+
LOGS_ARCHIVE_ROUTE,
|
|
25
|
+
LOGS_ARCHIVE_SOURCE_DIR,
|
|
26
|
+
LOGS_EXPORT_MAX_BYTES,
|
|
27
|
+
LOGS_MAX_SOURCE_FILES,
|
|
28
|
+
LOGS_SOURCE_MAX_BYTES,
|
|
29
|
+
type LogsSourceSummary,
|
|
30
|
+
} from './contract.js'
|
|
31
|
+
import { LOG_BLIND_SPOTS, loadLogExport, renderLogExport, type ExportOptions } from './export.js'
|
|
32
|
+
import { byteLength, formatArchiveFileName, formatLogTimestamp } from './format.js'
|
|
33
|
+
import { basename } from 'node:path'
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* 与 core 的 `SettingsRoute` 同形,只把 `path` 从「那一条 POST 路由」放宽到本 feature 的精确路径。
|
|
37
|
+
* core 因此不需要认识 `/logs.zip`(架构守护测试要求 core 无 feature 词汇)。
|
|
38
|
+
*/
|
|
39
|
+
export interface LogsArchiveRoute extends Omit<SettingsRoute, 'path'> {
|
|
40
|
+
readonly path: typeof LOGS_ARCHIVE_ROUTE
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** 载体侧的注册面:与 core 的 `HostFetch` 同形,收本 feature 自己的路由。 */
|
|
44
|
+
export interface LogsArchiveRouteRegistry {
|
|
45
|
+
register(route: LogsArchiveRoute): () => Promise<void>
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** 归档选项:来源定义与 header 元信息(`filePath`/`sources`/`homeDisplay`/`environment`)。 */
|
|
49
|
+
export interface LogsArchiveOptions extends Omit<ExportOptions, 'pluginVersion' | 'now'> {
|
|
50
|
+
/** 插件版本;允许传 promise,`readPluginVersion()` 的结果可以直接塞进来。 */
|
|
51
|
+
readonly pluginVersion: string | Promise<string>
|
|
52
|
+
/** 读文件前刷盘(采集器的 10 秒合并窗口);省略时直接读当前文件。 */
|
|
53
|
+
readonly flush?: () => Promise<void>
|
|
54
|
+
readonly now?: () => number
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** manifest 里的一个来源:规格 §9 的统计字段,外加它落在归档里的条目名。 */
|
|
58
|
+
export interface LogsArchiveManifestSource extends LogsSourceSummary {
|
|
59
|
+
/** 归档内的条目路径;缺失或未纳入时不存在。 */
|
|
60
|
+
readonly entry?: string
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface LogsArchiveEntry {
|
|
64
|
+
readonly name: string
|
|
65
|
+
readonly bytes: number
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface LogsArchiveManifest {
|
|
69
|
+
readonly generatedAt: string
|
|
70
|
+
readonly pluginVersion: string
|
|
71
|
+
readonly runtime: string
|
|
72
|
+
/** `$DSH_HOME` 的展示名(`~/.dsh` 或 `$DSH_HOME`),不写绝对路径。 */
|
|
73
|
+
readonly home: string
|
|
74
|
+
readonly limits: {
|
|
75
|
+
readonly sourceBytes: number
|
|
76
|
+
readonly archiveBytes: number
|
|
77
|
+
readonly maxSourceFiles: number
|
|
78
|
+
}
|
|
79
|
+
/** 未压缩字节是否触到任一上限。 */
|
|
80
|
+
readonly truncated: boolean
|
|
81
|
+
/** 归档内除 `manifest.json` 自身之外的条目(它无法记录自己的字节数)。 */
|
|
82
|
+
readonly entries: readonly LogsArchiveEntry[]
|
|
83
|
+
readonly sources: readonly LogsArchiveManifestSource[]
|
|
84
|
+
readonly blindSpots: readonly string[]
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface LogsArchive {
|
|
88
|
+
readonly fileName: string
|
|
89
|
+
readonly bytes: Uint8Array
|
|
90
|
+
readonly truncated: boolean
|
|
91
|
+
readonly manifest: LogsArchiveManifest
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const encoder = new TextEncoder()
|
|
95
|
+
const decoder = new TextDecoder('utf-8', { fatal: false })
|
|
96
|
+
|
|
97
|
+
/** 按 UTF-8 字节上限截断原文副本,并丢掉最后一个不完整行。 */
|
|
98
|
+
function truncateToBytes(text: string, maxBytes: number): string {
|
|
99
|
+
let slice = decoder.decode(encoder.encode(text).subarray(0, Math.max(0, maxBytes)))
|
|
100
|
+
// 截断点可能落在多字节字符中间,去掉 UTF-8 替换字符。
|
|
101
|
+
while (slice.endsWith('\uFFFD')) slice = slice.slice(0, -1)
|
|
102
|
+
const lastBreak = slice.lastIndexOf('\n')
|
|
103
|
+
return lastBreak === -1 ? slice : slice.slice(0, lastBreak + 1)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* 生成诊断包:单文件导出文本 + manifest + 额外来源原文。
|
|
108
|
+
* @param options - 来源定义与 header 元信息。
|
|
109
|
+
* @returns 归档字节、文件名、manifest 与整体 `truncated`。
|
|
110
|
+
*/
|
|
111
|
+
export async function buildLogArchive(options: LogsArchiveOptions): Promise<LogsArchive> {
|
|
112
|
+
await options.flush?.()
|
|
113
|
+
const pluginVersion = await options.pluginVersion
|
|
114
|
+
const generatedAt = (options.now ?? Date.now)()
|
|
115
|
+
const { loaded, truncated: logTruncated } = await loadLogExport(options)
|
|
116
|
+
const rendered = renderLogExport({
|
|
117
|
+
loaded,
|
|
118
|
+
truncated: logTruncated,
|
|
119
|
+
generatedAt,
|
|
120
|
+
homeDisplay: options.homeDisplay,
|
|
121
|
+
pluginVersion,
|
|
122
|
+
environment: options.environment,
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
let truncated = logTruncated
|
|
126
|
+
// 未压缩字节共用同一个 8 MiB 预算:ft-runtime.log 先占,剩下的才分给原文副本。
|
|
127
|
+
let remaining = LOGS_EXPORT_MAX_BYTES - rendered.bytes
|
|
128
|
+
const content: [string, Uint8Array][] = [[LOGS_ARCHIVE_LOG, encoder.encode(rendered.text)]]
|
|
129
|
+
const entries: LogsArchiveEntry[] = [{ name: LOGS_ARCHIVE_LOG, bytes: content[0][1].byteLength }]
|
|
130
|
+
const sources: LogsArchiveManifestSource[] = []
|
|
131
|
+
const seen = new Set<string>()
|
|
132
|
+
let index = 0
|
|
133
|
+
|
|
134
|
+
for (const [position, item] of loaded.entries()) {
|
|
135
|
+
const summary = item.summary
|
|
136
|
+
// 第一条固定是主日志:它的内容已经完整进了 ft-runtime.log。
|
|
137
|
+
if (position === 0) {
|
|
138
|
+
sources.push({ ...summary, entry: LOGS_ARCHIVE_LOG })
|
|
139
|
+
continue
|
|
140
|
+
}
|
|
141
|
+
if (summary.missing === true) {
|
|
142
|
+
sources.push({ ...summary })
|
|
143
|
+
continue
|
|
144
|
+
}
|
|
145
|
+
let entry: string | undefined
|
|
146
|
+
let sourceTruncated = summary.truncated
|
|
147
|
+
if (seen.has(summary.path)) {
|
|
148
|
+
// 同一路径被配置两次:归档内只留一份原文,编号仍然稳定。
|
|
149
|
+
} else if (remaining <= 0) {
|
|
150
|
+
truncated = true
|
|
151
|
+
sourceTruncated = true
|
|
152
|
+
} else {
|
|
153
|
+
const budget = Math.min(LOGS_SOURCE_MAX_BYTES, remaining)
|
|
154
|
+
let text = item.content
|
|
155
|
+
if (byteLength(text) > budget) {
|
|
156
|
+
text = truncateToBytes(text, budget)
|
|
157
|
+
truncated = true
|
|
158
|
+
sourceTruncated = true
|
|
159
|
+
// 一份来源被切短即表示预算耗尽:剩下的零头不再喂给后续来源,条目数保持确定。
|
|
160
|
+
remaining = 0
|
|
161
|
+
} else {
|
|
162
|
+
remaining -= byteLength(text)
|
|
163
|
+
}
|
|
164
|
+
if (text !== '' || item.content === '') {
|
|
165
|
+
index += 1
|
|
166
|
+
entry = `${LOGS_ARCHIVE_SOURCE_DIR}${index}-${basename(summary.path)}`
|
|
167
|
+
seen.add(summary.path)
|
|
168
|
+
const bytes = encoder.encode(text)
|
|
169
|
+
content.push([entry, bytes])
|
|
170
|
+
entries.push({ name: entry, bytes: bytes.byteLength })
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
sources.push({ ...summary, truncated: sourceTruncated, ...(entry === undefined ? {} : { entry }) })
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const manifest: LogsArchiveManifest = {
|
|
177
|
+
generatedAt: formatLogTimestamp(generatedAt),
|
|
178
|
+
pluginVersion: `@dsh-ft/dsh-fft-settings ${pluginVersion}`,
|
|
179
|
+
runtime: options.environment ?? process.version,
|
|
180
|
+
home: options.homeDisplay,
|
|
181
|
+
limits: {
|
|
182
|
+
sourceBytes: LOGS_SOURCE_MAX_BYTES,
|
|
183
|
+
archiveBytes: LOGS_EXPORT_MAX_BYTES,
|
|
184
|
+
maxSourceFiles: LOGS_MAX_SOURCE_FILES,
|
|
185
|
+
},
|
|
186
|
+
truncated,
|
|
187
|
+
entries,
|
|
188
|
+
sources,
|
|
189
|
+
blindSpots: [...LOG_BLIND_SPOTS],
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// manifest 排第一,其余条目按 ft-runtime.log → sources/<n>-… 的顺序写入(对象键保持插入序)。
|
|
193
|
+
const files: Record<string, Uint8Array> = {
|
|
194
|
+
[LOGS_ARCHIVE_MANIFEST]: encoder.encode(JSON.stringify(manifest, null, 2)),
|
|
195
|
+
}
|
|
196
|
+
for (const [name, bytes] of content) files[name] = bytes
|
|
197
|
+
|
|
198
|
+
return { fileName: formatArchiveFileName(generatedAt), bytes: zipSync(files), truncated, manifest }
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* 挂到 `/api` 载体上的精确 GET 路由。
|
|
203
|
+
*
|
|
204
|
+
* 不读任何请求参数、不重复鉴权(`/api` 前缀的信任与认证由载体负责);每次请求现算归档,
|
|
205
|
+
* 因此刷盘与版本解析都在这里发生。
|
|
206
|
+
* @param options - 归档选项。
|
|
207
|
+
* @returns 交给 `connection.fetch.register` 的路由定义。
|
|
208
|
+
*/
|
|
209
|
+
export function createLogsArchiveRoute(options: LogsArchiveOptions): LogsArchiveRoute {
|
|
210
|
+
return {
|
|
211
|
+
path: LOGS_ARCHIVE_ROUTE,
|
|
212
|
+
methods: ['GET'],
|
|
213
|
+
// GET 不读体;buffered 只是与载体约定「本路由不消费请求体」。
|
|
214
|
+
requestBody: 'buffered',
|
|
215
|
+
fetch: async () => {
|
|
216
|
+
const archive = await buildLogArchive(options)
|
|
217
|
+
// 复制成独立 ArrayBuffer 承载的视图:`Response` 的 BodyInit 不接受 ArrayBufferLike 视图。
|
|
218
|
+
const body = new Uint8Array(archive.bytes)
|
|
219
|
+
return new Response(body, {
|
|
220
|
+
status: 200,
|
|
221
|
+
headers: {
|
|
222
|
+
'content-type': 'application/zip',
|
|
223
|
+
'content-disposition': `attachment; filename="${archive.fileName}"`,
|
|
224
|
+
'cache-control': 'no-store',
|
|
225
|
+
'content-length': String(body.byteLength),
|
|
226
|
+
},
|
|
227
|
+
})
|
|
228
|
+
},
|
|
229
|
+
}
|
|
230
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { useCallback, useEffect, useState, type ReactNode } from 'react'
|
|
2
|
+
import {
|
|
3
|
+
LOGS_ARCHIVE_ROUTE,
|
|
4
|
+
LOGS_CARD_ID,
|
|
5
|
+
LOGS_CARD_ORDER,
|
|
6
|
+
LOGS_DEFAULT_PREVIEW_LINES,
|
|
7
|
+
LOGS_MAX_PREVIEW_LINES,
|
|
8
|
+
type LogsPreview,
|
|
9
|
+
type LogsPublicApi,
|
|
10
|
+
type LogsStats,
|
|
11
|
+
} from './contract.js'
|
|
12
|
+
import { createLogsClient } from './client-api.js'
|
|
13
|
+
import { createSettingsTransport } from '../../core/client-transport.js'
|
|
14
|
+
import { FTSHARE_SETTINGS_CARD_SLOT } from '../../core/contract.js'
|
|
15
|
+
import type { FtshareSettingsShellContext } from '../../core/shell.js'
|
|
16
|
+
import { LOGS_CARD_CSS } from './styles.js'
|
|
17
|
+
|
|
18
|
+
/** 预览行数候选:上限由 Host 侧校验兜底。 */
|
|
19
|
+
const PREVIEW_CHOICES = [50, LOGS_DEFAULT_PREVIEW_LINES, LOGS_MAX_PREVIEW_LINES]
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* 快照缺失时的占位文案。
|
|
23
|
+
*
|
|
24
|
+
* 失败后不能继续写「读取中…」:那会把一次已经结束的失败说成正在进行的读取,掩盖真正的
|
|
25
|
+
* 原因(例如宿主插件没有这个接口,重试永远不会成功)。
|
|
26
|
+
* @param failed - 本次读取是否已经失败。
|
|
27
|
+
* @returns 失败提示,或仍在读取的提示。
|
|
28
|
+
*/
|
|
29
|
+
export function statsPlaceholder(failed: boolean): string {
|
|
30
|
+
return failed ? '读取失败' : '读取中…'
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function displayBytes(bytes: number): string {
|
|
34
|
+
if (bytes < 1024) return `${bytes} B`
|
|
35
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KiB`
|
|
36
|
+
return `${(bytes / 1024 / 1024).toFixed(2)} MiB`
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** 把文本包成 Blob,用 a[download] 触发下载(Web 与桌面同一套)。 */
|
|
40
|
+
function downloadText(fileName: string, text: string): void {
|
|
41
|
+
const url = URL.createObjectURL(new Blob([text], { type: 'text/plain;charset=utf-8' }))
|
|
42
|
+
const anchor = document.createElement('a')
|
|
43
|
+
anchor.href = url
|
|
44
|
+
anchor.download = fileName
|
|
45
|
+
anchor.rel = 'noopener'
|
|
46
|
+
document.body.appendChild(anchor)
|
|
47
|
+
anchor.click()
|
|
48
|
+
anchor.remove()
|
|
49
|
+
setTimeout(() => URL.revokeObjectURL(url), 0)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Contribute the built-in runtime-log card through the same slot a third-party
|
|
54
|
+
* card uses. Nothing about it is privileged: same declaration, same options.
|
|
55
|
+
* @param ctx - client context carrying the renderer's slots service.
|
|
56
|
+
* @returns idempotent disposer for the card registration.
|
|
57
|
+
*/
|
|
58
|
+
export function registerLogsCard(ctx: FtshareSettingsShellContext): () => void {
|
|
59
|
+
return ctx.slots.inject(FTSHARE_SETTINGS_CARD_SLOT, () => ctx.slots.register(
|
|
60
|
+
{ name: FTSHARE_SETTINGS_CARD_SLOT, id: LOGS_CARD_ID, order: LOGS_CARD_ORDER },
|
|
61
|
+
createLogsCard(),
|
|
62
|
+
))
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Bind the card's adapter; the component only reads through it. */
|
|
66
|
+
function createLogsCard(): () => ReactNode {
|
|
67
|
+
const api = createLogsClient(createSettingsTransport())
|
|
68
|
+
return () => <LogsSection api={api} />
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function LogsSection({ api }: { readonly api: LogsPublicApi }): ReactNode {
|
|
72
|
+
const [stats, setStats] = useState<LogsStats>()
|
|
73
|
+
const [preview, setPreview] = useState<LogsPreview>()
|
|
74
|
+
const [lines, setLines] = useState(LOGS_DEFAULT_PREVIEW_LINES)
|
|
75
|
+
const [busy, setBusy] = useState(false)
|
|
76
|
+
const [message, setMessage] = useState('')
|
|
77
|
+
const [error, setError] = useState('')
|
|
78
|
+
/** 读取已失败且还没有任何快照时,每个字段都必须说出来,而不是假装还在读。 */
|
|
79
|
+
const placeholder = statsPlaceholder(error !== '')
|
|
80
|
+
|
|
81
|
+
const refresh = useCallback(async () => {
|
|
82
|
+
setBusy(true)
|
|
83
|
+
setError('')
|
|
84
|
+
try {
|
|
85
|
+
setStats(await api.stats())
|
|
86
|
+
setPreview(await api.preview(lines))
|
|
87
|
+
} catch (cause) {
|
|
88
|
+
setError(cause instanceof Error ? cause.message : '读取日志状态失败')
|
|
89
|
+
} finally {
|
|
90
|
+
setBusy(false)
|
|
91
|
+
}
|
|
92
|
+
}, [api, lines])
|
|
93
|
+
|
|
94
|
+
useEffect(() => { void refresh() }, [refresh])
|
|
95
|
+
|
|
96
|
+
const doExport = async () => {
|
|
97
|
+
setBusy(true)
|
|
98
|
+
setError('')
|
|
99
|
+
setMessage('')
|
|
100
|
+
try {
|
|
101
|
+
const result = await api.export()
|
|
102
|
+
downloadText(result.fileName, result.text)
|
|
103
|
+
setMessage(`已导出 ${result.fileName}(${displayBytes(result.bytes)}${result.truncated ? ',已达体积上限并截断' : ''})`)
|
|
104
|
+
} catch (cause) {
|
|
105
|
+
setError(cause instanceof Error ? cause.message : '导出日志失败')
|
|
106
|
+
} finally {
|
|
107
|
+
setBusy(false)
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return <>
|
|
112
|
+
<style>{LOGS_CARD_CSS}</style>
|
|
113
|
+
<section className="ftshare-settings" aria-label="FT 运行日志" aria-busy={busy}>
|
|
114
|
+
<header><div><span className="ftshare-eyebrow">FTSHARE / LOGS</span><h2>运行日志</h2></div></header>
|
|
115
|
+
<div className="ftshare-card">
|
|
116
|
+
<h3>采集状态</h3>
|
|
117
|
+
<dl>
|
|
118
|
+
<div className="ftshare-result"><dt>日志文件</dt><dd>{stats?.file ?? placeholder}</dd></div>
|
|
119
|
+
<div className="ftshare-result"><dt>记录 / 时间范围</dt><dd>
|
|
120
|
+
{stats === undefined ? placeholder : `${stats.records} 条`}
|
|
121
|
+
{stats?.firstAt !== undefined && `(${stats.firstAt} ~ ${stats.lastAt})`}
|
|
122
|
+
</dd></div>
|
|
123
|
+
<div className="ftshare-result"><dt>文件大小 / 上限</dt><dd>
|
|
124
|
+
{stats === undefined ? placeholder : `${displayBytes(stats.fileBytes)} / ${displayBytes(stats.maxBytes)}`}
|
|
125
|
+
</dd></div>
|
|
126
|
+
<div className="ftshare-result"><dt>丢弃 / 轮转 / 写失败</dt><dd>
|
|
127
|
+
{stats === undefined ? placeholder : `${stats.dropped} / ${stats.rotations} / ${stats.writeFailures}`}
|
|
128
|
+
</dd></div>
|
|
129
|
+
<div className="ftshare-result"><dt>日志级别</dt><dd>{stats?.level ?? placeholder} </dd></div>
|
|
130
|
+
<div className="ftshare-result"><dt>统计</dt><dd>
|
|
131
|
+
{stats === undefined
|
|
132
|
+
? placeholder
|
|
133
|
+
: Object.keys(stats.byName).length === 0
|
|
134
|
+
? '暂无'
|
|
135
|
+
: Object.entries(stats.byName).map(([name, count]) => `${name}=${count}`).join('、')}
|
|
136
|
+
</dd></div>
|
|
137
|
+
<div className="ftshare-result"><dt>额外来源</dt><dd>
|
|
138
|
+
{stats === undefined
|
|
139
|
+
? placeholder
|
|
140
|
+
: stats.sources.length === 0
|
|
141
|
+
? '暂无'
|
|
142
|
+
: stats.sources.map(source => `${source.path}(${source.kind}${source.bytes === undefined ? '' : ` ${displayBytes(source.bytes)}`})`).join(';')}
|
|
143
|
+
</dd></div>
|
|
144
|
+
</dl>
|
|
145
|
+
<div className="ftshare-actions">
|
|
146
|
+
<button className="primary" type="button" onClick={() => void doExport()} disabled={busy}>导出日志</button>
|
|
147
|
+
{/* 同源 GET,浏览器自带凭据:直接指向路由,不需要 fetch/Blob。 */}
|
|
148
|
+
<a className="ftshare-download" href={LOGS_ARCHIVE_ROUTE} download="">导出诊断包</a>
|
|
149
|
+
<button type="button" onClick={() => void refresh()} disabled={busy}>刷新</button>
|
|
150
|
+
<label htmlFor="ftshare-log-lines">预览行数</label>
|
|
151
|
+
<select id="ftshare-log-lines" value={lines} onChange={e => setLines(Number(e.target.value))} disabled={busy}>
|
|
152
|
+
{PREVIEW_CHOICES.map(choice => <option key={choice} value={choice}>{choice}</option>)}
|
|
153
|
+
</select>
|
|
154
|
+
</div>
|
|
155
|
+
{busy && <p role="status">正在处理,请稍候…</p>}
|
|
156
|
+
{message && <p role="status">{message}</p>}
|
|
157
|
+
{error && <p className="ftshare-error" role="alert">{error}</p>}
|
|
158
|
+
</div>
|
|
159
|
+
<div className="ftshare-card">
|
|
160
|
+
<h3>最近日志{preview?.truncated === true ? '(已截断)' : ''}</h3>
|
|
161
|
+
{preview === undefined
|
|
162
|
+
? <p>{error === '' ? '正在读取…' : '日志预览读取失败,见上方提示。'}</p>
|
|
163
|
+
: preview.lines.length === 0
|
|
164
|
+
? <p>无</p>
|
|
165
|
+
: <pre className="ftshare-log-preview">{preview.lines.join('\n')}</pre>}
|
|
166
|
+
</div>
|
|
167
|
+
</section>
|
|
168
|
+
</>
|
|
169
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import {
|
|
2
|
+
LOGS_DEFAULT_PREVIEW_LINES,
|
|
3
|
+
type LogsExport,
|
|
4
|
+
type LogsMethod,
|
|
5
|
+
type LogsPreview,
|
|
6
|
+
type LogsPublicApi,
|
|
7
|
+
type LogsStats,
|
|
8
|
+
} from './contract.js'
|
|
9
|
+
import { callSettingsRpc, type ClientTransport } from '../../core/rpc-core.js'
|
|
10
|
+
|
|
11
|
+
/** 浏览器适配器:只读,且不暴露本地路径以外的任何 Host 能力(刷盘与释放句柄)。 */
|
|
12
|
+
export function createLogsClient(transport: ClientTransport): LogsPublicApi {
|
|
13
|
+
const call = <T>(method: LogsMethod, payload: unknown = {}): Promise<T> =>
|
|
14
|
+
callSettingsRpc<T>(transport, 'logs', method, payload)
|
|
15
|
+
return {
|
|
16
|
+
stats: () => call<LogsStats>('stats'),
|
|
17
|
+
preview: (lines = LOGS_DEFAULT_PREVIEW_LINES) => call<LogsPreview>('preview', { lines }),
|
|
18
|
+
export: () => call<LogsExport>('export'),
|
|
19
|
+
}
|
|
20
|
+
}
|