@gehennawu/dsh-service 0.20.0 → 0.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +2 -0
- package/README.md +2 -0
- package/client.js +188 -53
- package/index.js +73 -12
- package/package.json +4 -1
package/README.en.md
CHANGED
|
@@ -10,6 +10,8 @@ A service-control and operations plugin for self-hosted DSH Web. Provides safe r
|
|
|
10
10
|
|
|
11
11
|
The Settings panel "Service Control" page has seven top-level tabs: **Overview, Notifications, Health, Model stats, Quota lookup, Backups, Restart**; the Restart and Quota lookup tabs can each enable a quick entry at the bottom of the settings left navigation (off by default).
|
|
12
12
|
|
|
13
|
+
The plugin also appears under **Plugins → Plugin configuration**, with five host-level switches enabled by default: **Model statistics, Quota lookup, Backup maintenance, Task notifications, and the `/healthz` liveness endpoint**. Disabling one hides its UI, stops the associated polling/subscriptions, and makes the Host reject that capability; Overview, Health diagnostics, and Restart remain available. All five switches are live settings: disabling or re-enabling them requires neither a page reload nor a DSH Web restart. Statistics refreshes, quota requests, or backup operations already in flight are allowed to finish; quota re-enablement preserves existing cache, TTL, and backoff state, so its UI and calls return immediately but a new upstream request is not guaranteed at once.
|
|
14
|
+
|
|
13
15
|
### Version and updates
|
|
14
16
|
|
|
15
17
|
- Displays current DSH and plugin versions with links to GitHub Releases
|
package/README.md
CHANGED
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
|
|
11
11
|
设置页「服务控制」面板包含七个顶部标签:**概览、通知、健康诊断、模型统计、额度查询、备份维护、重启**;重启与额度查询标签还可在设置页左侧标签列底部开启快捷入口(默认关闭)。
|
|
12
12
|
|
|
13
|
+
插件同时出现在「插件 → 插件配置」,提供五个默认开启的宿主级开关:**模型统计、额度查询、备份维护、任务通知、`/healthz` 探活端点**。关闭后不仅隐藏对应界面,也会停止相关轮询/订阅并由宿主拒绝对应能力;概览、健康诊断与重启固定保留。设置写入 DSH settings,五项均为热生效:关闭或重新开启都无需刷新页面或重启 DSH Web。已在途的统计刷新、额度请求或备份操作允许完成;额度重新开启时保留既有缓存、TTL 与退避状态,因此可立即恢复界面和调用,但不保证立刻重新请求上游。
|
|
14
|
+
|
|
13
15
|
### 版本与更新
|
|
14
16
|
|
|
15
17
|
- 显示当前 DSH 和插件版本,版本号链接到 GitHub Releases
|
package/client.js
CHANGED
|
@@ -11,6 +11,16 @@ window.__ModuleLoader__.load({
|
|
|
11
11
|
const zh = {
|
|
12
12
|
'nav.label': '服务控制',
|
|
13
13
|
'nav.restart': '重启',
|
|
14
|
+
'features.cardTitle': '服务控制(dsh-service)',
|
|
15
|
+
'features.cardHint': '控制可选功能和外部能力。开关立即生效,无需重启;详细状态与操作位于左侧「服务控制」。',
|
|
16
|
+
'features.optional': '可选功能',
|
|
17
|
+
'features.external': '外部能力',
|
|
18
|
+
'features.modelUsage': '模型统计',
|
|
19
|
+
'features.quotaLookup': '额度查询',
|
|
20
|
+
'features.backupMaintenance': '备份维护',
|
|
21
|
+
'features.taskNotifications': '任务通知',
|
|
22
|
+
'features.healthz': '/healthz 探活端点',
|
|
23
|
+
'features.readOnly': '当前设置不可写。',
|
|
14
24
|
'overlay.label': '服务重启状态',
|
|
15
25
|
'recovery.waiting.title': '服务重启中…',
|
|
16
26
|
'recovery.waiting.body': '正在等待新的 DSH Web 进程启动,已等待 {seconds} 秒。',
|
|
@@ -309,6 +319,16 @@ window.__ModuleLoader__.load({
|
|
|
309
319
|
const en = {
|
|
310
320
|
'nav.label': 'Service Control',
|
|
311
321
|
'nav.restart': 'Restart',
|
|
322
|
+
'features.cardTitle': 'Service control (dsh-service)',
|
|
323
|
+
'features.cardHint': 'Control optional features and external capabilities. Changes take effect immediately without a restart; detailed status and actions remain in Service Control.',
|
|
324
|
+
'features.optional': 'Optional features',
|
|
325
|
+
'features.external': 'External capabilities',
|
|
326
|
+
'features.modelUsage': 'Model statistics',
|
|
327
|
+
'features.quotaLookup': 'Quota lookup',
|
|
328
|
+
'features.backupMaintenance': 'Backup maintenance',
|
|
329
|
+
'features.taskNotifications': 'Task notifications',
|
|
330
|
+
'features.healthz': '/healthz liveness endpoint',
|
|
331
|
+
'features.readOnly': 'These settings are read-only.',
|
|
312
332
|
'overlay.label': 'Service restart status',
|
|
313
333
|
'recovery.waiting.title': 'Restarting service…',
|
|
314
334
|
'recovery.waiting.body': 'Waiting for a new DSH Web process. Elapsed: {seconds} seconds.',
|
|
@@ -664,7 +684,7 @@ window.__ModuleLoader__.load({
|
|
|
664
684
|
}
|
|
665
685
|
}
|
|
666
686
|
|
|
667
|
-
const inject = ['slots', 'connection', 'timer', 'locale', 'sessions']
|
|
687
|
+
const inject = ['slots', 'connection', 'timer', 'locale', 'sessions', 'settingsScope']
|
|
668
688
|
|
|
669
689
|
function apply(ctx) {
|
|
670
690
|
const { useState, useEffect, useRef } = React
|
|
@@ -687,6 +707,16 @@ window.__ModuleLoader__.load({
|
|
|
687
707
|
ctx.effect(() => () => { if (svcStyle) svcStyle.remove() }, 'dsh-service theme styles')
|
|
688
708
|
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'dsh-service dictionaries')
|
|
689
709
|
const t = ctx.locale.bind(NS)
|
|
710
|
+
const DEFAULT_FEATURES = { modelUsage: true, quotaLookup: true, backupMaintenance: true, taskNotifications: true, healthz: true }
|
|
711
|
+
const featureScope = ctx.settingsScope.bind({ namespace: NS })
|
|
712
|
+
const featureSnapshot = () => featureScope.getSnapshot()
|
|
713
|
+
const featureValue = () => Object.assign({}, DEFAULT_FEATURES, featureSnapshot().value || {})
|
|
714
|
+
const featureEnabled = (key) => featureValue()[key] !== false
|
|
715
|
+
const useFeatures = () => {
|
|
716
|
+
const [snapshot, setSnapshot] = React.useState(featureSnapshot())
|
|
717
|
+
React.useEffect(() => featureScope.subscribe(() => setSnapshot(featureSnapshot())), [])
|
|
718
|
+
return { snapshot, value: Object.assign({}, DEFAULT_FEATURES, snapshot.value || {}) }
|
|
719
|
+
}
|
|
690
720
|
// 设置页左列三行打标记,配合上方样式换成各自图标;label 走 locale 绑定值。
|
|
691
721
|
ctx.effect(
|
|
692
722
|
() => markSettingsNavRows([
|
|
@@ -762,7 +792,7 @@ window.__ModuleLoader__.load({
|
|
|
762
792
|
let quotaNavDispose = null
|
|
763
793
|
const syncQuotaNavEntry = () => {
|
|
764
794
|
if (quotaNavDispose) { quotaNavDispose(); quotaNavDispose = null }
|
|
765
|
-
if (!quotaNavEnabled) return
|
|
795
|
+
if (!quotaNavEnabled || !featureEnabled('quotaLookup')) return
|
|
766
796
|
quotaNavDispose = ctx.slots.register(
|
|
767
797
|
{ name: 'settings.section', id: 'dsh-service-quota', order: 498, label: () => t('tabs.quota') },
|
|
768
798
|
() => React.createElement(QuotaSection, null),
|
|
@@ -811,11 +841,14 @@ window.__ModuleLoader__.load({
|
|
|
811
841
|
} catch (_) {}
|
|
812
842
|
return undefined
|
|
813
843
|
}
|
|
814
|
-
// 会话活跃态(sessions.list
|
|
844
|
+
// 会话活跃态(sessions.list 快照派生,订阅推送更新):任务通知和后台额度轮询共享这一事实源。
|
|
845
|
+
// 两项都关闭时彻底摘除订阅;任一重新开启时重新建立当前快照基线,不补发关闭期间的旧边沿。
|
|
815
846
|
const sessionActivity = { anyRunning: false, runningSessionIds: new Set() }
|
|
816
847
|
if (ctx.sessions && typeof ctx.sessions.list?.subscribe === 'function') {
|
|
817
848
|
const observed = new Map()
|
|
818
849
|
let baselined = false
|
|
850
|
+
let sessionsDispose = null
|
|
851
|
+
let resetDispose = null
|
|
819
852
|
const observeSessions = () => {
|
|
820
853
|
const snapshot = ctx.sessions.list.getSnapshot()
|
|
821
854
|
if (!snapshot || !snapshot.byId) return
|
|
@@ -832,10 +865,10 @@ window.__ModuleLoader__.load({
|
|
|
832
865
|
const next = { running: summary.running === true, pending: summary.pendingInteraction !== undefined }
|
|
833
866
|
const prev = observed.get(id)
|
|
834
867
|
if (prev !== undefined) {
|
|
835
|
-
if (prev.running && !next.running && notifyEnabled && notifyDone) {
|
|
868
|
+
if (prev.running && !next.running && featureEnabled('taskNotifications') && notifyEnabled && notifyDone) {
|
|
836
869
|
fireNotification(t('notification.doneTitle'), t('notification.doneBody', { title: summary.displayTitle || id }))
|
|
837
870
|
}
|
|
838
|
-
if (!prev.pending && next.pending && notifyEnabled && notifyInput) {
|
|
871
|
+
if (!prev.pending && next.pending && featureEnabled('taskNotifications') && notifyEnabled && notifyInput) {
|
|
839
872
|
const kindKey = NOTIFY_KIND_KEYS[summary.pendingInteraction]
|
|
840
873
|
const kind = kindKey ? t(kindKey) : String(summary.pendingInteraction)
|
|
841
874
|
fireNotification(t('notification.inputTitle'), t('notification.inputBody', { title: summary.displayTitle || id, kind }))
|
|
@@ -848,12 +881,30 @@ window.__ModuleLoader__.load({
|
|
|
848
881
|
}
|
|
849
882
|
// agent 启动时轮询链可能已因「隐藏页跳过周期」而死(runQuotaCycle 跳过即不再排下一轮):
|
|
850
883
|
// 有活跃会话就重新拉起排程(幂等:已有挂起定时器/refs=0/仅手动时 no-op)。
|
|
851
|
-
// 放在 baselined 早退之后——首次同步调用发生在工厂初始化期(quotaLoop 尚未定义),订阅事件只会在初始化完成后到。
|
|
852
884
|
if (sessionActivity.anyRunning) scheduleQuotaCycle()
|
|
853
885
|
}
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
886
|
+
const stopSessionObservation = () => {
|
|
887
|
+
if (sessionsDispose !== null) { sessionsDispose(); sessionsDispose = null }
|
|
888
|
+
if (resetDispose !== null) { resetDispose(); resetDispose = null }
|
|
889
|
+
observed.clear()
|
|
890
|
+
baselined = false
|
|
891
|
+
sessionActivity.anyRunning = false
|
|
892
|
+
sessionActivity.runningSessionIds = new Set()
|
|
893
|
+
}
|
|
894
|
+
const syncSessionObservation = () => {
|
|
895
|
+
const needed = featureEnabled('taskNotifications') || featureEnabled('quotaLookup')
|
|
896
|
+
if (!needed) { stopSessionObservation(); return }
|
|
897
|
+
if (sessionsDispose !== null) return
|
|
898
|
+
sessionsDispose = ctx.sessions.list.subscribe(() => observeSessions())
|
|
899
|
+
resetDispose = ctx.on('connection/reset', () => { observed.clear(); baselined = false })
|
|
900
|
+
observeSessions()
|
|
901
|
+
}
|
|
902
|
+
syncSessionObservation()
|
|
903
|
+
const unsubscribeFeatures = featureScope.subscribe(syncSessionObservation)
|
|
904
|
+
ctx.effect(() => () => {
|
|
905
|
+
unsubscribeFeatures()
|
|
906
|
+
stopSessionObservation()
|
|
907
|
+
}, 'dsh-service: shared session observation')
|
|
857
908
|
}
|
|
858
909
|
|
|
859
910
|
const recoveryListeners = new Set()
|
|
@@ -1342,7 +1393,7 @@ window.__ModuleLoader__.load({
|
|
|
1342
1393
|
const quotaLoop = { refs: 0, allRefs: 0, nextDispose: null, running: false, onVisible: undefined }
|
|
1343
1394
|
const isTabHidden = () => typeof document !== 'undefined' && document.visibilityState === 'hidden'
|
|
1344
1395
|
function scheduleQuotaCycle() {
|
|
1345
|
-
if (quotaLoop.refs === 0 || quotaLoop.nextDispose !== null || readQuotaPollMinutes() <= 0) return
|
|
1396
|
+
if (!featureEnabled('quotaLookup') || quotaLoop.refs === 0 || quotaLoop.nextDispose !== null || readQuotaPollMinutes() <= 0) return
|
|
1346
1397
|
const minutes = readQuotaPollMinutes()
|
|
1347
1398
|
quotaLoop.nextDispose = ctx.timer.timeout(() => {
|
|
1348
1399
|
quotaLoop.nextDispose = null
|
|
@@ -1350,7 +1401,7 @@ window.__ModuleLoader__.load({
|
|
|
1350
1401
|
}, minutes * 60000)
|
|
1351
1402
|
}
|
|
1352
1403
|
function runQuotaCycle() {
|
|
1353
|
-
if (quotaLoop.refs === 0 || quotaLoop.running) return
|
|
1404
|
+
if (!featureEnabled('quotaLookup') || quotaLoop.refs === 0 || quotaLoop.running) return
|
|
1354
1405
|
// 额度页打开时全量;其余自动/后台轮询只刷新 running 会话供应商。
|
|
1355
1406
|
const payload = quotaLoop.allRefs > 0 ? { scope: 'all' } : { providers: runningQuotaProviders() }
|
|
1356
1407
|
if (payload.scope !== 'all' && payload.providers.length === 0) return
|
|
@@ -1361,6 +1412,7 @@ window.__ModuleLoader__.load({
|
|
|
1361
1412
|
})
|
|
1362
1413
|
}
|
|
1363
1414
|
function acquireQuotaLoop(options = {}) {
|
|
1415
|
+
if (!featureEnabled('quotaLookup')) return
|
|
1364
1416
|
quotaLoop.refs += 1
|
|
1365
1417
|
if (options.all === true) quotaLoop.allRefs += 1
|
|
1366
1418
|
// 额度页显式全量;圆环等其他表面由当前交互/后台活跃集合决定目标。
|
|
@@ -1377,6 +1429,7 @@ window.__ModuleLoader__.load({
|
|
|
1377
1429
|
}
|
|
1378
1430
|
}
|
|
1379
1431
|
function releaseQuotaLoop(options = {}) {
|
|
1432
|
+
if (!featureEnabled('quotaLookup') && quotaLoop.refs === 0) return
|
|
1380
1433
|
quotaLoop.refs = Math.max(0, quotaLoop.refs - 1)
|
|
1381
1434
|
if (options.all === true) quotaLoop.allRefs = Math.max(0, quotaLoop.allRefs - 1)
|
|
1382
1435
|
if (quotaLoop.refs > 0) return
|
|
@@ -1646,6 +1699,58 @@ window.__ModuleLoader__.load({
|
|
|
1646
1699
|
updatedNode) : null)
|
|
1647
1700
|
}
|
|
1648
1701
|
|
|
1702
|
+
function FeatureSettingsCard() {
|
|
1703
|
+
const translate = useTranslation()
|
|
1704
|
+
const { snapshot, value } = useFeatures()
|
|
1705
|
+
const [open, setOpen] = React.useState(false)
|
|
1706
|
+
const [saving, setSaving] = React.useState('')
|
|
1707
|
+
const writable = snapshot.status === 'ready' && snapshot.writable === true
|
|
1708
|
+
const row = (key) => React.createElement('div', {
|
|
1709
|
+
key,
|
|
1710
|
+
style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '14px', padding: '7px 0' },
|
|
1711
|
+
},
|
|
1712
|
+
React.createElement('span', { style: { fontSize: '13px', color: 'var(--dsw-alias-label-primary)' } }, translate('features.' + key)),
|
|
1713
|
+
React.createElement('button', {
|
|
1714
|
+
type: 'button',
|
|
1715
|
+
role: 'switch',
|
|
1716
|
+
'data-testid': 'feature-switch-' + key,
|
|
1717
|
+
'aria-checked': String(value[key] !== false),
|
|
1718
|
+
disabled: !writable || saving !== '',
|
|
1719
|
+
onClick: async () => {
|
|
1720
|
+
setSaving(key)
|
|
1721
|
+
try { await featureScope.set(key, value[key] === false) } catch (_) {}
|
|
1722
|
+
setSaving('')
|
|
1723
|
+
},
|
|
1724
|
+
style: { width: '34px', height: '20px', borderRadius: '10px', padding: 0, flexShrink: 0, position: 'relative', border: '1px solid ' + (value[key] !== false ? 'var(--dsw-alias-state-success-primary)' : 'var(--dsw-alias-border-l2)'), background: value[key] !== false ? 'var(--dsw-alias-state-success-primary)' : 'var(--dsw-alias-bg-layer-2)', cursor: writable && saving === '' ? 'pointer' : 'default', opacity: writable ? 1 : 0.5, lineHeight: 0 },
|
|
1725
|
+
}, React.createElement('span', { style: { position: 'absolute', top: '1px', left: value[key] !== false ? '15px' : '1px', width: '16px', height: '16px', borderRadius: '50%', background: value[key] !== false ? '#fff' : 'var(--dsw-alias-label-tertiary)' } })))
|
|
1726
|
+
return React.createElement('li', {
|
|
1727
|
+
style: { listStyle: 'none', border: '1px solid ' + (open ? 'var(--dsw-alias-label-dimmed)' : 'var(--dsw-alias-border-l2)'), borderRadius: '12px', color: 'var(--dsw-alias-label-primary)', background: open ? 'var(--dsw-alias-bg-layer-2)' : 'var(--dsw-alias-bg-layer-3)' },
|
|
1728
|
+
},
|
|
1729
|
+
React.createElement('button', {
|
|
1730
|
+
type: 'button',
|
|
1731
|
+
'data-testid': 'feature-card-toggle',
|
|
1732
|
+
'aria-expanded': String(open),
|
|
1733
|
+
onClick: () => setOpen(!open),
|
|
1734
|
+
style: { appearance: 'none', width: '100%', display: 'flex', alignItems: 'center', gap: '12px', padding: '14px 16px', border: 0, borderRadius: '12px', background: 'transparent', color: 'inherit', font: 'inherit', textAlign: 'left', cursor: 'pointer' },
|
|
1735
|
+
},
|
|
1736
|
+
React.createElement('span', { style: { display: 'flex', minWidth: 0, flex: 1, flexDirection: 'column', gap: '4px' } },
|
|
1737
|
+
React.createElement('span', { style: { fontSize: '15px', fontWeight: 600, lineHeight: 1.4 } }, translate('features.cardTitle')),
|
|
1738
|
+
React.createElement('span', { style: { fontSize: '13px', lineHeight: 1.5, color: 'var(--dsw-alias-label-tertiary)' } }, translate('features.cardHint'))),
|
|
1739
|
+
React.createElement('svg', {
|
|
1740
|
+
viewBox: '0 0 14 14',
|
|
1741
|
+
width: 14,
|
|
1742
|
+
height: 14,
|
|
1743
|
+
'aria-hidden': 'true',
|
|
1744
|
+
style: { flex: 'none', color: 'var(--dsw-alias-label-tertiary)', transform: open ? 'rotate(180deg)' : 'none', transition: 'transform .16s' },
|
|
1745
|
+
}, React.createElement('path', { d: 'M3 5.25 7 9l4-3.75', fill: 'none', stroke: 'currentColor', strokeWidth: 1.4, strokeLinecap: 'round', strokeLinejoin: 'round' }))),
|
|
1746
|
+
open ? React.createElement('div', { style: { margin: '0 16px', padding: '12px 0 8px', borderTop: '1px solid var(--dsw-alias-border-l2)' } },
|
|
1747
|
+
React.createElement('div', { style: { fontSize: '12px', fontWeight: 700 } }, translate('features.optional')),
|
|
1748
|
+
['modelUsage', 'quotaLookup', 'backupMaintenance', 'taskNotifications'].map(row),
|
|
1749
|
+
React.createElement('div', { style: { fontSize: '12px', fontWeight: 700, marginTop: '8px', paddingTop: '10px', borderTop: '1px solid var(--dsw-alias-border-l1)' } }, translate('features.external')),
|
|
1750
|
+
row('healthz'),
|
|
1751
|
+
!writable ? React.createElement('p', { style: { margin: '6px 0 0', fontSize: '11px', color: 'var(--dsw-alias-label-tertiary)' } }, translate('features.readOnly')) : null) : null)
|
|
1752
|
+
}
|
|
1753
|
+
|
|
1649
1754
|
function QuotaSection() {
|
|
1650
1755
|
return React.createElement(RemoteQuotaCard, null)
|
|
1651
1756
|
}
|
|
@@ -1907,6 +2012,7 @@ window.__ModuleLoader__.load({
|
|
|
1907
2012
|
|
|
1908
2013
|
function ServicePanel() {
|
|
1909
2014
|
const translate = useTranslation()
|
|
2015
|
+
const { value: features } = useFeatures()
|
|
1910
2016
|
const [health, setHealth] = useState(null)
|
|
1911
2017
|
const [healthError, setHealthError] = useState(null)
|
|
1912
2018
|
const [diagnostics, setDiagnostics] = useState(null)
|
|
@@ -1984,6 +2090,7 @@ window.__ModuleLoader__.load({
|
|
|
1984
2090
|
return () => { active = false }
|
|
1985
2091
|
}, [])
|
|
1986
2092
|
useEffect(() => {
|
|
2093
|
+
if (!featureEnabled('modelUsage')) return () => {}
|
|
1987
2094
|
let active = true
|
|
1988
2095
|
ctx.connection.rpc.call('/dsh-service', 'usage', usageRequestPayload).then(async (res) => {
|
|
1989
2096
|
if (!active) return
|
|
@@ -1998,8 +2105,9 @@ window.__ModuleLoader__.load({
|
|
|
1998
2105
|
if (active) setUsageError(translate('usage.error'))
|
|
1999
2106
|
})
|
|
2000
2107
|
return () => { active = false }
|
|
2001
|
-
}, [])
|
|
2108
|
+
}, [features.modelUsage])
|
|
2002
2109
|
useEffect(() => {
|
|
2110
|
+
if (!featureEnabled('backupMaintenance')) return () => {}
|
|
2003
2111
|
let active = true
|
|
2004
2112
|
ctx.connection.rpc.call('/dsh-service', 'backup-list', {}).then((res) => {
|
|
2005
2113
|
if (!active) return
|
|
@@ -2009,7 +2117,7 @@ window.__ModuleLoader__.load({
|
|
|
2009
2117
|
if (active) setBackupError(translate('backup.error'))
|
|
2010
2118
|
})
|
|
2011
2119
|
return () => { active = false }
|
|
2012
|
-
}, [])
|
|
2120
|
+
}, [features.backupMaintenance])
|
|
2013
2121
|
useEffect(() => {
|
|
2014
2122
|
let active = true
|
|
2015
2123
|
let cancelNext = () => {}
|
|
@@ -2760,25 +2868,26 @@ window.__ModuleLoader__.load({
|
|
|
2760
2868
|
}
|
|
2761
2869
|
const tabs = [
|
|
2762
2870
|
['overview', 'tabs.overview'],
|
|
2763
|
-
['notify', 'tabs.notify'],
|
|
2871
|
+
...(features.taskNotifications !== false ? [['notify', 'tabs.notify']] : []),
|
|
2764
2872
|
['health', 'tabs.health'],
|
|
2765
|
-
['usage', 'tabs.usage'],
|
|
2766
|
-
['quota', 'tabs.quota'],
|
|
2767
|
-
['backup', 'tabs.backup'],
|
|
2873
|
+
...(features.modelUsage !== false ? [['usage', 'tabs.usage']] : []),
|
|
2874
|
+
...(features.quotaLookup !== false ? [['quota', 'tabs.quota']] : []),
|
|
2875
|
+
...(features.backupMaintenance !== false ? [['backup', 'tabs.backup']] : []),
|
|
2768
2876
|
['restart', 'tabs.restart'],
|
|
2769
2877
|
]
|
|
2770
2878
|
const warningTabs = tabs.filter(([id]) => tabWarnings[id]).map(([, label]) => translate(label))
|
|
2771
|
-
const
|
|
2879
|
+
const visibleActiveTab = tabs.some(([id]) => id === activeTab) ? activeTab : 'overview'
|
|
2880
|
+
const tabContent = visibleActiveTab === 'overview'
|
|
2772
2881
|
? overviewBlock
|
|
2773
|
-
:
|
|
2882
|
+
: visibleActiveTab === 'notify'
|
|
2774
2883
|
? notifyBlock
|
|
2775
|
-
:
|
|
2884
|
+
: visibleActiveTab === 'health'
|
|
2776
2885
|
? healthBlock
|
|
2777
|
-
:
|
|
2886
|
+
: visibleActiveTab === 'usage'
|
|
2778
2887
|
? usageBlock
|
|
2779
|
-
:
|
|
2888
|
+
: visibleActiveTab === 'quota'
|
|
2780
2889
|
? React.createElement(RemoteQuotaCard, null)
|
|
2781
|
-
:
|
|
2890
|
+
: visibleActiveTab === 'backup'
|
|
2782
2891
|
? maintenanceBlock
|
|
2783
2892
|
: restartBlock
|
|
2784
2893
|
return React.createElement('div', null,
|
|
@@ -2786,7 +2895,7 @@ window.__ModuleLoader__.load({
|
|
|
2786
2895
|
React.createElement('div', { style: { fontSize: '13px', fontWeight: 700 } }, translate('tabs.alert.title')),
|
|
2787
2896
|
React.createElement('div', { style: Object.assign({}, hint, { marginTop: '3px' }) }, translate('tabs.alert.body', { tabs: warningTabs.join('、') }))) : null,
|
|
2788
2897
|
React.createElement('div', { 'data-testid': 'tab-list', style: { display: 'flex', gap: '10px', flexWrap: 'wrap', borderBottom: '1px solid var(--dsw-alias-border-l1)' } },
|
|
2789
|
-
tabs.map(([id, label]) => React.createElement('button', { key: id, style: Object.assign({}, inlineTab,
|
|
2898
|
+
tabs.map(([id, label]) => React.createElement('button', { key: id, style: Object.assign({}, inlineTab, visibleActiveTab === id ? inlineTabActive : { color: tabWarnings[id] ? 'var(--dsw-alias-state-warn-primary)' : 'var(--dsw-alias-label-secondary)', borderBottom: '2px solid transparent' }), onClick: () => { setActiveTab(id); if (id === 'health') runDiagnostics(false) } }, `${tabWarnings[id] ? '⚠ ' : ''}${translate(label)}`))),
|
|
2790
2899
|
React.createElement('div', { 'data-testid': 'tab-panel', style: tabPanel }, tabContent))
|
|
2791
2900
|
}
|
|
2792
2901
|
|
|
@@ -2815,14 +2924,28 @@ window.__ModuleLoader__.load({
|
|
|
2815
2924
|
},
|
|
2816
2925
|
}, BellIcon(enabled))
|
|
2817
2926
|
}
|
|
2818
|
-
ctx.slots.inject('conversation.input.left', () =>
|
|
2819
|
-
|
|
2820
|
-
() =>
|
|
2821
|
-
|
|
2927
|
+
ctx.slots.inject('conversation.input.left', () => {
|
|
2928
|
+
let dispose = null
|
|
2929
|
+
const sync = () => {
|
|
2930
|
+
if (dispose !== null) { dispose(); dispose = null }
|
|
2931
|
+
if (!featureEnabled('taskNotifications')) return
|
|
2932
|
+
dispose = ctx.slots.register(
|
|
2933
|
+
{ name: 'conversation.input.left', id: 'dsh-service-notify', order: 90, label: () => t('notification.bellOn') },
|
|
2934
|
+
() => React.createElement(InlineNotifyBell, null),
|
|
2935
|
+
)
|
|
2936
|
+
}
|
|
2937
|
+
sync()
|
|
2938
|
+
const unsubscribe = featureScope.subscribe(sync)
|
|
2939
|
+
return () => { unsubscribe(); if (dispose !== null) dispose() }
|
|
2940
|
+
})
|
|
2822
2941
|
ctx.slots.inject('sidebar.footer.action', () => ctx.slots.register(
|
|
2823
2942
|
{ name: 'sidebar.footer.action', id: 'dsh-service-update', order: 90, label: () => t('update.badge') },
|
|
2824
2943
|
() => React.createElement(UpdateBadge, null),
|
|
2825
2944
|
))
|
|
2945
|
+
ctx.slots.inject('settings.plugin.item', () => ctx.slots.register(
|
|
2946
|
+
{ name: 'settings.plugin.item', id: 'dsh-service', key: 'dsh-service', order: 40 },
|
|
2947
|
+
() => React.createElement(FeatureSettingsCard, null),
|
|
2948
|
+
))
|
|
2826
2949
|
ctx.slots.inject('settings.section', () => {
|
|
2827
2950
|
const disposePanel = ctx.slots.register(
|
|
2828
2951
|
{ name: 'settings.section', id: 'dsh-service', order: 99, label: () => t('nav.label') },
|
|
@@ -2831,7 +2954,9 @@ window.__ModuleLoader__.load({
|
|
|
2831
2954
|
// 左列「重启」「额度查询」入口由各自标签内的开关控制,默认不注册
|
|
2832
2955
|
syncRestartNavEntry()
|
|
2833
2956
|
syncQuotaNavEntry()
|
|
2957
|
+
const unsubscribeFeatures = featureScope.subscribe(syncQuotaNavEntry)
|
|
2834
2958
|
return () => {
|
|
2959
|
+
unsubscribeFeatures()
|
|
2835
2960
|
disposePanel()
|
|
2836
2961
|
if (restartNavDispose) {
|
|
2837
2962
|
restartNavDispose()
|
|
@@ -2852,31 +2977,41 @@ window.__ModuleLoader__.load({
|
|
|
2852
2977
|
// (老版本 DSH 没有)。槽位条目无条件注册,服务在条目渲染时(inject(sessionId))
|
|
2853
2978
|
// 经 ctx.get 惰性解析——此时会话已渲染、model-selection 必然已挂载,不受注入时序影响;
|
|
2854
2979
|
// 拿不到服务时 props 为空,QuotaRing 渲染 null 且不启动轮询,其他功能零影响。
|
|
2855
|
-
ctx.slots.inject('conversation.input.right', () =>
|
|
2856
|
-
|
|
2857
|
-
|
|
2858
|
-
|
|
2859
|
-
|
|
2860
|
-
|
|
2861
|
-
|
|
2862
|
-
|
|
2863
|
-
|
|
2864
|
-
|
|
2865
|
-
|
|
2866
|
-
|
|
2867
|
-
|
|
2868
|
-
|
|
2869
|
-
|
|
2870
|
-
|
|
2871
|
-
|
|
2872
|
-
|
|
2873
|
-
|
|
2874
|
-
|
|
2875
|
-
|
|
2876
|
-
|
|
2877
|
-
|
|
2878
|
-
|
|
2879
|
-
|
|
2980
|
+
ctx.slots.inject('conversation.input.right', () => {
|
|
2981
|
+
let dispose = null
|
|
2982
|
+
const sync = () => {
|
|
2983
|
+
if (dispose !== null) { dispose(); dispose = null }
|
|
2984
|
+
if (!featureEnabled('quotaLookup')) return
|
|
2985
|
+
dispose = ctx.slots.register({
|
|
2986
|
+
name: 'conversation.input.right',
|
|
2987
|
+
id: 'dsh-service-quota-ring',
|
|
2988
|
+
order: 95,
|
|
2989
|
+
label: () => t('quota.ring.label'),
|
|
2990
|
+
inject: (sessionId) => {
|
|
2991
|
+
if (sessionId === undefined || sessionId === null) return {}
|
|
2992
|
+
try {
|
|
2993
|
+
const models = getModelDirectories()
|
|
2994
|
+
if (models === undefined || typeof models.directoryFor !== 'function') return {}
|
|
2995
|
+
const directory = models.directoryFor(sessionId)
|
|
2996
|
+
return {
|
|
2997
|
+
directoryStore: directory.store,
|
|
2998
|
+
loadDirectory: () => {
|
|
2999
|
+
try {
|
|
3000
|
+
const pending = directory.load()
|
|
3001
|
+
if (pending && typeof pending.catch === 'function') pending.catch(() => {})
|
|
3002
|
+
} catch (_) {}
|
|
3003
|
+
},
|
|
3004
|
+
}
|
|
3005
|
+
} catch (_) {
|
|
3006
|
+
return {}
|
|
3007
|
+
}
|
|
3008
|
+
},
|
|
3009
|
+
}, (props) => React.createElement(QuotaRing, props))
|
|
3010
|
+
}
|
|
3011
|
+
sync()
|
|
3012
|
+
const unsubscribe = featureScope.subscribe(sync)
|
|
3013
|
+
return () => { unsubscribe(); if (dispose !== null) dispose() }
|
|
3014
|
+
})
|
|
2880
3015
|
}
|
|
2881
3016
|
|
|
2882
3017
|
exports.inject = inject
|
package/index.js
CHANGED
|
@@ -9,12 +9,28 @@ import { basename, dirname, join, relative, resolve, sep } from 'node:path'
|
|
|
9
9
|
import { createRequire } from 'node:module'
|
|
10
10
|
import { fileURLToPath } from 'node:url'
|
|
11
11
|
import https from 'node:https'
|
|
12
|
+
import z from '@deepseek-ai/schemastery'
|
|
12
13
|
|
|
13
14
|
const require = createRequire(import.meta.url)
|
|
14
15
|
const name = 'dsh-service'
|
|
15
16
|
const inject = ['connection']
|
|
16
17
|
const DSH_PACKAGE = '@deepseek-ai/dsh'
|
|
17
18
|
const PLUGIN_PACKAGE = '@gehennawu/dsh-service'
|
|
19
|
+
const SETTINGS_NAMESPACE = 'dsh-service'
|
|
20
|
+
const DEFAULT_FEATURE_SETTINGS = Object.freeze({
|
|
21
|
+
modelUsage: true,
|
|
22
|
+
quotaLookup: true,
|
|
23
|
+
backupMaintenance: true,
|
|
24
|
+
taskNotifications: true,
|
|
25
|
+
healthz: true,
|
|
26
|
+
})
|
|
27
|
+
const FeatureSettingsSchema = z.object({
|
|
28
|
+
modelUsage: z.boolean().default(true),
|
|
29
|
+
quotaLookup: z.boolean().default(true),
|
|
30
|
+
backupMaintenance: z.boolean().default(true),
|
|
31
|
+
taskNotifications: z.boolean().default(true),
|
|
32
|
+
healthz: z.boolean().default(true),
|
|
33
|
+
})
|
|
18
34
|
const NPM_REGISTRY = 'https://registry.npmjs.org/'
|
|
19
35
|
const MAX_NPM_RESPONSE_BYTES = 256 * 1024
|
|
20
36
|
const MAX_BACKUP_TRANSFER_BYTES = 128 * 1024 * 1024
|
|
@@ -1800,6 +1816,25 @@ function scheduleRestart(ctx) {
|
|
|
1800
1816
|
|
|
1801
1817
|
function apply(ctx) {
|
|
1802
1818
|
const dshHome = resolveDshHome()
|
|
1819
|
+
let featureSettings = DEFAULT_FEATURE_SETTINGS
|
|
1820
|
+
const featureSettingsListeners = new Set()
|
|
1821
|
+
const publishFeatureSettings = () => {
|
|
1822
|
+
for (const listener of featureSettingsListeners) listener(featureSettings)
|
|
1823
|
+
}
|
|
1824
|
+
ctx.inject(['settings'], (settingsCtx) => {
|
|
1825
|
+
try {
|
|
1826
|
+
const scope = settingsCtx.settings.register(SETTINGS_NAMESPACE, FeatureSettingsSchema, { base: DEFAULT_FEATURE_SETTINGS })
|
|
1827
|
+
featureSettings = scope.get()
|
|
1828
|
+
publishFeatureSettings()
|
|
1829
|
+
if (typeof scope.watch === 'function') settingsCtx.effect(() => scope.watch((value) => {
|
|
1830
|
+
featureSettings = value ?? scope.get()
|
|
1831
|
+
publishFeatureSettings()
|
|
1832
|
+
}), 'dsh-service feature settings watch')
|
|
1833
|
+
} catch (error) {
|
|
1834
|
+
ctx.logger?.warn?.(`dsh-service: feature settings unavailable: ${error?.message || error}`)
|
|
1835
|
+
}
|
|
1836
|
+
})
|
|
1837
|
+
const featureEnabled = (key) => featureSettings?.[key] !== false
|
|
1803
1838
|
// 进程运行环境在生命周期内不变:挂载时探测一次,version RPC 与升级分支共用。
|
|
1804
1839
|
const runtimeEnv = detectRuntimeEnv()
|
|
1805
1840
|
const permissionPlans = new Map()
|
|
@@ -1965,19 +2000,33 @@ function apply(ctx) {
|
|
|
1965
2000
|
}
|
|
1966
2001
|
const webServer = ctx.get('webServer')
|
|
1967
2002
|
if (webServer !== undefined) {
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
2003
|
+
let healthzDispose = null
|
|
2004
|
+
const syncHealthzRoute = () => {
|
|
2005
|
+
if (healthzDispose !== null) {
|
|
2006
|
+
healthzDispose()
|
|
2007
|
+
healthzDispose = null
|
|
2008
|
+
}
|
|
2009
|
+
if (!featureEnabled('healthz')) return
|
|
2010
|
+
healthzDispose = webServer.register({
|
|
2011
|
+
kind: 'exact',
|
|
2012
|
+
path: '/healthz',
|
|
2013
|
+
handler: (req, res) => {
|
|
2014
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
2015
|
+
res.writeHead(405)
|
|
2016
|
+
res.end()
|
|
2017
|
+
return
|
|
2018
|
+
}
|
|
2019
|
+
res.writeHead(200)
|
|
1974
2020
|
res.end()
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
2021
|
+
},
|
|
2022
|
+
})
|
|
2023
|
+
}
|
|
2024
|
+
syncHealthzRoute()
|
|
2025
|
+
featureSettingsListeners.add(syncHealthzRoute)
|
|
2026
|
+
ctx.effect(() => () => {
|
|
2027
|
+
featureSettingsListeners.delete(syncHealthzRoute)
|
|
2028
|
+
if (healthzDispose !== null) healthzDispose()
|
|
2029
|
+
}, 'dsh-service healthz route')
|
|
1981
2030
|
ctx.effect(() => webServer.register({
|
|
1982
2031
|
kind: 'exact',
|
|
1983
2032
|
path: '/dsh-backup-download',
|
|
@@ -2071,6 +2120,7 @@ function apply(ctx) {
|
|
|
2071
2120
|
}
|
|
2072
2121
|
|
|
2073
2122
|
if (endpoint === 'usage') {
|
|
2123
|
+
if (!featureEnabled('modelUsage')) return { ok: false, error: 'feature-disabled' }
|
|
2074
2124
|
try {
|
|
2075
2125
|
return { ok: true, value: publicUsage(await usageIndexPromise, payload?.timezoneOffsetMinutes) }
|
|
2076
2126
|
} catch (error) {
|
|
@@ -2079,6 +2129,7 @@ function apply(ctx) {
|
|
|
2079
2129
|
}
|
|
2080
2130
|
|
|
2081
2131
|
if (endpoint === 'usage-refresh') {
|
|
2132
|
+
if (!featureEnabled('modelUsage')) return { ok: false, error: 'feature-disabled' }
|
|
2082
2133
|
try {
|
|
2083
2134
|
if (usageRefreshPromise === undefined) {
|
|
2084
2135
|
usageRefreshPromise = usageIndexPromise.then((index) => refreshUsageIndex(ctx, dshHome, index)).finally(() => { usageRefreshPromise = undefined })
|
|
@@ -2118,6 +2169,7 @@ function apply(ctx) {
|
|
|
2118
2169
|
}
|
|
2119
2170
|
|
|
2120
2171
|
if (endpoint === 'backup-list') {
|
|
2172
|
+
if (!featureEnabled('backupMaintenance')) return { ok: false, error: 'feature-disabled' }
|
|
2121
2173
|
try {
|
|
2122
2174
|
return { ok: true, value: await listBackups(dshHome) }
|
|
2123
2175
|
} catch (error) {
|
|
@@ -2126,6 +2178,7 @@ function apply(ctx) {
|
|
|
2126
2178
|
}
|
|
2127
2179
|
|
|
2128
2180
|
if (endpoint === 'backup-create') {
|
|
2181
|
+
if (!featureEnabled('backupMaintenance')) return { ok: false, error: 'feature-disabled' }
|
|
2129
2182
|
try {
|
|
2130
2183
|
return { ok: true, value: await createBackup(ctx, dshHome) }
|
|
2131
2184
|
} catch (error) {
|
|
@@ -2134,6 +2187,7 @@ function apply(ctx) {
|
|
|
2134
2187
|
}
|
|
2135
2188
|
|
|
2136
2189
|
if (endpoint === 'backup-export') {
|
|
2190
|
+
if (!featureEnabled('backupMaintenance')) return { ok: false, error: 'feature-disabled' }
|
|
2137
2191
|
try {
|
|
2138
2192
|
const value = await exportBackup(dshHome, downloadTokens, payload?.id)
|
|
2139
2193
|
if (value === undefined) return { ok: false, error: 'unknown-backup' }
|
|
@@ -2144,6 +2198,7 @@ function apply(ctx) {
|
|
|
2144
2198
|
}
|
|
2145
2199
|
|
|
2146
2200
|
if (endpoint === 'backup-delete') {
|
|
2201
|
+
if (!featureEnabled('backupMaintenance')) return { ok: false, error: 'feature-disabled' }
|
|
2147
2202
|
try {
|
|
2148
2203
|
const value = await deleteBackup(dshHome, payload?.id)
|
|
2149
2204
|
if (value === undefined) return { ok: false, error: 'unknown-backup' }
|
|
@@ -2154,6 +2209,7 @@ function apply(ctx) {
|
|
|
2154
2209
|
}
|
|
2155
2210
|
|
|
2156
2211
|
if (endpoint === 'backup-restore') {
|
|
2212
|
+
if (!featureEnabled('backupMaintenance')) return { ok: false, error: 'feature-disabled' }
|
|
2157
2213
|
try {
|
|
2158
2214
|
const value = await restoreBackup(ctx, dshHome, payload?.id)
|
|
2159
2215
|
if (value === undefined) return { ok: false, error: 'unknown-backup' }
|
|
@@ -2165,6 +2221,7 @@ function apply(ctx) {
|
|
|
2165
2221
|
}
|
|
2166
2222
|
|
|
2167
2223
|
if (endpoint === 'backup-import') {
|
|
2224
|
+
if (!featureEnabled('backupMaintenance')) return { ok: false, error: 'feature-disabled' }
|
|
2168
2225
|
try {
|
|
2169
2226
|
const value = await importBackup(dshHome, payload?.name, payload?.data)
|
|
2170
2227
|
if (value === undefined) return { ok: false, error: 'invalid-backup' }
|
|
@@ -2175,6 +2232,7 @@ function apply(ctx) {
|
|
|
2175
2232
|
}
|
|
2176
2233
|
|
|
2177
2234
|
if (endpoint === 'quota') {
|
|
2235
|
+
if (!featureEnabled('quotaLookup')) return { ok: false, error: 'feature-disabled' }
|
|
2178
2236
|
try {
|
|
2179
2237
|
const providers = readLlmProviders(ctx.get('settings'))
|
|
2180
2238
|
quotaThrottle.prune(new Set(providers.map((profile) => profile.name)))
|
|
@@ -2226,6 +2284,7 @@ function apply(ctx) {
|
|
|
2226
2284
|
}
|
|
2227
2285
|
|
|
2228
2286
|
if (endpoint === 'quota-refresh') {
|
|
2287
|
+
if (!featureEnabled('quotaLookup')) return { ok: false, error: 'feature-disabled' }
|
|
2229
2288
|
try {
|
|
2230
2289
|
// 手动刷新入口:provider 过白名单且 kind 已适配;清掉节流闸后立即 kick。
|
|
2231
2290
|
// 单飞仍生效(在途时本次点击为 no-op);上游结果经后续 quota 快照带出,不在此等待。
|
|
@@ -2248,6 +2307,7 @@ function apply(ctx) {
|
|
|
2248
2307
|
}
|
|
2249
2308
|
|
|
2250
2309
|
if (endpoint === 'quota-config') {
|
|
2310
|
+
if (!featureEnabled('quotaLookup')) return { ok: false, error: 'feature-disabled' }
|
|
2251
2311
|
try {
|
|
2252
2312
|
const providerName = typeof payload?.provider === 'string' ? payload.provider : ''
|
|
2253
2313
|
// 三种写法,语义对齐配置文件解析(显式 kind > 显式 null 停用 > 自动推断):
|
|
@@ -2273,6 +2333,7 @@ function apply(ctx) {
|
|
|
2273
2333
|
}
|
|
2274
2334
|
|
|
2275
2335
|
if (endpoint === 'quota-reset-card') {
|
|
2336
|
+
if (!featureEnabled('quotaLookup')) return { ok: false, error: 'feature-disabled' }
|
|
2276
2337
|
try {
|
|
2277
2338
|
// 手录重置卡(v0.19 过渡方案;v0.20 免次数、每 provider 可多条)的面板写入口:
|
|
2278
2339
|
// provider 过宿主清单白名单;{remove:true,id} 删除宿主下发 id 对应的那一条,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gehennawu/dsh-service",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.21.0",
|
|
4
4
|
"description": "DSH Web 自托管运维面板:安全重启、健康监控、备份和 Linux 权限维护。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"scripts": {
|
|
@@ -40,6 +40,9 @@
|
|
|
40
40
|
"engines": {
|
|
41
41
|
"node": ">=22"
|
|
42
42
|
},
|
|
43
|
+
"dependencies": {
|
|
44
|
+
"@deepseek-ai/schemastery": "^3.18.1"
|
|
45
|
+
},
|
|
43
46
|
"dsh": {
|
|
44
47
|
"bundle": {
|
|
45
48
|
"patch": "./cordis.patch.yml"
|