@goodandready/dsh-subscriptions 0.7.34 → 0.7.36

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -313,6 +313,13 @@ window.__ModuleLoader__.load({
313
313
  "save": "Save",
314
314
  "saved": "Saved",
315
315
  "cancel": "Cancel",
316
+ "checkAllAccounts": "⚡ Check all",
317
+ "refreshAllQuotas": "🔄 Refresh quotas",
318
+ "importAllCli": "📥 Import all CLI",
319
+ "batchProgress": "{done}/{total} done",
320
+ "moveUp": "Move up",
321
+ "moveDown": "Move down",
322
+ "copyRef": "Copy account ref",
316
323
  "unsavedChanges": "Unsaved changes",
317
324
  "accountLabel": "Account",
318
325
  "usageRemaining": "Remaining quota",
@@ -712,6 +719,13 @@ window.__ModuleLoader__.load({
712
719
  "save": "保存",
713
720
  "saved": "已保存",
714
721
  "cancel": "取消",
722
+ "checkAllAccounts": "⚡ 全部检查",
723
+ "refreshAllQuotas": "🔄 刷新配额",
724
+ "importAllCli": "📥 导入全部 CLI",
725
+ "batchProgress": "已完成 {done}/{total}",
726
+ "moveUp": "上移",
727
+ "moveDown": "下移",
728
+ "copyRef": "复制账户引用",
715
729
  "unsavedChanges": "有未保存的更改",
716
730
  "accountLabel": "账户",
717
731
  "usageRemaining": "剩余配额",
@@ -1018,6 +1032,17 @@ window.__ModuleLoader__.load({
1018
1032
  }
1019
1033
  const Chevron = ChevronIcon || FallbackChevron
1020
1034
 
1035
+ function lazyPanel(name) {
1036
+ const Component = React.lazy(() => require.async('./client.panels.js').then((mod) => ({ default: mod[name] })))
1037
+ return function LazyPanel(props) {
1038
+ return h(React.Suspense, { fallback: null }, h(Component, props))
1039
+ }
1040
+ }
1041
+ const WebhookTestButton = lazyPanel('WebhookTestButton')
1042
+ const VaultSnapshotsPanel = lazyPanel('VaultSnapshotsPanel')
1043
+ const ProviderPacksPanel = lazyPanel('ProviderPacksPanel')
1044
+ const SourcesPanel = lazyPanel('SourcesPanel')
1045
+
1021
1046
  // #839 model-list helpers
1022
1047
  function modelListSearchText(model) {
1023
1048
  const parts = [model && model.id, model && model.name, model && model.alias, model && model.provider, model && model.providerName]
@@ -1432,6 +1457,8 @@ window.__ModuleLoader__.load({
1432
1457
  }
1433
1458
  }
1434
1459
  const [checking, setChecking] = React.useState({})
1460
+ const [batch, setBatch] = React.useState(null)
1461
+ const batchCancelRef = React.useRef(false)
1435
1462
  const [saved, setSaved] = React.useState(false)
1436
1463
  const [savedSnapshot, setSavedSnapshot] = React.useState(null)
1437
1464
  const [tokenDraft, setTokenDraft] = React.useState({})
@@ -1544,6 +1571,21 @@ window.__ModuleLoader__.load({
1544
1571
  const updateSlot = (provider, index, patch) => setSlots((prev) => prev.map((s) => (
1545
1572
  s && s.provider === provider && Number(s.index) === Number(index) ? Object.assign({}, s, patch) : s
1546
1573
  )))
1574
+ const moveSlot = (provider, index, delta) => setSlots((prev) => {
1575
+ const positions = prev
1576
+ .map((slot, position) => ({ slot, position }))
1577
+ .filter((row) => row.slot && row.slot.provider === provider)
1578
+ const from = positions.findIndex((row) => Number(row.slot.index) === Number(index))
1579
+ const to = from + delta
1580
+ if (from < 0 || to < 0 || to >= positions.length) return prev
1581
+ const next = prev.slice()
1582
+ const a = positions[from].position
1583
+ const b = positions[to].position
1584
+ const tmp = next[a]
1585
+ next[a] = next[b]
1586
+ next[b] = tmp
1587
+ return next
1588
+ })
1547
1589
  const accountOf = (provider, index) => accounts.find((a) => a.provider === provider && a.index === index) || {}
1548
1590
 
1549
1591
  const save = async () => {
@@ -1762,6 +1804,34 @@ window.__ModuleLoader__.load({
1762
1804
  later(() => setInfo(''), 3000)
1763
1805
  }
1764
1806
 
1807
+ const runBatch = async (kind, provider) => {
1808
+ if (batch) return
1809
+ const rows = slots.filter((slot) => slot && slot.provider === provider)
1810
+ if (!rows.length) return
1811
+ batchCancelRef.current = false
1812
+ setBatch({ kind, provider, done: 0, total: rows.length })
1813
+ const queue = rows.slice()
1814
+ const worker = async () => {
1815
+ while (queue.length && !batchCancelRef.current) {
1816
+ const slot = queue.shift()
1817
+ try {
1818
+ if (kind === 'import') await importLocalCli(slot.provider, slot.index)
1819
+ else await doCheck(slot.provider, slot.index)
1820
+ } catch (e) {
1821
+ setErr(cleanErrorMessage(e.message || e))
1822
+ } finally {
1823
+ setBatch((b) => b ? Object.assign({}, b, { done: b.done + 1 }) : b)
1824
+ }
1825
+ }
1826
+ }
1827
+ await Promise.all([worker(), worker()])
1828
+ setBatch(null)
1829
+ }
1830
+ const cancelBatch = () => {
1831
+ batchCancelRef.current = true
1832
+ setBatch((b) => b ? Object.assign({}, b, { cancelled: true }) : b)
1833
+ }
1834
+
1765
1835
  const importToken = async (provider, index) => {
1766
1836
  setErr('')
1767
1837
  const tok = (tokenDraft[provider + ':' + index] || '').trim()
@@ -2849,10 +2919,22 @@ h('div', { className: 'dsub-row' },
2849
2919
  .map((slot, i) => ({ slot: slot, i: i }))
2850
2920
  .filter((row) => row.slot.provider === prov.id)
2851
2921
  return h('div', { className: 'dsub-block', key: prov.id },
2852
- h('div', { className: 'dsub-h' }, prov.name),
2922
+ h('div', { className: 'dsub-h', style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 6, flexWrap: 'wrap' } },
2923
+ h('span', null, prov.name),
2924
+ h('div', { className: 'dsub-row', style: { marginTop: 0, gap: 4, flexWrap: 'wrap' } },
2925
+ h('button', { type: 'button', className: 'dsub-mini', disabled: Boolean(batch), onClick: () => runBatch('check', prov.id).catch((e) => setErr(cleanErrorMessage(e.message || e))) }, t('checkAllAccounts')),
2926
+ h('button', { type: 'button', className: 'dsub-mini', disabled: Boolean(batch), onClick: () => runBatch('check', prov.id).catch((e) => setErr(cleanErrorMessage(e.message || e))) }, t('refreshAllQuotas')),
2927
+ h('button', { type: 'button', className: 'dsub-mini', disabled: Boolean(batch), onClick: () => runBatch('import', prov.id).catch((e) => setErr(cleanErrorMessage(e.message || e))) }, t('importAllCli')),
2928
+ batch && batch.provider === prov.id ? h(React.Fragment, null,
2929
+ h('span', { className: 'dsub-sub' }, t('batchProgress').replace('{done}', String(batch.done)).replace('{total}', String(batch.total))),
2930
+ h('button', { type: 'button', className: 'dsub-mini', onClick: cancelBatch }, t('cancel'))
2931
+ ) : null,
2932
+ ),
2933
+ ),
2853
2934
  rows.map((row) => {
2854
2935
  const account = accountOf(row.slot.provider, row.slot.index)
2855
2936
  const key = row.slot.provider + ':' + row.slot.index
2937
+ const rowPosition = rows.findIndex((candidate) => candidate.i === row.i)
2856
2938
  const on = !!account.configured
2857
2939
  const poolEnabled = row.slot.poolEnabled !== false
2858
2940
  const poolModels = Array.isArray(row.slot.poolModels) ? row.slot.poolModels : null
@@ -2903,6 +2985,9 @@ h('div', { className: 'dsub-row' },
2903
2985
  },
2904
2986
  }),
2905
2987
  h('span', { className: 'dsub-badge' + (account.isRevoked ? ' dsub-badge-bad' : (account.validationUrl ? ' dsub-badge-warn' : (on ? ' dsub-badge-on' : ''))) }, badgeFor(account, t)),
2988
+ account.capabilities && account.capabilities.vision ? h('span', { className: 'dsub-model-tag', title: 'Vision' }, '👁') : null,
2989
+ account.capabilities && account.capabilities.tools ? h('span', { className: 'dsub-model-tag', title: 'Tools' }, '🔧') : null,
2990
+ account.capabilities && account.capabilities.thinking ? h('span', { className: 'dsub-model-tag', title: 'Thinking' }, '🧠') : null,
2906
2991
  normalizePlanBadge(row.slot.provider, account.plan || account.paidTierName || (account.quota && account.quota.plan)),
2907
2992
  h('button', {
2908
2993
  type: 'button', className: 'dsub-mini',
@@ -2968,6 +3053,20 @@ h('div', { className: 'dsub-row' },
2968
3053
  type: 'button', className: 'dsub-mini', title: t('quarantineParkTitle'),
2969
3054
  onClick: () => doParkQuarantine(row.slot.provider, row.slot.index, 3600000),
2970
3055
  }, ('⏸ ' + t('quarantinePark'))) : null)),
3056
+ h('button', {
3057
+ type: 'button', className: 'dsub-mini', title: t('moveUp'),
3058
+ disabled: rowPosition <= 0,
3059
+ onClick: () => moveSlot(row.slot.provider, row.slot.index, -1),
3060
+ }, '↑'),
3061
+ h('button', {
3062
+ type: 'button', className: 'dsub-mini', title: t('moveDown'),
3063
+ disabled: rowPosition < 0 || rowPosition >= rows.length - 1,
3064
+ onClick: () => moveSlot(row.slot.provider, row.slot.index, 1),
3065
+ }, '↓'),
3066
+ h('button', {
3067
+ type: 'button', className: 'dsub-mini', title: t('copyRef'),
3068
+ onClick: () => copyText(row.slot.ref || key),
3069
+ }, '📋'),
2971
3070
  h('button', {
2972
3071
  type: 'button', className: 'dsub-mini', title: t('removeSlot'),
2973
3072
  onClick: () => {
@@ -4260,181 +4359,6 @@ h('div', { className: 'dsub-row' },
4260
4359
  )
4261
4360
  }
4262
4361
 
4263
- function WebhookTestButton(props) {
4264
- const t = (props && props.t) || ((k) => k)
4265
- const [state, setState] = React.useState({ busy: false, message: '', error: null })
4266
- const send = () => {
4267
- setState({ busy: true, message: '', error: null })
4268
- fetch('/dsh-subscriptions/webhook/test', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' })
4269
- .then(async (res) => {
4270
- const data = await res.json().catch(() => ({}))
4271
- if (!res.ok || !data.delivered) throw new Error((data.error && data.error.message) || ('HTTP ' + res.status))
4272
- setState({ busy: false, message: t('delivered'), error: null })
4273
- })
4274
- .catch((err) => setState({ busy: false, message: '', error: String(err && err.message || err) }))
4275
- }
4276
- return h('div', { className: 'dsub-row' },
4277
- h('button', { type: 'button', className: 'dsub-mini', disabled: state.busy, onClick: send }, state.busy ? '…' : t('sendTest')),
4278
- state.message ? h('span', { className: 'dsub-ok' }, state.message) : null,
4279
- state.error ? h('span', { className: 'dsub-bad' }, state.error) : null,
4280
- )
4281
- }
4282
-
4283
- // #736: vault snapshot list with create/restore. Payload stays server-side.
4284
- function VaultSnapshotsPanel(props) {
4285
- const t = (props && props.t) || ((k) => k)
4286
- const [state, setState] = React.useState({ snapshots: [], stats: null, message: '', error: null, busy: false })
4287
- const pull = () => {
4288
- fetch('/dsh-subscriptions/vault/snapshots', { cache: 'no-store' })
4289
- .then((res) => res.json())
4290
- .then((data) => setState((st) => Object.assign({}, st, { snapshots: data.snapshots || [], stats: data.stats || null, error: null })))
4291
- .catch((err) => setState((st) => Object.assign({}, st, { error: String(err && err.message || err) })))
4292
- }
4293
- React.useEffect(() => { pull() }, [])
4294
- const call = (url, body) => fetch(url, {
4295
- method: 'POST',
4296
- headers: { 'content-type': 'application/json' },
4297
- body: JSON.stringify(body || {}),
4298
- }).then(async (res) => {
4299
- const data = await res.json().catch(() => ({}))
4300
- if (!res.ok || !data.ok) throw new Error((data.error && data.error.message) || ('HTTP ' + res.status))
4301
- return data
4302
- })
4303
- const create = () => {
4304
- setState((st) => Object.assign({}, st, { busy: true, message: '', error: null }))
4305
- call('/dsh-subscriptions/vault/snapshots', { label: 'manual' })
4306
- .then((data) => { setState((st) => Object.assign({}, st, { busy: false, message: t('created') + ' ' + data.snapshot.id })); pull() })
4307
- .catch((err) => setState((st) => Object.assign({}, st, { busy: false, error: String(err && err.message || err) })))
4308
- }
4309
- const restore = (id) => {
4310
- setState((st) => Object.assign({}, st, { busy: true, message: '', error: null }))
4311
- call('/dsh-subscriptions/vault/snapshots/restore', { id })
4312
- .then((data) => { setState((st) => Object.assign({}, st, { busy: false, message: t('restoredSlots') + ' ' + data.slotCount })); pull() })
4313
- .catch((err) => setState((st) => Object.assign({}, st, { busy: false, error: String(err && err.message || err) })))
4314
- }
4315
- return h('div', { className: 'dsub-section-card' },
4316
- h('div', { className: 'dsub-section-title' }, t('vaultSnapshots'), h('button', { type: 'button', className: 'dsub-mini', onClick: create, disabled: state.busy }, state.busy ? '…' : t('createSnapshot'))),
4317
- h('div', { className: 'dsub-section-desc' }, t('vaultSnapshotsHint')),
4318
- state.stats ? h('div', { className: 'dsub-dim' }, state.stats.count + ' snapshots · ' + state.stats.bytes + ' bytes · ' + state.stats.path) : null,
4319
- state.snapshots.length ? state.snapshots.map((snap) => h('div', { className: 'dsub-row', key: snap.id },
4320
- h('span', { className: 'dsub-h' }, snap.id),
4321
- h('span', { className: 'dsub-dim' }, (snap.label || '-') + ' · ' + snap.slotCount + ' slots · ' + snap.createdAt),
4322
- h('button', { type: 'button', className: 'dsub-mini', disabled: state.busy, onClick: () => restore(snap.id) }, t('restoreSnapshot')),
4323
- )) : h('div', { className: 'dsub-dim' }, t('noSnapshots')),
4324
- state.message ? h('div', { className: 'dsub-ok' }, state.message) : null,
4325
- state.error ? h('div', { className: 'dsub-bad' }, state.error) : null,
4326
- )
4327
- }
4328
-
4329
- // #724: provider pack manager. Displays ids/models only; headers are masked.
4330
- function ProviderPacksPanel(props) {
4331
- const t = (props && props.t) || ((k) => k)
4332
- const [state, setState] = React.useState({ packs: [], text: '', message: '', error: null, busy: false })
4333
- const publicPack = (pack) => {
4334
- const copy = Object.assign({}, pack)
4335
- if (copy.clientSecret) copy.clientSecret = '••••'
4336
- if (copy.headers && typeof copy.headers === 'object') {
4337
- copy.headers = Object.fromEntries(Object.keys(copy.headers).map((key) => [key, '••••']))
4338
- }
4339
- return copy
4340
- }
4341
- const pull = () => {
4342
- fetch('/dsh-subscriptions/config', { cache: 'no-store' })
4343
- .then((res) => res.json())
4344
- .then((cfg) => setState((st) => Object.assign({}, st, { packs: (cfg.config && cfg.config.providerPacks) || [] })))
4345
- .catch((err) => setState((st) => Object.assign({}, st, { error: String(err && err.message || err) })))
4346
- }
4347
- React.useEffect(() => { pull() }, [])
4348
- const parse = () => {
4349
- const raw = JSON.parse(state.text || '{}')
4350
- return raw && raw.pack ? raw.pack : raw
4351
- }
4352
- const validate = (pack) => fetch('/dsh-subscriptions/provider-packs/validate', {
4353
- method: 'POST',
4354
- headers: { 'content-type': 'application/json' },
4355
- body: JSON.stringify({ pack }),
4356
- }).then(async (res) => {
4357
- const data = await res.json()
4358
- if (!res.ok || !data.ok) throw new Error((data.error && data.error.message) || ('HTTP ' + res.status))
4359
- return data.pack
4360
- })
4361
- const save = () => {
4362
- setState((st) => Object.assign({}, st, { busy: true, message: '', error: null }))
4363
- Promise.resolve().then(parse)
4364
- .then(async (pack) => {
4365
- const checked = await validate(pack)
4366
- const cfg = await fetch('/dsh-subscriptions/config', { cache: 'no-store' }).then((res) => res.json())
4367
- const packs = ((cfg.config && cfg.config.providerPacks) || []).filter((p) => p && p.id !== pack.id)
4368
- packs.push(pack)
4369
- const res = await fetch('/dsh-subscriptions/config', {
4370
- method: 'PUT',
4371
- headers: { 'content-type': 'application/json' },
4372
- body: JSON.stringify({ config: Object.assign({}, cfg.config, { providerPacks: packs }), revision: cfg.revision }),
4373
- })
4374
- if (!res.ok) throw new Error('HTTP ' + res.status)
4375
- setState((st) => Object.assign({}, st, { packs, busy: false, message: t('saved') + ' ' + checked.id, text: '' }))
4376
- })
4377
- .catch((err) => setState((st) => Object.assign({}, st, { busy: false, error: String(err && err.message || err) })))
4378
- }
4379
- const exportPacks = () => setState((st) => Object.assign({}, st, { text: JSON.stringify(st.packs.map(publicPack), null, 2), message: t('exported') }))
4380
- return h('div', { className: 'dsub-section-card' },
4381
- h('div', { className: 'dsub-section-title' }, t('providerPacks'), h('button', { type: 'button', className: 'dsub-mini', onClick: pull }, '↻')),
4382
- h('div', { className: 'dsub-section-desc' }, t('providerPacksHint')),
4383
- state.packs.length ? state.packs.map((pack) => h('div', { className: 'dsub-row', key: pack.id },
4384
- h('span', { className: 'dsub-h' }, pack.id),
4385
- h('span', { className: 'dsub-dim' }, (pack.displayName || '') + ' · ' + ((pack.models || []).length) + ' models'),
4386
- )) : h('div', { className: 'dsub-dim' }, t('noPacks')),
4387
- h('textarea', {
4388
- className: 'dsub-inp',
4389
- style: { width: '100%', minHeight: 120, fontFamily: 'ui-monospace,monospace', padding: 8 },
4390
- value: state.text,
4391
- placeholder: '{"id":"acme","displayName":"Acme AI","authUrl":"https://…","tokenUrl":"https://…","baseUrl":"https://…","clientId":"…","models":[{"id":"acme-1"}]}',
4392
- onChange: (e) => setState((st) => Object.assign({}, st, { text: e.target.value, message: '', error: null })),
4393
- }),
4394
- h('div', { className: 'dsub-row' },
4395
- h('button', { type: 'button', className: 'dsub-mini', disabled: state.busy, onClick: save }, state.busy ? '…' : t('validateAndSave')),
4396
- h('button', { type: 'button', className: 'dsub-mini', onClick: exportPacks }, t('exportPacks')),
4397
- ),
4398
- state.message ? h('div', { className: 'dsub-ok' }, state.message) : null,
4399
- state.error ? h('div', { className: 'dsub-bad' }, state.error) : null,
4400
- )
4401
- }
4402
-
4403
- // #713: read-only source chain panel. Never renders key values.
4404
- function SourcesPanel(props) {
4405
- const t = (props && props.t) || ((k) => k)
4406
- const [state, setState] = React.useState({ loading: true, data: null, error: null })
4407
- const pull = () => {
4408
- fetch('/dsh-subscriptions/sources', { cache: 'no-store' })
4409
- .then((res) => res.json())
4410
- .then((data) => setState({ loading: false, data, error: null }))
4411
- .catch((err) => setState({ loading: false, data: null, error: String(err && err.message || err) }))
4412
- }
4413
- React.useEffect(() => {
4414
- pull()
4415
- const id = setInterval(pull, 30 * 1000)
4416
- return () => clearInterval(id)
4417
- }, [])
4418
- const sourceLabel = (source) => source === 'api-key' ? t('apiKeySource') : (source === 'oauth' ? t('oauthSource') : (source === 'ollama' ? t('ollamaSource') : t('sourceNone')))
4419
- return h('div', { className: 'dsub-section-card' },
4420
- h('div', { className: 'dsub-section-title' }, t('sources'), h('button', { type: 'button', className: 'dsub-mini', onClick: pull }, state.loading ? '…' : '↻')),
4421
- h('div', { className: 'dsub-section-desc' }, t('sourcesHint')),
4422
- state.error ? h('div', { className: 'dsub-bad' }, state.error) : null,
4423
- state.data && state.data.keyRotation && !state.data.keyRotation.available
4424
- ? h('div', { className: 'dsub-dim' }, t('keyRotationOffline') + (state.data.keyRotation.reason ? ': ' + state.data.keyRotation.reason : ''))
4425
- : null,
4426
- state.data && state.data.keyRotation && state.data.keyRotation.available
4427
- ? h('div', { className: 'dsub-dim' }, t('keyRotationHealth') + ': ' + state.data.keyRotation.healthyKeys + '/' + state.data.keyRotation.totalKeys + ' healthy keys')
4428
- : null,
4429
- (state.data && state.data.providers ? state.data.providers : []).map((row) => h('div', { className: 'dsub-row', key: row.provider },
4430
- h('span', { className: 'dsub-h' }, row.provider),
4431
- h('span', { className: 'dsub-pillTag' }, sourceLabel(row.selected)),
4432
- row.candidates.map((c, i) => h('span', { key: i, className: 'dsub-dim' },
4433
- sourceLabel(c.source) + (c.available ? ' ✓' : ' ✕'))),
4434
- )),
4435
- )
4436
- }
4437
-
4438
4362
  // #688: floating/docked quota capsule. Draggable unless docked; opens settings.
4439
4363
  function QuotaCapsule(props) {
4440
4364
  const t = (props && props.t) || ((k) => k)
@@ -0,0 +1,187 @@
1
+ /* global window */
2
+ window.__ModuleLoader__.load({
3
+ id: '@goodandready/dsh-subscriptions',
4
+ chunk: 'client.panels.js',
5
+ factory: (require) => {
6
+ const React = require('react')
7
+ const h = React.createElement
8
+
9
+ function WebhookTestButton(props) {
10
+ const t = (props && props.t) || ((k) => k)
11
+ const [state, setState] = React.useState({ busy: false, message: '', error: null })
12
+ const send = () => {
13
+ setState({ busy: true, message: '', error: null })
14
+ fetch('/dsh-subscriptions/webhook/test', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' })
15
+ .then(async (res) => {
16
+ const data = await res.json().catch(() => ({}))
17
+ if (!res.ok || !data.delivered) throw new Error((data.error && data.error.message) || ('HTTP ' + res.status))
18
+ setState({ busy: false, message: t('delivered'), error: null })
19
+ })
20
+ .catch((err) => setState({ busy: false, message: '', error: String(err && err.message || err) }))
21
+ }
22
+ return h('div', { className: 'dsub-row' },
23
+ h('button', { type: 'button', className: 'dsub-mini', disabled: state.busy, onClick: send }, state.busy ? '…' : t('sendTest')),
24
+ state.message ? h('span', { className: 'dsub-ok' }, state.message) : null,
25
+ state.error ? h('span', { className: 'dsub-bad' }, state.error) : null,
26
+ )
27
+ }
28
+
29
+ // #736: vault snapshot list with create/restore. Payload stays server-side.
30
+ function VaultSnapshotsPanel(props) {
31
+ const t = (props && props.t) || ((k) => k)
32
+ const [state, setState] = React.useState({ snapshots: [], stats: null, message: '', error: null, busy: false })
33
+ const pull = () => {
34
+ fetch('/dsh-subscriptions/vault/snapshots', { cache: 'no-store' })
35
+ .then((res) => res.json())
36
+ .then((data) => setState((st) => Object.assign({}, st, { snapshots: data.snapshots || [], stats: data.stats || null, error: null })))
37
+ .catch((err) => setState((st) => Object.assign({}, st, { error: String(err && err.message || err) })))
38
+ }
39
+ React.useEffect(() => { pull() }, [])
40
+ const call = (url, body) => fetch(url, {
41
+ method: 'POST',
42
+ headers: { 'content-type': 'application/json' },
43
+ body: JSON.stringify(body || {}),
44
+ }).then(async (res) => {
45
+ const data = await res.json().catch(() => ({}))
46
+ if (!res.ok || !data.ok) throw new Error((data.error && data.error.message) || ('HTTP ' + res.status))
47
+ return data
48
+ })
49
+ const create = () => {
50
+ setState((st) => Object.assign({}, st, { busy: true, message: '', error: null }))
51
+ call('/dsh-subscriptions/vault/snapshots', { label: 'manual' })
52
+ .then((data) => { setState((st) => Object.assign({}, st, { busy: false, message: t('created') + ' ' + data.snapshot.id })); pull() })
53
+ .catch((err) => setState((st) => Object.assign({}, st, { busy: false, error: String(err && err.message || err) })))
54
+ }
55
+ const restore = (id) => {
56
+ setState((st) => Object.assign({}, st, { busy: true, message: '', error: null }))
57
+ call('/dsh-subscriptions/vault/snapshots/restore', { id })
58
+ .then((data) => { setState((st) => Object.assign({}, st, { busy: false, message: t('restoredSlots') + ' ' + data.slotCount })); pull() })
59
+ .catch((err) => setState((st) => Object.assign({}, st, { busy: false, error: String(err && err.message || err) })))
60
+ }
61
+ return h('div', { className: 'dsub-section-card' },
62
+ h('div', { className: 'dsub-section-title' }, t('vaultSnapshots'), h('button', { type: 'button', className: 'dsub-mini', onClick: create, disabled: state.busy }, state.busy ? '…' : t('createSnapshot'))),
63
+ h('div', { className: 'dsub-section-desc' }, t('vaultSnapshotsHint')),
64
+ state.stats ? h('div', { className: 'dsub-dim' }, state.stats.count + ' snapshots · ' + state.stats.bytes + ' bytes · ' + state.stats.path) : null,
65
+ state.snapshots.length ? state.snapshots.map((snap) => h('div', { className: 'dsub-row', key: snap.id },
66
+ h('span', { className: 'dsub-h' }, snap.id),
67
+ h('span', { className: 'dsub-dim' }, (snap.label || '-') + ' · ' + snap.slotCount + ' slots · ' + snap.createdAt),
68
+ h('button', { type: 'button', className: 'dsub-mini', disabled: state.busy, onClick: () => restore(snap.id) }, t('restoreSnapshot')),
69
+ )) : h('div', { className: 'dsub-dim' }, t('noSnapshots')),
70
+ state.message ? h('div', { className: 'dsub-ok' }, state.message) : null,
71
+ state.error ? h('div', { className: 'dsub-bad' }, state.error) : null,
72
+ )
73
+ }
74
+
75
+ // #724: provider pack manager. Displays ids/models only; headers are masked.
76
+ function ProviderPacksPanel(props) {
77
+ const t = (props && props.t) || ((k) => k)
78
+ const [state, setState] = React.useState({ packs: [], text: '', message: '', error: null, busy: false })
79
+ const publicPack = (pack) => {
80
+ const copy = Object.assign({}, pack)
81
+ if (copy.clientSecret) copy.clientSecret = '••••'
82
+ if (copy.headers && typeof copy.headers === 'object') {
83
+ copy.headers = Object.fromEntries(Object.keys(copy.headers).map((key) => [key, '••••']))
84
+ }
85
+ return copy
86
+ }
87
+ const pull = () => {
88
+ fetch('/dsh-subscriptions/config', { cache: 'no-store' })
89
+ .then((res) => res.json())
90
+ .then((cfg) => setState((st) => Object.assign({}, st, { packs: (cfg.config && cfg.config.providerPacks) || [] })))
91
+ .catch((err) => setState((st) => Object.assign({}, st, { error: String(err && err.message || err) })))
92
+ }
93
+ React.useEffect(() => { pull() }, [])
94
+ const parse = () => {
95
+ const raw = JSON.parse(state.text || '{}')
96
+ return raw && raw.pack ? raw.pack : raw
97
+ }
98
+ const validate = (pack) => fetch('/dsh-subscriptions/provider-packs/validate', {
99
+ method: 'POST',
100
+ headers: { 'content-type': 'application/json' },
101
+ body: JSON.stringify({ pack }),
102
+ }).then(async (res) => {
103
+ const data = await res.json()
104
+ if (!res.ok || !data.ok) throw new Error((data.error && data.error.message) || ('HTTP ' + res.status))
105
+ return data.pack
106
+ })
107
+ const save = () => {
108
+ setState((st) => Object.assign({}, st, { busy: true, message: '', error: null }))
109
+ Promise.resolve().then(parse)
110
+ .then(async (pack) => {
111
+ const checked = await validate(pack)
112
+ const cfg = await fetch('/dsh-subscriptions/config', { cache: 'no-store' }).then((res) => res.json())
113
+ const packs = ((cfg.config && cfg.config.providerPacks) || []).filter((p) => p && p.id !== pack.id)
114
+ packs.push(pack)
115
+ const res = await fetch('/dsh-subscriptions/config', {
116
+ method: 'PUT',
117
+ headers: { 'content-type': 'application/json' },
118
+ body: JSON.stringify({ config: Object.assign({}, cfg.config, { providerPacks: packs }), revision: cfg.revision }),
119
+ })
120
+ if (!res.ok) throw new Error('HTTP ' + res.status)
121
+ setState((st) => Object.assign({}, st, { packs, busy: false, message: t('saved') + ' ' + checked.id, text: '' }))
122
+ })
123
+ .catch((err) => setState((st) => Object.assign({}, st, { busy: false, error: String(err && err.message || err) })))
124
+ }
125
+ const exportPacks = () => setState((st) => Object.assign({}, st, { text: JSON.stringify(st.packs.map(publicPack), null, 2), message: t('exported') }))
126
+ return h('div', { className: 'dsub-section-card' },
127
+ h('div', { className: 'dsub-section-title' }, t('providerPacks'), h('button', { type: 'button', className: 'dsub-mini', onClick: pull }, '↻')),
128
+ h('div', { className: 'dsub-section-desc' }, t('providerPacksHint')),
129
+ state.packs.length ? state.packs.map((pack) => h('div', { className: 'dsub-row', key: pack.id },
130
+ h('span', { className: 'dsub-h' }, pack.id),
131
+ h('span', { className: 'dsub-dim' }, (pack.displayName || '') + ' · ' + ((pack.models || []).length) + ' models'),
132
+ )) : h('div', { className: 'dsub-dim' }, t('noPacks')),
133
+ h('textarea', {
134
+ className: 'dsub-inp',
135
+ style: { width: '100%', minHeight: 120, fontFamily: 'ui-monospace,monospace', padding: 8 },
136
+ value: state.text,
137
+ placeholder: '{"id":"acme","displayName":"Acme AI","authUrl":"https://…","tokenUrl":"https://…","baseUrl":"https://…","clientId":"…","models":[{"id":"acme-1"}]}',
138
+ onChange: (e) => setState((st) => Object.assign({}, st, { text: e.target.value, message: '', error: null })),
139
+ }),
140
+ h('div', { className: 'dsub-row' },
141
+ h('button', { type: 'button', className: 'dsub-mini', disabled: state.busy, onClick: save }, state.busy ? '…' : t('validateAndSave')),
142
+ h('button', { type: 'button', className: 'dsub-mini', onClick: exportPacks }, t('exportPacks')),
143
+ ),
144
+ state.message ? h('div', { className: 'dsub-ok' }, state.message) : null,
145
+ state.error ? h('div', { className: 'dsub-bad' }, state.error) : null,
146
+ )
147
+ }
148
+
149
+ // #713: read-only source chain panel. Never renders key values.
150
+ function SourcesPanel(props) {
151
+ const t = (props && props.t) || ((k) => k)
152
+ const [state, setState] = React.useState({ loading: true, data: null, error: null })
153
+ const pull = () => {
154
+ fetch('/dsh-subscriptions/sources', { cache: 'no-store' })
155
+ .then((res) => res.json())
156
+ .then((data) => setState({ loading: false, data, error: null }))
157
+ .catch((err) => setState({ loading: false, data: null, error: String(err && err.message || err) }))
158
+ }
159
+ React.useEffect(() => {
160
+ pull()
161
+ const id = setInterval(pull, 30 * 1000)
162
+ return () => clearInterval(id)
163
+ }, [])
164
+ const sourceLabel = (source) => source === 'api-key' ? t('apiKeySource') : (source === 'oauth' ? t('oauthSource') : (source === 'ollama' ? t('ollamaSource') : t('sourceNone')))
165
+ return h('div', { className: 'dsub-section-card' },
166
+ h('div', { className: 'dsub-section-title' }, t('sources'), h('button', { type: 'button', className: 'dsub-mini', onClick: pull }, state.loading ? '…' : '↻')),
167
+ h('div', { className: 'dsub-section-desc' }, t('sourcesHint')),
168
+ state.error ? h('div', { className: 'dsub-bad' }, state.error) : null,
169
+ state.data && state.data.keyRotation && !state.data.keyRotation.available
170
+ ? h('div', { className: 'dsub-dim' }, t('keyRotationOffline') + (state.data.keyRotation.reason ? ': ' + state.data.keyRotation.reason : ''))
171
+ : null,
172
+ state.data && state.data.keyRotation && state.data.keyRotation.available
173
+ ? h('div', { className: 'dsub-dim' }, t('keyRotationHealth') + ': ' + state.data.keyRotation.healthyKeys + '/' + state.data.keyRotation.totalKeys + ' healthy keys')
174
+ : null,
175
+ (state.data && state.data.providers ? state.data.providers : []).map((row) => h('div', { className: 'dsub-row', key: row.provider },
176
+ h('span', { className: 'dsub-h' }, row.provider),
177
+ h('span', { className: 'dsub-pillTag' }, sourceLabel(row.selected)),
178
+ row.candidates.map((c, i) => h('span', { key: i, className: 'dsub-dim' },
179
+ sourceLabel(c.source) + (c.available ? ' ✓' : ' ✕'))),
180
+ )),
181
+ )
182
+ }
183
+
184
+
185
+ return { WebhookTestButton, VaultSnapshotsPanel, ProviderPacksPanel, SourcesPanel }
186
+ }
187
+ })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-subscriptions",
3
- "version": "0.7.34",
3
+ "version": "0.7.36",
4
4
  "description": "Use ChatGPT Codex, Claude, Grok, Antigravity, Kimi, GLM, Cursor, Kiro, Copilot, Qwen, ERNIE, Spark, JetBrains, Perplexity, Replit, and Cody subscriptions as DeepSeek Harness LLM providers via OAuth.",
5
5
  "license": "MIT",
6
6
  "type": "module",