dsh-remote-plugin 0.7.0 → 0.7.2
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/apk/dsh-remote.apk +0 -0
- package/client.js +12 -2
- package/gateway.cjs +81 -9
- package/index.mjs +10 -0
- package/insights.mjs +73 -0
- package/package.json +3 -2
- package/plugin-center.mjs +118 -21
- package/public/admin.html +3 -0
- package/public/admin.js +1 -0
- package/public/app.js +100 -174
- package/public/desktop/desktop.html +5 -0
- package/public/desktop/desktop.js +76 -8
- package/public/index.html +14 -64
- package/public/insights.css +14 -0
- package/public/insights.js +228 -0
- package/public/plugin-center.css +89 -1
- package/public/plugin-center.js +148 -39
- package/public/plugin.js +7 -1
- package/public/styles.css +0 -93
- package/public/update.json +12 -12
- package/public/version.json +1 -1
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/* Shared read-only context/cost UI. No plugin UI bundles, scripts or settings. */
|
|
2
|
+
;(function (root) {
|
|
3
|
+
'use strict'
|
|
4
|
+
const obj = v => v && typeof v === 'object' && !Array.isArray(v) ? v : null
|
|
5
|
+
const num = v => typeof v === 'number' && Number.isFinite(v) && v >= 0 ? v : null
|
|
6
|
+
const esc = v => String(v ?? '').slice(0, 240).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]))
|
|
7
|
+
const categories = ['system', 'tools', 'user', 'inject', 'skill', 'assistant', 'tool']
|
|
8
|
+
const labels = {
|
|
9
|
+
zh: { title: '会话洞察', context: '上下文', cost: '费用与额度', close: '关闭', refresh: '刷新', loading: '正在读取…',
|
|
10
|
+
none: '选择一个会话后查看洞察。', missing: '此主机尚未提供 dsh-context 数据。请安装并启用插件后刷新。',
|
|
11
|
+
unavailable: '读取失败,请重试。已有概览仍可查看。', unsupported: '当前插件或 DSH 版本暂不支持此数据格式。',
|
|
12
|
+
costMissing: '此主机尚未提供费用数据。需要启用 dsh-cost-meter,并更新主机端 Remote 插件。',
|
|
13
|
+
occupancy: '上下文占用', modelName: '当前模型', estimated: '组成 Token 为插件估算;占用优先使用 DSH 官方投影。',
|
|
14
|
+
system: '系统提示', tools: '工具定义', user: '用户消息', inject: '注入内容', skill: '技能', assistant: '助手消息', tool: '工具结果',
|
|
15
|
+
composition: '当前组成', events: '最近上下文变化', noEvents: '尚无压缩、裁剪或注入记录。',
|
|
16
|
+
compaction: '压缩', prune: '裁剪', model: '模型变更', mode: '模式变更', counts: '累计变化',
|
|
17
|
+
detailsFailed: '变化记录暂不可用,点击刷新重试。', detailsLoading: '正在读取变化记录…',
|
|
18
|
+
session: '当前会话 API 费用', today: '今日 API 费用', equivalent: '含套餐等值估算', subagents: '子代理 API 费用(单列)',
|
|
19
|
+
balance: '账户余额', plans: '套餐额度 · 已用', source: '数据来源', assembled: '摘要读取于', queried: '供应商查询于',
|
|
20
|
+
estimateNote: '费用由插件账本按用量估算,以供应商账单为准;套餐等值金额不计入 API 费用,子代理费用单独列出。',
|
|
21
|
+
sessionMissing: '插件账本尚无此会话记录', noPlans: '主机未启用可展示的套餐额度。', off: '主机未开启查询',
|
|
22
|
+
error: '供应商查询失败', stale: '缓存数据', unknown: '暂无有效数据', unlimited: '不限量', reset: '重置',
|
|
23
|
+
rolling: '滚动窗口', weekly: '每周', monthly: '每月', date: '主机记账日', details: '明细' },
|
|
24
|
+
en: { title: 'Session insights', context: 'Context', cost: 'Cost & quotas', close: 'Close', refresh: 'Refresh', loading: 'Loading…',
|
|
25
|
+
none: 'Select a session to view insights.', missing: 'This host has no dsh-context data. Enable the plugin, then refresh.',
|
|
26
|
+
unavailable: 'Could not read data. Retry; the current overview is still available.', unsupported: 'This plugin or DSH version uses an unsupported data format.',
|
|
27
|
+
costMissing: 'Enable dsh-cost-meter and update the host Remote plugin to read costs.', occupancy: 'Context occupancy', modelName: 'Current model',
|
|
28
|
+
estimated: 'Composition tokens are plugin estimates; occupancy uses the DSH projection when available.',
|
|
29
|
+
system: 'System prompt', tools: 'Tool definitions', user: 'User messages', inject: 'Injections', skill: 'Skills', assistant: 'Assistant', tool: 'Tool results',
|
|
30
|
+
composition: 'Current composition', events: 'Recent context changes', noEvents: 'No compaction, pruning or injection records yet.',
|
|
31
|
+
compaction: 'Compaction', prune: 'Pruning', model: 'Model change', mode: 'Mode change', counts: 'Total changes',
|
|
32
|
+
detailsFailed: 'Change records are unavailable. Refresh to retry.', detailsLoading: 'Loading change records…',
|
|
33
|
+
session: 'Session API cost', today: 'Today API cost', equivalent: 'Estimate including plan equivalents', subagents: 'Subagent API cost (separate)',
|
|
34
|
+
balance: 'Account balance', plans: 'Plan quotas · used', source: 'Source', assembled: 'Summary read at', queried: 'Provider queried at',
|
|
35
|
+
estimateNote: 'Costs are usage estimates from the plugin ledger. Provider invoices are authoritative. Plan equivalents and subagents are shown separately.',
|
|
36
|
+
sessionMissing: 'No ledger record for this session yet', noPlans: 'No visible plan quota is enabled on this host.', off: 'Query disabled on host',
|
|
37
|
+
error: 'Provider query failed', stale: 'Cached data', unknown: 'No valid data', unlimited: 'Unlimited', reset: 'Resets',
|
|
38
|
+
rolling: 'Rolling window', weekly: 'Weekly', monthly: 'Monthly', date: 'Host ledger date', details: 'Details' },
|
|
39
|
+
}
|
|
40
|
+
let lang = 'zh'
|
|
41
|
+
const t = key => labels[lang][key] || key
|
|
42
|
+
const tokens = v => num(v) === null ? '—' : new Intl.NumberFormat(lang === 'zh' ? 'zh-CN' : 'en-US', { maximumFractionDigits: 0 }).format(v)
|
|
43
|
+
const money = (v, currency = 'USD', digits = 4) => num(v) === null || !/^[A-Z]{3}$/.test(currency) ? '—' : currency + ' ' + v.toFixed(digits)
|
|
44
|
+
const time = v => typeof v === 'number' && v > 0 && v < 8640000000000000 ? new Date(v).toLocaleString(lang === 'zh' ? 'zh-CN' : 'en-US') : '—'
|
|
45
|
+
function contextOf(value) {
|
|
46
|
+
const data = obj(value), current = obj(data?.current)
|
|
47
|
+
if (!data || !current || num(current.total) === null) return null
|
|
48
|
+
if (categories.some(key => num(current[key]) === null)) return null
|
|
49
|
+
return { ...data, current: Object.fromEntries([...categories, 'total'].map(key => [key, current[key]])) }
|
|
50
|
+
}
|
|
51
|
+
function costOf(value) {
|
|
52
|
+
const data = obj(value)
|
|
53
|
+
if (data?.schema !== 1 || data.available !== true || data.source !== 'dsh-cost-meter' || data.currency !== 'USD') return null
|
|
54
|
+
return data
|
|
55
|
+
}
|
|
56
|
+
function metric(label, value, note = '') {
|
|
57
|
+
return `<div class="ins-metric"><span>${esc(label)}</span><strong>${esc(value)}</strong>${note ? `<small>${esc(note)}</small>` : ''}</div>`
|
|
58
|
+
}
|
|
59
|
+
function bar(percent, label) {
|
|
60
|
+
const value = num(percent)
|
|
61
|
+
return value === null ? '' : `<div class="ins-progress" role="progressbar" aria-label="${esc(label)}" aria-valuemin="0" aria-valuemax="100" aria-valuenow="${Math.min(value, 100)}"><span style="width:${Math.min(value, 100)}%"></span></div>`
|
|
62
|
+
}
|
|
63
|
+
function contextHtml(session, detail, detailStatus) {
|
|
64
|
+
const values = obj(session?.projections?.values) || {}, head = contextOf(values.contextTimeline)
|
|
65
|
+
// Only combine a detail with the projection revision that requested it.
|
|
66
|
+
const compatible = detail && (!head || head.detailRev === detail.rev)
|
|
67
|
+
const timeline = head || (compatible ? contextOf(detail.head) : null)
|
|
68
|
+
if (timeline?.unsupported) return `<p class="ins-empty">${esc(t('unsupported'))}</p>`
|
|
69
|
+
if (!timeline) return `<p class="ins-empty">${esc(detailStatus === 'loading' ? t('loading') : detailStatus === 'error' ? t('unavailable') : t('missing'))}</p>`
|
|
70
|
+
const pressure = obj(values.contextPressure), occupied = num(pressure?.pressureTokens) ?? timeline.current.total
|
|
71
|
+
const window = num(pressure?.contextWindow) ?? num(timeline.contextWindow), percent = window > 0 ? occupied / window * 100 : null
|
|
72
|
+
let html = `<section class="ins-section"><div class="ins-section-head"><h3>${esc(t('occupancy'))}</h3><span class="ins-badge">dsh-context</span></div>
|
|
73
|
+
<div class="ins-metrics">${metric(t('occupancy'), tokens(occupied), window > 0 ? '/ ' + tokens(window) + ' tokens' : '')}${metric(t('modelName'), timeline.model || '—', timeline.provider || '')}</div>
|
|
74
|
+
${bar(percent, t('occupancy'))}<p class="ins-note">${esc(t('estimated'))}</p></section>
|
|
75
|
+
<section class="ins-section"><h3>${esc(t('composition'))}</h3><div class="ins-composition">`
|
|
76
|
+
for (const key of categories) {
|
|
77
|
+
const count = timeline.current[key], share = timeline.current.total > 0 ? count / timeline.current.total * 100 : 0
|
|
78
|
+
html += `<div class="ins-category"><span>${esc(t(key))}</span><span class="ins-track"><i style="width:${Math.min(share, 100)}%"></i></span><strong>${tokens(count)}</strong></div>`
|
|
79
|
+
}
|
|
80
|
+
html += '</div></section>'
|
|
81
|
+
const counts = obj(timeline.counts)
|
|
82
|
+
if (counts) html += `<section class="ins-section"><h3>${esc(t('counts'))}</h3><div class="ins-metrics">${['compactions', 'prunes', 'injects'].map((key, i) => metric(t(['compaction', 'prune', 'inject'][i]), tokens(counts[key]))).join('')}</div></section>`
|
|
83
|
+
const events = head?.detailRev !== undefined ? (compatible ? detail.events : []) : timeline.events
|
|
84
|
+
html += `<section class="ins-section"><h3>${esc(t('events'))}</h3>`
|
|
85
|
+
if (head?.detailRev !== undefined && !compatible) html += `<p class="ins-note">${esc(t(detailStatus === 'loading' ? 'detailsLoading' : 'detailsFailed'))}</p>`
|
|
86
|
+
else if (!Array.isArray(events) || !events.length) html += `<p class="ins-note">${esc(t('noEvents'))}</p>`
|
|
87
|
+
else html += '<ol class="ins-events">' + events.filter(row => obj(row) && ['compaction', 'prune', 'inject', 'model', 'mode'].includes(row.kind)).slice(-8).reverse().map(row => `<li><div><strong>${esc(t(row.kind))}</strong><time>${esc(time(row.time))}</time></div><span>${esc(row.name || row.sub || '')}${num(row.tokens) !== null ? ' · ' + tokens(row.tokens) + ' tokens' : ''}</span></li>`).join('') + '</ol>'
|
|
88
|
+
return html + `</section><p class="ins-source">${esc(t('source'))} · dsh-context / DSH</p>`
|
|
89
|
+
}
|
|
90
|
+
function costHtml(raw, phase) {
|
|
91
|
+
const data = costOf(raw)
|
|
92
|
+
if (!data) return `<p class="ins-empty">${esc(t(phase === 'loading' ? 'loading' : phase === 'error' ? 'unavailable' : raw?.available === false ? 'costMissing' : 'unsupported'))}</p>`
|
|
93
|
+
const own = data.session?.found === true ? data.session.own : null
|
|
94
|
+
let html = (phase === 'error' ? `<p role="status" class="ins-note">${esc(t('unavailable'))}</p>` : '') + `<section class="ins-section"><div class="ins-section-head"><h3>${esc(t('cost'))}</h3><span class="ins-badge">dsh-cost-meter</span></div><div class="ins-metrics">
|
|
95
|
+
${metric(t('session'), money(own?.apiCost), own ? '' : t('sessionMissing'))}${metric(t('today'), money(data.today?.apiCost), data.day ? t('date') + ' · ' + data.day : '')}
|
|
96
|
+
${own && num(own.cost) !== null && own.cost !== own.apiCost ? metric(t('equivalent'), money(own.cost)) : ''}
|
|
97
|
+
${num(data.session?.subagentCount) > 0 ? metric(t('subagents'), money(data.session?.subagents?.apiCost)) : ''}</div><p class="ins-note">${esc(t('estimateNote'))}</p></section>`
|
|
98
|
+
if (obj(data.balance)) {
|
|
99
|
+
const balance = data.balance
|
|
100
|
+
html += `<section class="ins-section"><h3>${esc(t('balance'))}</h3><div class="ins-metrics">${metric(t('balance'), balance.status === 'ok' ? money(balance.total, balance.currency, 2) : '—', balance.status === 'ok' ? t('queried') + ' · ' + time(balance.fetchedAt) : t(['off', 'error', 'stale'].includes(balance.status) ? balance.status : 'unknown'))}</div></section>`
|
|
101
|
+
}
|
|
102
|
+
html += `<section class="ins-section"><h3>${esc(t('plans'))}</h3>`
|
|
103
|
+
const plans = Array.isArray(data.plans) ? data.plans.slice(0, 20) : []
|
|
104
|
+
if (!plans.length) html += `<p class="ins-note">${esc(t('noPlans'))}</p>`
|
|
105
|
+
for (const rawPlan of plans) {
|
|
106
|
+
const plan = obj(rawPlan)
|
|
107
|
+
if (!plan) continue
|
|
108
|
+
html += `<div class="ins-plan"><strong>${esc(plan.name)}</strong>`
|
|
109
|
+
if (plan.status !== 'ok') html += `<p class="ins-note">${esc(t(['off', 'error', 'stale'].includes(plan.status) ? plan.status : 'unknown'))}</p>`
|
|
110
|
+
else {
|
|
111
|
+
const rows = Array.isArray(plan.windows) ? plan.windows.slice(0, 8) : []
|
|
112
|
+
if (!rows.length) html += `<p class="ins-note">${esc(t('unknown'))}</p>`
|
|
113
|
+
for (const rawRow of rows) {
|
|
114
|
+
const row = obj(rawRow)
|
|
115
|
+
if (!row) continue
|
|
116
|
+
const percent = num(row.percent)
|
|
117
|
+
html += `<div class="ins-plan-row"><span>${esc(t(row.name))}</span><strong>${row.unlimited === true ? esc(t('unlimited')) : percent !== null && percent <= 100 ? percent.toFixed(1) + '%' : '—'}</strong></div>${row.unlimited === true ? '' : bar(percent, plan.name + ' ' + row.name)}${row.resetsAt ? `<small>${esc(t('reset'))} · ${esc(row.resetsAt)}</small>` : ''}`
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
html += `<small>${esc(t('queried'))} · ${esc(time(plan.fetchedAt))}</small></div>`
|
|
121
|
+
}
|
|
122
|
+
return html + `</section><p class="ins-source">${esc(t('source'))} · dsh-cost-meter<br>${esc(t('assembled'))} · ${esc(time(data.generatedAt))}</p>`
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
let dialog, options, tab = 'context', generation = 0, controller, poll, debounce
|
|
126
|
+
let detail = null, detailStatus = '', cost = null, costStatus = '', detailTask = false, detailRevision
|
|
127
|
+
function current() { return options?.getSession?.() }
|
|
128
|
+
function valid(g) { return g === generation && dialog?.open && options.connection.valid() && current()?.sessionId === options.sessionId }
|
|
129
|
+
function render() {
|
|
130
|
+
if (!dialog?.open) return
|
|
131
|
+
if (!valid(generation)) { close(); return }
|
|
132
|
+
dialog.querySelector('.ins-content').innerHTML = tab === 'context' ? contextHtml(current(), detail, detailStatus) : costHtml(cost, costStatus)
|
|
133
|
+
for (const button of dialog.querySelectorAll('[data-ins-tab]')) {
|
|
134
|
+
const active = button.dataset.insTab === tab
|
|
135
|
+
button.setAttribute('aria-selected', String(active)); button.tabIndex = active ? 0 : -1
|
|
136
|
+
}
|
|
137
|
+
dialog.querySelector('.ins-content').setAttribute('aria-labelledby', 'ins-tab-' + tab)
|
|
138
|
+
}
|
|
139
|
+
async function request(path, body, g) {
|
|
140
|
+
if (!valid(g)) throw new Error('stale')
|
|
141
|
+
const connection = options.connection
|
|
142
|
+
const parent = controller.signal, attempt = new AbortController()
|
|
143
|
+
const abort = () => attempt.abort()
|
|
144
|
+
parent.addEventListener('abort', abort, { once: true })
|
|
145
|
+
const deadline = setTimeout(abort, 25000)
|
|
146
|
+
try {
|
|
147
|
+
const response = await fetch(options.url(path), { method: body ? 'POST' : 'GET', headers: { authorization: 'Bearer ' + connection.token,
|
|
148
|
+
'x-dsh-remote-client': 'web', 'content-type': 'application/json' }, ...(body ? { body: JSON.stringify(body) } : {}), signal: attempt.signal })
|
|
149
|
+
if (!valid(g)) throw new Error('stale')
|
|
150
|
+
if (!response.ok) { const error = new Error('read-failed'); error.status = response.status; throw error }
|
|
151
|
+
const source = await response.text()
|
|
152
|
+
if (!valid(g)) throw new Error('stale')
|
|
153
|
+
if (source.length > 2 * 1024 * 1024) throw new Error('too-large')
|
|
154
|
+
const data = JSON.parse(source)
|
|
155
|
+
if (!obj(data) || data.ok !== true) throw new Error('invalid-response')
|
|
156
|
+
return data
|
|
157
|
+
} finally { clearTimeout(deadline); parent.removeEventListener('abort', abort) }
|
|
158
|
+
}
|
|
159
|
+
async function loadContext(force = false) {
|
|
160
|
+
const g = generation, head = contextOf(current()?.projections?.values?.contextTimeline)
|
|
161
|
+
if (!valid(g) || tab !== 'context' || detailTask || head?.unsupported) return
|
|
162
|
+
if (head && head.detailRev === undefined) { detailStatus = ''; render(); return }
|
|
163
|
+
const revision = head?.detailRev ?? null
|
|
164
|
+
if (!force && detail && detailRevision === revision) return
|
|
165
|
+
detailTask = true; detailStatus = 'loading'; render()
|
|
166
|
+
try {
|
|
167
|
+
const data = await request('/api/dsh-context/detail', { sessionId: options.sessionId }, g)
|
|
168
|
+
if (!valid(g)) return
|
|
169
|
+
const next = obj(data.value), newest = contextOf(current()?.projections?.values?.contextTimeline)
|
|
170
|
+
if (next && num(next.rev) !== null && Array.isArray(next.events) && contextOf(next.head)) {
|
|
171
|
+
if (newest?.detailRev !== undefined && newest.detailRev !== next.rev) { detailStatus = 'changed'; return }
|
|
172
|
+
detail = { rev: next.rev, head: next.head, events: next.events }; detailRevision = newest?.detailRev ?? null; detailStatus = ''
|
|
173
|
+
} else if (next === null) detailStatus = 'missing'
|
|
174
|
+
else detailStatus = 'error'
|
|
175
|
+
} catch (error) { if (valid(g)) detailStatus = error.status === 404 ? 'missing' : 'error' }
|
|
176
|
+
finally {
|
|
177
|
+
if (g === generation) { detailTask = false; render(); if (detailStatus === 'changed') scheduleContext() }
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
async function loadCost() {
|
|
181
|
+
const g = generation
|
|
182
|
+
if (!valid(g) || tab !== 'cost' || costStatus === 'loading') return
|
|
183
|
+
costStatus = 'loading'; render()
|
|
184
|
+
try {
|
|
185
|
+
const data = await request('/remote/api/insights/cost?sessionId=' + encodeURIComponent(options.sessionId), null, g)
|
|
186
|
+
if (!valid(g)) return
|
|
187
|
+
if (data.available === false || costOf(data)) { cost = data; costStatus = '' }
|
|
188
|
+
else costStatus = 'error'
|
|
189
|
+
} catch (error) { if (valid(g)) { costStatus = 'error'; if (error.status === 404) { cost = { available: false }; costStatus = '' } } }
|
|
190
|
+
finally { if (valid(g)) render() }
|
|
191
|
+
}
|
|
192
|
+
function scheduleContext() { clearTimeout(debounce); debounce = setTimeout(() => loadContext(), 400) }
|
|
193
|
+
function close() {
|
|
194
|
+
generation++; controller?.abort(); clearInterval(poll); clearTimeout(debounce)
|
|
195
|
+
if (dialog?.open) dialog.close()
|
|
196
|
+
options = null; detail = null; cost = null; detailTask = false; detailStatus = ''; costStatus = ''
|
|
197
|
+
}
|
|
198
|
+
function open(opts) {
|
|
199
|
+
close(); options = opts; tab = opts.tab === 'cost' ? 'cost' : 'context'; lang = root.I18N?.lang === 'en' ? 'en' : 'zh'
|
|
200
|
+
options.sessionId = current()?.sessionId
|
|
201
|
+
if (!options.sessionId) return
|
|
202
|
+
controller = new AbortController(); detailRevision = undefined
|
|
203
|
+
if (!dialog) {
|
|
204
|
+
dialog = document.createElement('dialog'); dialog.className = 'ins-dialog'; document.body.append(dialog)
|
|
205
|
+
dialog.addEventListener('cancel', event => { event.preventDefault(); close() })
|
|
206
|
+
dialog.addEventListener('close', () => { if (options && !dialog.open) close() })
|
|
207
|
+
}
|
|
208
|
+
dialog.innerHTML = `<header class="ins-header"><div><h2 id="ins-title">${esc(t('title'))}</h2><p>${esc(opts.getTitle?.() || options.sessionId)}</p></div><button type="button" data-ins-close aria-label="${esc(t('close'))}">×</button></header>
|
|
209
|
+
<nav class="ins-tabs" role="tablist" aria-label="${esc(t('title'))}"><button type="button" role="tab" id="ins-tab-context" data-ins-tab="context" aria-controls="ins-content">${esc(t('context'))}</button><button type="button" role="tab" id="ins-tab-cost" data-ins-tab="cost" aria-controls="ins-content">${esc(t('cost'))}</button><button type="button" data-ins-refresh>${esc(t('refresh'))}</button></nav><div id="ins-content" class="ins-content" role="tabpanel" tabindex="0"></div>`
|
|
210
|
+
dialog.setAttribute('aria-labelledby', 'ins-title')
|
|
211
|
+
dialog.querySelector('[data-ins-close]').addEventListener('click', close)
|
|
212
|
+
function select(value) { tab = value; render(); tab === 'cost' ? loadCost() : loadContext() }
|
|
213
|
+
dialog.querySelectorAll('[data-ins-tab]').forEach(button => {
|
|
214
|
+
button.addEventListener('click', () => select(button.dataset.insTab))
|
|
215
|
+
button.addEventListener('keydown', event => {
|
|
216
|
+
if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return
|
|
217
|
+
event.preventDefault(); select(event.key === 'Home' ? 'context' : event.key === 'End' ? 'cost' : tab === 'context' ? 'cost' : 'context')
|
|
218
|
+
dialog.querySelector('[data-ins-tab="' + tab + '"]').focus()
|
|
219
|
+
})
|
|
220
|
+
})
|
|
221
|
+
dialog.querySelector('[data-ins-refresh]').addEventListener('click', () => tab === 'cost' ? loadCost() : loadContext(true))
|
|
222
|
+
dialog.showModal(); render(); tab === 'cost' ? loadCost() : loadContext()
|
|
223
|
+
poll = setInterval(() => { if (!valid(generation)) close(); else if (tab === 'cost') loadCost(); else { render(); loadContext() } }, 60000)
|
|
224
|
+
}
|
|
225
|
+
const api = { open, close, update() { if (dialog?.open) { render(); if (dialog.open && tab === 'context') scheduleContext() } }, contextOf, costOf, contextHtml, costHtml }
|
|
226
|
+
root.DshInsights = api
|
|
227
|
+
if (typeof module !== 'undefined') module.exports = api
|
|
228
|
+
})(typeof window !== 'undefined' ? window : globalThis)
|
package/public/plugin-center.css
CHANGED
|
@@ -1 +1,89 @@
|
|
|
1
|
-
|
|
1
|
+
/* Shared plugin manager: theme tokens, readable cards and compact mobile controls. */
|
|
2
|
+
.pc-dialog{width:min(920px,94vw);max-height:88dvh;box-sizing:border-box;border:1px solid var(--dsr-line,#30363d);border-radius:20px;background:var(--dsr-bg,#0d1117);color:var(--dsr-text,#eee);padding:26px;overflow:auto}
|
|
3
|
+
.pc-dialog *,.pc-dialog *::before,.pc-dialog *::after{box-sizing:border-box}
|
|
4
|
+
.pc-dialog::backdrop{background:var(--dsr-overlay,#0009)}
|
|
5
|
+
.pc-dialog [hidden]{display:none!important}
|
|
6
|
+
.pc-dialog h2,.pc-dialog h3,.pc-dialog p{margin:0}
|
|
7
|
+
.pc-dialog h2{font-size:26px;letter-spacing:-.5px;line-height:1.3}
|
|
8
|
+
.pc-dialog h3{font-size:16px;line-height:1.4;overflow-wrap:anywhere}
|
|
9
|
+
.pc-dialog p{line-height:1.6;overflow-wrap:anywhere}
|
|
10
|
+
.pc-dialog button,.pc-dialog input,.pc-dialog select{font:inherit;font-size:13px;color:inherit;border:1px solid var(--dsr-line,#30363d);border-radius:10px;padding:10px 13px;background:var(--dsr-panel,#161b22)}
|
|
11
|
+
.pc-dialog button{cursor:pointer;transition:background .15s,border-color .15s}
|
|
12
|
+
.pc-dialog button:hover{background:var(--dsr-panel-2,#21262d);border-color:var(--dsr-muted,#888)}
|
|
13
|
+
.pc-dialog button:disabled{opacity:.45;cursor:not-allowed}
|
|
14
|
+
.pc-dialog :is(button,input,select):focus-visible{outline:2px solid var(--dsr-accent,#58a6ff);outline-offset:3px}
|
|
15
|
+
.pc-dialog input{min-width:0;max-width:100%}
|
|
16
|
+
.pc-dialog a{color:var(--dsr-accent-strong,#79c0ff)}
|
|
17
|
+
.pc-dialog small{font-size:12px;color:var(--dsr-muted,#a7b0bc)}
|
|
18
|
+
.pc-heading{display:flex;justify-content:space-between;align-items:flex-start;gap:16px}
|
|
19
|
+
.pc-intro{margin-top:7px!important;font-size:13px;color:var(--dsr-muted,#a7b0bc)}
|
|
20
|
+
.pc-context{display:flex;align-items:center;justify-content:space-between;gap:16px;margin:22px 0}
|
|
21
|
+
.pc-profile{font-size:12px;color:var(--dsr-muted,#a7b0bc)}
|
|
22
|
+
.pc-profile::before{content:'';display:inline-block;width:6px;height:6px;border-radius:50%;background:var(--dsr-accent,#58a6ff);margin-right:7px;vertical-align:2px}
|
|
23
|
+
.pc-summary{display:flex;align-items:center;gap:20px}
|
|
24
|
+
.pc-stat{display:inline-flex;align-items:baseline;gap:7px;font-size:12px;color:var(--dsr-muted,#a7b0bc)}
|
|
25
|
+
.pc-stat strong{color:var(--dsr-text,#eee);font-size:20px;font-weight:650;font-variant-numeric:tabular-nums}
|
|
26
|
+
.pc-status,.pc-message:not(:empty),.pc-recovery:not(:empty){border:1px solid var(--dsr-warning-line,#ff9e6466);border-radius:12px;padding:12px 14px;margin:0 0 16px!important;font-size:13px;background:var(--dsr-warning-soft,#ff9e6421);color:var(--dsr-text,#eee)}
|
|
27
|
+
.pc-message:empty,.pc-detail:empty,.pc-recovery:empty{display:none}
|
|
28
|
+
.pc-recovery button{margin-top:10px}
|
|
29
|
+
.pc-controls{border:1px solid var(--dsr-line,#30363d);border-radius:14px;padding:12px;margin-bottom:20px;background:var(--dsr-panel,#161b22)}
|
|
30
|
+
.pc-toolbar{display:flex;align-items:center;justify-content:space-between;gap:12px}
|
|
31
|
+
.pc-tabs{display:flex;gap:3px;min-width:0;padding:3px;border-radius:10px;background:var(--dsr-bg-2,#11161e)}
|
|
32
|
+
.pc-tabs button{white-space:nowrap;padding:8px 14px;background:transparent;border-color:transparent;color:var(--dsr-muted,#a7b0bc);border-radius:8px}
|
|
33
|
+
.pc-tabs button.active{background:var(--dsr-accent-soft,#58a6ff1f);color:var(--dsr-accent-strong,#79c0ff)}
|
|
34
|
+
.pc-dialog .pc-refresh{border-color:transparent;background:transparent;color:var(--dsr-muted,#a7b0bc);padding:8px;flex-shrink:0}
|
|
35
|
+
.pc-filters:not([hidden]),.pc-market-tools:not([hidden]){display:flex;gap:10px;margin-top:12px}
|
|
36
|
+
.pc-filters input,.pc-search{flex:1;width:0}
|
|
37
|
+
.pc-filters select{flex-shrink:0;max-width:42%}
|
|
38
|
+
.pc-market-tools{flex-wrap:wrap}
|
|
39
|
+
.pc-market-tools .pc-search{flex-basis:160px}
|
|
40
|
+
.pc-list{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,310px),1fr));gap:14px}
|
|
41
|
+
.pc-card{min-width:0;border:1px solid var(--dsr-line,#30363d);border-radius:16px;padding:18px;background:var(--dsr-panel,#161b22)}
|
|
42
|
+
.pc-card-head{display:flex;align-items:center;gap:12px;min-height:40px}
|
|
43
|
+
.pc-card-head h3{flex:1;min-width:0}
|
|
44
|
+
.pc-plugin-icon{display:flex;justify-content:center;align-items:center;flex:0 0 40px;height:40px;font-size:17px;font-weight:600;border-radius:12px;background:var(--dsr-accent-soft,#58a6ff1f);color:var(--dsr-accent-strong,#79c0ff)}
|
|
45
|
+
.pc-description{margin-top:14px!important;font-size:13px;color:var(--dsr-muted,#a7b0bc);display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
|
|
46
|
+
.pc-package{display:block;margin-top:7px;font-family:ui-monospace,monospace;overflow-wrap:anywhere;font-size:12px!important}
|
|
47
|
+
.pc-meta{display:flex;align-items:center;flex-wrap:wrap;gap:7px;margin:14px 0 16px}
|
|
48
|
+
.pc-badge,.pc-version{font-size:11px;border-radius:6px;padding:4px 7px;line-height:1.2;background:var(--dsr-panel-2,#21262d);color:var(--dsr-muted,#a7b0bc)}
|
|
49
|
+
.pc-badge.is-active{background:var(--dsr-success-soft,#4bcf7e21);color:var(--dsr-success,#4bcf7e)}
|
|
50
|
+
.pc-badge.is-error{background:var(--dsr-error-soft,#ff8f6b24);color:var(--dsr-error,#ff8f6b)}
|
|
51
|
+
.pc-dialog .pc-toggle{position:relative;flex:0 0 42px;width:42px;height:26px;min-height:0;padding:0;border-radius:20px;border:1px solid var(--dsr-line,#30363d);background:var(--dsr-panel-2,#21262d);font-size:0}
|
|
52
|
+
.pc-toggle::after{content:'';position:absolute;top:4px;left:4px;width:16px;height:16px;border-radius:50%;background:var(--dsr-muted,#a7b0bc);transition:transform .15s}
|
|
53
|
+
.pc-dialog .pc-toggle[aria-checked=true]{background:var(--dsr-accent,#58a6ff);border-color:var(--dsr-accent,#58a6ff)}
|
|
54
|
+
.pc-toggle[aria-checked=true]::after{transform:translateX(16px);background:var(--dsr-on-accent,#07111f)}
|
|
55
|
+
.pc-card-actions{display:flex;align-items:center;gap:6px;border-top:1px solid var(--dsr-line,#30363d);padding-top:12px;margin-top:auto}
|
|
56
|
+
.pc-card-actions button{border-color:transparent;background:transparent;font-size:12px;padding:8px 10px}
|
|
57
|
+
.pc-card-actions .pc-detail-button{background:var(--dsr-accent-soft,#58a6ff1f);color:var(--dsr-accent-strong,#79c0ff);margin-right:auto}
|
|
58
|
+
.pc-card-actions .pc-danger-button{color:var(--dsr-muted,#a7b0bc)}
|
|
59
|
+
.pc-card-actions .pc-danger-button:hover{background:var(--dsr-error-soft,#ff8f6b24);color:var(--dsr-error,#ff8f6b);border-color:transparent}
|
|
60
|
+
.pc-list>.pc-card{display:flex;flex-direction:column}
|
|
61
|
+
.pc-detail .pc-card{margin-bottom:18px;border-color:var(--dsr-accent-line,#58a6ff66)}
|
|
62
|
+
.pc-detail .pc-description{display:block}
|
|
63
|
+
.pc-detail .pc-card>p,.pc-detail .pc-card>h4{margin:14px 0 0;font-size:13px}
|
|
64
|
+
.pc-detail .pc-card>button,.pc-detail .pc-card>input,.pc-detail .pc-card>a{margin:16px 8px 0 0}
|
|
65
|
+
.pc-dialog button.pc-primary{background:var(--dsr-accent,#58a6ff);color:var(--dsr-on-accent,#07111f);border-color:transparent}
|
|
66
|
+
.pc-dialog button.pc-danger-confirm{background:var(--dsr-error-soft,#ff8f6b24);color:var(--dsr-error,#ff8f6b);border-color:var(--dsr-error-line,#ff8f6b66)}
|
|
67
|
+
.pc-empty{grid-column:1/-1;padding:30px 16px;text-align:center;border:1px dashed var(--dsr-line,#30363d);border-radius:14px;font-size:13px;color:var(--dsr-muted,#a7b0bc)}
|
|
68
|
+
.pc-list>details,.pc-market-note{grid-column:1/-1}
|
|
69
|
+
.pc-market-note{font-size:12px;color:var(--dsr-muted,#a7b0bc);margin-bottom:4px!important}
|
|
70
|
+
.pc-dialog details{font-size:13px;padding:12px 0}
|
|
71
|
+
.pc-dialog summary{cursor:pointer;overflow-wrap:anywhere;color:var(--dsr-muted,#a7b0bc)}
|
|
72
|
+
.pc-runtime,.pc-history{border-top:1px solid var(--dsr-line,#30363d);margin-top:6px}
|
|
73
|
+
.pc-history{margin-top:16px}
|
|
74
|
+
.pc-dialog details p{margin-top:10px;font-size:12px}
|
|
75
|
+
.pc-jobs>details{margin-top:12px;padding:12px;border-radius:10px;background:var(--dsr-panel,#161b22)}
|
|
76
|
+
.pc-dialog pre{white-space:pre-wrap;overflow-wrap:anywhere;max-height:240px;overflow:auto;font-size:12px}
|
|
77
|
+
.pc-page{width:100%;max-height:none;border:0;border-radius:0;background:transparent;padding:12px 0 24px;overflow:visible}
|
|
78
|
+
.plugins-view{padding-top:8px}
|
|
79
|
+
@media(max-width:520px){
|
|
80
|
+
.pc-dialog{padding:18px}.pc-page{padding:6px 0 24px}.pc-dialog h2{font-size:24px}
|
|
81
|
+
.pc-context{align-items:flex-start;flex-direction:column;gap:10px;margin:18px 0}.pc-summary{gap:24px}
|
|
82
|
+
.pc-controls{padding:10px;margin-bottom:16px}.pc-toolbar{gap:4px}.pc-tabs{flex:1}
|
|
83
|
+
.pc-tabs button{flex:1;padding:9px 8px;font-size:12px}
|
|
84
|
+
.pc-filters:not([hidden]),.pc-market-tools:not([hidden]){gap:7px}
|
|
85
|
+
.pc-dialog input,.pc-dialog select{font-size:12px;padding:11px 9px}
|
|
86
|
+
.pc-card{padding:16px}.pc-card-actions button{min-height:40px;padding:9px 10px}
|
|
87
|
+
.pc-dialog .pc-toggle::before{content:'';position:absolute;inset:-9px -3px}
|
|
88
|
+
}
|
|
89
|
+
@media(prefers-reduced-motion:reduce){.pc-dialog button,.pc-toggle::after{transition:none}}
|
package/public/plugin-center.js
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
'use strict';
|
|
3
3
|
window.DshPluginCenter = (() => {
|
|
4
4
|
let dialog, connection, snapshot, timer, generation = 0, pending = false, tab = 'installed', offset = 0, searchSequence = 0, detailSequence = 0
|
|
5
|
-
let renderedInstalled = '', renderedJobs = ''
|
|
6
|
-
const labels = { install: '安装', update: '更新', remove: '卸载', enable: '启用', disable: '停用', queued: '等待执行', running: '执行中', complete: '已完成', failed: '失败' }
|
|
5
|
+
let renderedInstalled = '', renderedJobs = '', filter = '', statusFilter = 'all', refreshSequence = 0
|
|
6
|
+
const labels = { install: '安装', update: '更新', remove: '卸载', enable: '启用', disable: '停用', queued: '等待执行', running: '执行中', complete: '已完成', failed: '失败', interrupted: '已中断', active: '运行中', pending: '等待依赖', loading: '加载中', disposed: '未运行', unloading: '卸载中', inactive: '未运行' }
|
|
7
7
|
function node(tag, text, className) {
|
|
8
8
|
const el = document.createElement(tag)
|
|
9
9
|
if (text != null) el.textContent = text
|
|
@@ -14,7 +14,7 @@ window.DshPluginCenter = (() => {
|
|
|
14
14
|
const el = node('button', text)
|
|
15
15
|
el.type = 'button'; el.addEventListener('click', () => {
|
|
16
16
|
const current = generation
|
|
17
|
-
Promise.resolve().then(action).catch(error => { if (current === generation) message(error.message) })
|
|
17
|
+
Promise.resolve().then(() => { if (current === generation) return action() }).catch(error => { if (current === generation) message(error.message) })
|
|
18
18
|
})
|
|
19
19
|
return el
|
|
20
20
|
}
|
|
@@ -35,37 +35,101 @@ window.DshPluginCenter = (() => {
|
|
|
35
35
|
}
|
|
36
36
|
function card(name, description) {
|
|
37
37
|
const el = node('article', null, 'pc-card')
|
|
38
|
-
|
|
38
|
+
const header = node('div', null, 'pc-card-head')
|
|
39
|
+
const icon = node('span', (name || 'P').replace(/^@/, '').slice(0, 1).toUpperCase(), 'pc-plugin-icon')
|
|
40
|
+
icon.setAttribute('aria-hidden', 'true')
|
|
41
|
+
header.append(icon, node('h3', name)); el.append(header)
|
|
42
|
+
if (description) el.append(node('p', description, 'pc-description'))
|
|
39
43
|
return el
|
|
40
44
|
}
|
|
41
45
|
function renderInstalled() {
|
|
42
46
|
const list = dialog.querySelector('.pc-list'); list.replaceChildren()
|
|
43
|
-
if (!snapshot
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
+
if (!snapshot) { list.append(node('p', '尚未读取插件状态,请点击刷新。')); return }
|
|
48
|
+
const rows = snapshot.items.filter(item => tab === 'builtin' ? !item.managed : item.managed)
|
|
49
|
+
.filter(item => [item.name, item.displayName, item.description].join(' ').toLowerCase().includes(filter.toLowerCase()))
|
|
50
|
+
.filter(item => statusFilter === 'all' || (statusFilter === 'enabled' ? item.enabled : !item.enabled))
|
|
51
|
+
if (!rows.length) list.append(node('p', filter || statusFilter !== 'all' ? '没有匹配的插件。' : tab === 'builtin' ? '暂无内置插件。' : '还没有安装其他插件,点击“添加插件”查看详情并安装。', 'pc-empty'))
|
|
52
|
+
for (const item of rows) {
|
|
53
|
+
const el = card(item.displayName || item.name, item.description)
|
|
54
|
+
el.append(node('small', item.name, 'pc-package'))
|
|
55
|
+
const meta = node('div', null, 'pc-meta')
|
|
56
|
+
meta.append(node('span', item.version ? 'v' + item.version : item.requested || '内置', 'pc-version'))
|
|
57
|
+
if (!item.bundle || !item.managed || !snapshot.writable) meta.append(node('span', item.enabled ? '配置启用' : '配置停用', 'pc-badge ' + (item.enabled ? 'is-active' : '')))
|
|
58
|
+
const runtime = item.runtime || []
|
|
59
|
+
if (runtime.some(entry => entry.phase === 'failed')) meta.append(node('span', '运行异常', 'pc-badge is-error'))
|
|
60
|
+
else if (runtime.length) meta.append(node('span', `${runtime.filter(entry => entry.phase === 'active').length}/${runtime.length} 组件运行中`, 'pc-badge'))
|
|
61
|
+
el.append(meta)
|
|
62
|
+
const actions = node('div', null, 'pc-card-actions')
|
|
63
|
+
const detail = button('查看详情', () => installedDetails(item)); detail.className = 'pc-detail-button'
|
|
64
|
+
actions.append(detail)
|
|
47
65
|
if (item.managed && snapshot.writable) {
|
|
48
|
-
const actions = node('div', null, 'pc-actions')
|
|
49
66
|
actions.append(button('查看更新', () => showDetails(item.name)))
|
|
50
|
-
if (item.bundle)
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
67
|
+
if (item.bundle) {
|
|
68
|
+
const toggle = button(item.enabled ? '已启用' : '已停用', () => mutate(item.enabled ? 'disable' : 'enable', item.name))
|
|
69
|
+
toggle.className = 'pc-toggle'; toggle.disabled = pending || snapshot.busy
|
|
70
|
+
toggle.setAttribute('role', 'switch'); toggle.setAttribute('aria-checked', String(item.enabled)); toggle.setAttribute('aria-label', '启用 ' + item.name)
|
|
71
|
+
toggle.setAttribute('title', item.enabled ? '配置已启用' : '配置已停用')
|
|
72
|
+
el.querySelector('.pc-card-head').append(toggle)
|
|
73
|
+
}
|
|
74
|
+
const remove = button('卸载', () => mutate('remove', item.name)); remove.className = 'pc-danger-button'; actions.append(remove)
|
|
75
|
+
for (const action of actions.children) if (action !== detail) action.disabled = pending || snapshot.busy
|
|
76
|
+
} else actions.append(node('small', '主机维护 · 只读'))
|
|
77
|
+
el.append(actions)
|
|
55
78
|
list.append(el)
|
|
56
79
|
}
|
|
57
|
-
const runtime = node('details')
|
|
80
|
+
const runtime = node('details', null, 'pc-runtime')
|
|
58
81
|
runtime.append(node('summary', '当前进程实际加载状态 (' + snapshot.runtime.length + ')'))
|
|
59
82
|
if (!snapshot.runtimeAvailable) runtime.append(node('p', '此 DSH 版本未提供加载状态'))
|
|
60
83
|
for (const entry of snapshot.runtime) runtime.append(node('p', entry.name + ' · ' + entry.phase + (entry.enabled ? '' : ' · disabled')))
|
|
61
84
|
list.append(runtime)
|
|
62
85
|
}
|
|
86
|
+
function installedDetails(item) {
|
|
87
|
+
detailSequence++
|
|
88
|
+
const area = dialog.querySelector('.pc-detail')
|
|
89
|
+
const el = card(item.displayName || item.name, item.description)
|
|
90
|
+
el.append(node('p', item.name + ' · ' + (item.version || item.requested || '内置')), node('p', '配置:' + (item.enabled ? '启用' : '停用')))
|
|
91
|
+
const entries = item.runtime || []
|
|
92
|
+
el.append(node('h4', '组件运行状态'))
|
|
93
|
+
if (!entries.length) el.append(node('p', '未获得该包的组件归属。可在下方查看当前进程全部加载状态。'))
|
|
94
|
+
for (const entry of entries) el.append(node('p', entry.name + ' · ' + (labels[entry.phase] || entry.phase)))
|
|
95
|
+
if (!item.managed) el.append(node('p', '随 DSH 提供的核心插件由主机维护。'))
|
|
96
|
+
el.append(button('返回插件列表', () => area.replaceChildren()))
|
|
97
|
+
area.replaceChildren(el); area.scrollIntoView({ block: 'nearest' })
|
|
98
|
+
}
|
|
99
|
+
function renderTabs() {
|
|
100
|
+
for (const button of dialog.querySelectorAll('[data-pc-tab]')) {
|
|
101
|
+
button.classList.toggle('active', button.dataset.pcTab === tab)
|
|
102
|
+
button.setAttribute('aria-pressed', String(button.dataset.pcTab === tab))
|
|
103
|
+
}
|
|
104
|
+
}
|
|
63
105
|
async function refresh() {
|
|
64
|
-
|
|
106
|
+
const sequence = ++refreshSequence
|
|
107
|
+
let data
|
|
108
|
+
try { data = await api('/state') } catch (error) { if (sequence === refreshSequence) throw error; return }
|
|
109
|
+
if (sequence !== refreshSequence) return
|
|
110
|
+
snapshot = data
|
|
111
|
+
const recovery = dialog.querySelector('.pc-recovery'); recovery.replaceChildren()
|
|
112
|
+
if (snapshot.recovery) {
|
|
113
|
+
recovery.append(node('p', snapshot.recovery.reason))
|
|
114
|
+
const action = button('恢复旧任务', () => {
|
|
115
|
+
const body = { id: snapshot.recovery.id, revision: snapshot.revision, confirmNoRunningProcess: true }
|
|
116
|
+
const review = card('解除旧任务锁', '请先在主机确认没有正在运行的插件安装或卸载进程。此操作保留文件和备份,不执行回滚。')
|
|
117
|
+
review.append(button('已确认,解除锁定', () => executeMutation(body, '/recover')), button('取消', () => dialog.querySelector('.pc-detail').replaceChildren()))
|
|
118
|
+
dialog.querySelector('.pc-detail').replaceChildren(review)
|
|
119
|
+
})
|
|
120
|
+
action.disabled = !snapshot.writable || pending; recovery.append(action)
|
|
121
|
+
}
|
|
65
122
|
dialog.querySelector('.pc-profile').textContent = snapshot.profile ? '当前环境:' + snapshot.profile : '当前环境无法识别'
|
|
66
123
|
dialog.querySelector('.pc-status').textContent = snapshot.reason || (snapshot.pendingRestart ? '插件配置已变更,重启 DSH 后生效。' : '安装到当前连接的 DSH 主机。')
|
|
67
|
-
|
|
68
|
-
|
|
124
|
+
dialog.querySelector('.pc-status').hidden = !snapshot.reason && !snapshot.pendingRestart
|
|
125
|
+
const summary = dialog.querySelector('.pc-summary'); summary.replaceChildren()
|
|
126
|
+
for (const [count, title] of [[snapshot.items.filter(item => item.managed).length, '已安装'], [snapshot.items.filter(item => item.managed && item.enabled).length, '配置启用']]) {
|
|
127
|
+
const stat = node('span', null, 'pc-stat'); stat.append(node('strong', String(count)), node('span', title)); summary.append(stat)
|
|
128
|
+
}
|
|
129
|
+
if (snapshot.busy) summary.append(node('span', '任务执行中', 'pc-badge'))
|
|
130
|
+
const installedKey = JSON.stringify([snapshot.items, snapshot.runtime, snapshot.pendingRestart, snapshot.busy, snapshot.writable, pending, tab, filter, statusFilter])
|
|
131
|
+
if (tab !== 'market' && installedKey !== renderedInstalled) { renderInstalled(); renderedInstalled = installedKey }
|
|
132
|
+
renderTabs()
|
|
69
133
|
const jobsKey = JSON.stringify(snapshot.operations)
|
|
70
134
|
if (jobsKey === renderedJobs) return
|
|
71
135
|
renderedJobs = jobsKey
|
|
@@ -88,11 +152,14 @@ window.DshPluginCenter = (() => {
|
|
|
88
152
|
const list = dialog.querySelector('.pc-list'); list.replaceChildren(node('p', '正在查询 npm 插件目录…'))
|
|
89
153
|
const data = await api('/market?q=' + encodeURIComponent(q) + '&offset=' + offset)
|
|
90
154
|
if (tab !== 'market' || sequence !== searchSequence) return
|
|
91
|
-
list.replaceChildren(node('p', '来源:npm · 安装前校验 DSH bundle。目录收录不代表安全或兼容性审核。'))
|
|
155
|
+
list.replaceChildren(node('p', '来源:npm · 安装前校验 DSH bundle。目录收录不代表安全或兼容性审核。', 'pc-market-note'))
|
|
92
156
|
if (!data.items.length) list.append(node('p', '没有找到插件;也可直接输入完整 npm 包名后查看详情。'))
|
|
93
157
|
for (const item of data.items) {
|
|
94
158
|
const el = card(item.name, item.description)
|
|
95
|
-
|
|
159
|
+
const meta = node('div', null, 'pc-meta'); meta.append(node('span', item.version ? 'v' + item.version : '版本未声明', 'pc-version'))
|
|
160
|
+
const actions = node('div', null, 'pc-card-actions')
|
|
161
|
+
const detail = button('查看详情', () => showDetails(item.name)); detail.className = 'pc-detail-button'; actions.append(detail)
|
|
162
|
+
el.append(meta, actions)
|
|
96
163
|
list.append(el)
|
|
97
164
|
}
|
|
98
165
|
if (offset > 0) list.append(button('上一页', () => { offset -= 20; return search() }))
|
|
@@ -127,55 +194,97 @@ window.DshPluginCenter = (() => {
|
|
|
127
194
|
const area = dialog.querySelector('.pc-detail')
|
|
128
195
|
const review = card('确认' + labels[action], name + (version ? '@' + version : ''))
|
|
129
196
|
review.append(node('p', '目标环境:' + snapshot.profile + '。配置修改后需要重启 DSH;不会自动重启。'))
|
|
130
|
-
|
|
197
|
+
const confirm = button('确认' + labels[action], () => executeMutation(body)); confirm.className = action === 'remove' ? 'pc-danger-confirm' : 'pc-primary'
|
|
198
|
+
review.append(confirm, button('取消', () => area.replaceChildren()))
|
|
131
199
|
area.replaceChildren(review); area.scrollIntoView({ block: 'nearest' })
|
|
132
200
|
}
|
|
133
|
-
async function executeMutation(body) {
|
|
201
|
+
async function executeMutation(body, endpoint = '/operations') {
|
|
134
202
|
if (pending) return
|
|
135
203
|
const current = generation
|
|
136
204
|
pending = true
|
|
137
205
|
try {
|
|
138
206
|
message('正在提交…')
|
|
139
|
-
await api(
|
|
207
|
+
await api(endpoint, body)
|
|
140
208
|
message('已受理,可在操作记录查看结果。')
|
|
141
209
|
dialog.querySelector('.pc-detail').replaceChildren()
|
|
142
210
|
} catch (error) {
|
|
143
211
|
if (current !== generation) return
|
|
144
212
|
message(error.message + ';请先查看操作记录,避免重复提交。')
|
|
145
|
-
const retry = button('重试同一请求', () => executeMutation(body))
|
|
213
|
+
const retry = button('重试同一请求', () => executeMutation(body, endpoint))
|
|
146
214
|
dialog.querySelector('.pc-detail').replaceChildren(retry)
|
|
147
215
|
} finally { if (current === generation) { pending = false; await refresh() } }
|
|
148
216
|
}
|
|
149
|
-
|
|
217
|
+
function close() {
|
|
218
|
+
generation++; searchSequence++; detailSequence++; clearTimeout(timer)
|
|
219
|
+
if (!dialog) return
|
|
220
|
+
if (dialog.tagName === 'DIALOG') dialog.close()
|
|
221
|
+
else { dialog.open = false; dialog.remove() }
|
|
222
|
+
dialog = null
|
|
223
|
+
}
|
|
224
|
+
async function open(config, target) {
|
|
225
|
+
close()
|
|
150
226
|
if (!dialog) {
|
|
151
|
-
dialog = node('dialog', null, 'pc-dialog')
|
|
152
|
-
const heading = node('div', null, 'pc-heading')
|
|
153
|
-
const
|
|
154
|
-
|
|
227
|
+
dialog = node(target ? 'div' : 'dialog', null, 'pc-dialog' + (target ? ' pc-page' : ''))
|
|
228
|
+
const heading = node('div', null, 'pc-heading')
|
|
229
|
+
const title = node('div', null, 'pc-title'); title.append(node('h2', '插件管理'), node('p', '管理当前主机的插件与扩展', 'pc-intro')); heading.append(title)
|
|
230
|
+
if (!target) heading.append(button('关闭', close))
|
|
231
|
+
const controls = node('div', null, 'pc-controls')
|
|
232
|
+
const toolbar = node('div', null, 'pc-tabs')
|
|
233
|
+
for (const [value, title] of [['installed', '已安装'], ['market', '添加插件'], ['builtin', '内置插件']]) {
|
|
234
|
+
const control = button(title, () => {
|
|
235
|
+
tab = value; detailSequence++; renderedInstalled = ''; dialog.querySelector('.pc-detail').replaceChildren()
|
|
236
|
+
renderTabs(); message('')
|
|
237
|
+
dialog.querySelector('.pc-market-tools').hidden = value !== 'market'; dialog.querySelector('.pc-filters').hidden = value === 'market'
|
|
238
|
+
if (value === 'market') { offset = 0; return search() }
|
|
239
|
+
return refresh()
|
|
240
|
+
})
|
|
241
|
+
control.dataset.pcTab = value; toolbar.append(control)
|
|
242
|
+
}
|
|
243
|
+
const refreshButton = button('刷新', () => tab === 'market' ? search() : refresh()); refreshButton.className = 'pc-refresh'
|
|
244
|
+
const toolbarRow = node('div', null, 'pc-toolbar'); toolbarRow.append(toolbar, refreshButton)
|
|
245
|
+
const filters = node('div', null, 'pc-filters')
|
|
246
|
+
const query = node('input'); query.placeholder = '筛选已安装插件'; query.setAttribute('aria-label', '筛选已安装插件')
|
|
247
|
+
query.addEventListener('input', () => { filter = query.value.trim(); renderInstalled() })
|
|
248
|
+
const select = node('select'); select.setAttribute('aria-label', '按配置状态筛选')
|
|
249
|
+
for (const [value, title] of [['all', '全部状态'], ['enabled', '配置启用'], ['disabled', '配置停用']]) { const option = node('option', title); option.value = value; select.append(option) }
|
|
250
|
+
select.addEventListener('change', () => { statusFilter = select.value; renderInstalled() })
|
|
251
|
+
filters.append(query, select)
|
|
155
252
|
const market = node('form', null, 'pc-market-tools'); market.hidden = true
|
|
156
253
|
const input = node('input', null, 'pc-search'); input.placeholder = '搜索插件或输入完整 npm 包名'; input.maxLength = 80; input.setAttribute('aria-label', '搜索插件或 npm 包名')
|
|
157
254
|
market.append(input, button('搜索', () => { offset = 0; return search() }), button('按包名查看', () => showDetails(input.value.trim())))
|
|
158
|
-
market.addEventListener('submit', event => {
|
|
159
|
-
|
|
255
|
+
market.addEventListener('submit', event => {
|
|
256
|
+
event.preventDefault(); offset = 0
|
|
257
|
+
const current = generation
|
|
258
|
+
search().catch(error => { if (current === generation) message(error.message) })
|
|
259
|
+
})
|
|
260
|
+
const jobs = node('details', null, 'pc-history'); jobs.append(node('summary', '操作记录'), node('div', null, 'pc-jobs'))
|
|
160
261
|
const alert = node('p', null, 'pc-message'); alert.setAttribute('role', 'status')
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
262
|
+
const context = node('div', null, 'pc-context'); context.append(node('p', null, 'pc-profile'), node('div', null, 'pc-summary'))
|
|
263
|
+
controls.append(toolbarRow, filters, market)
|
|
264
|
+
dialog.append(heading, context, node('p', null, 'pc-status'), controls, alert, node('div', null, 'pc-recovery'), node('div', null, 'pc-detail'), node('div', null, 'pc-list'), jobs)
|
|
265
|
+
const surface = dialog
|
|
266
|
+
dialog.addEventListener('close', () => {
|
|
267
|
+
if (dialog !== surface) return
|
|
268
|
+
generation++; clearTimeout(timer); dialog = null; surface.remove()
|
|
269
|
+
})
|
|
270
|
+
;(target || document.body).append(dialog)
|
|
164
271
|
}
|
|
165
|
-
generation++; searchSequence++; detailSequence++; connection = config; tab = 'installed'; pending = false; snapshot = null
|
|
272
|
+
generation++; searchSequence++; detailSequence++; connection = config; tab = 'installed'; pending = false; snapshot = null; filter = ''; statusFilter = 'all'
|
|
166
273
|
const current = generation
|
|
167
274
|
renderedInstalled = ''; renderedJobs = ''
|
|
168
275
|
dialog.querySelector('.pc-market-tools').hidden = true
|
|
169
276
|
dialog.querySelector('.pc-detail').replaceChildren(); dialog.querySelector('.pc-list').replaceChildren(); dialog.querySelector('.pc-jobs').replaceChildren()
|
|
170
|
-
|
|
277
|
+
if (target) dialog.open = true
|
|
278
|
+
else dialog.showModal()
|
|
279
|
+
message('正在读取插件状态…')
|
|
171
280
|
try { await refresh(); message('') } catch (error) { if (current === generation) message(error.message) }
|
|
172
281
|
if (current !== generation) return
|
|
173
282
|
async function poll() {
|
|
174
|
-
if (
|
|
283
|
+
if (current !== generation || !dialog?.open) return
|
|
175
284
|
try { await refresh() } catch (error) { if (current === generation) message(error.message) }
|
|
176
|
-
if (
|
|
285
|
+
if (current === generation && dialog?.open) timer = setTimeout(poll, 4000)
|
|
177
286
|
}
|
|
178
287
|
timer = setTimeout(poll, 4000)
|
|
179
288
|
}
|
|
180
|
-
return { open }
|
|
289
|
+
return { open, mount: (config, target) => open(config, target), close }
|
|
181
290
|
})()
|