@goodandready/dsh-subscriptions 0.4.7 → 0.4.9
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/adapter.js +14 -0
- package/lib/client.js +45 -9
- package/lib/history.js +66 -0
- package/lib/index.js +62 -0
- package/lib/subscriptions.js +14 -0
- package/lib/usage.js +2 -0
- package/package.json +1 -1
package/lib/adapter.js
CHANGED
|
@@ -121,6 +121,20 @@ export class SubscriptionAdapter extends LlmAdapter {
|
|
|
121
121
|
})
|
|
122
122
|
// поток завершился успешно: снять кулдаун и здоровье-штрафы
|
|
123
123
|
if (typeof deps.recordSuccess === 'function') deps.recordSuccess(account.ref)
|
|
124
|
+
// #65: записать в историю запросов и стоимости.
|
|
125
|
+
if (typeof deps.recordHistory === 'function') {
|
|
126
|
+
try {
|
|
127
|
+
deps.recordHistory({
|
|
128
|
+
provider,
|
|
129
|
+
ref: account.ref,
|
|
130
|
+
model: (opts && opts.model) || null,
|
|
131
|
+
path: '/responses',
|
|
132
|
+
method: 'POST',
|
|
133
|
+
status: 200,
|
|
134
|
+
kind: 'stream',
|
|
135
|
+
})
|
|
136
|
+
} catch {}
|
|
137
|
+
}
|
|
124
138
|
} catch (err) {
|
|
125
139
|
if (err && err.code === 'VALIDATION_REQUIRED' && err.validationUrl) {
|
|
126
140
|
await deps.saveBlob(account.ref, {
|
package/lib/client.js
CHANGED
|
@@ -42,6 +42,8 @@ window.__ModuleLoader__.load({
|
|
|
42
42
|
'.dsub-badge-warn{color:var(--dsw-alias-state-warning-primary);border-color:currentColor}' +
|
|
43
43
|
'.dsub-chip{display:inline-flex;align-items:center;gap:5px;height:26px;padding:0 10px;border-radius:999px;font-size:12px;font-weight:600;line-height:1;white-space:nowrap;color:var(--dsw-alias-label-secondary);border:1px solid var(--dsw-alias-border-l2)}' +
|
|
44
44
|
'.dsub-chipOn{color:var(--dsw-alias-state-success-primary);border-color:currentColor}' +
|
|
45
|
+
'.dsub-chipWarn{color:var(--dsw-alias-state-warning-primary);border-color:currentColor}' +
|
|
46
|
+
'.dsub-chipDanger{color:var(--dsw-alias-state-error-primary);border-color:currentColor}' +
|
|
45
47
|
'.dsub-chipDot{width:6px;height:6px;border-radius:999px;background:currentColor;flex:none}' +
|
|
46
48
|
|
|
47
49
|
'.dsub-link{background:none;border:none;color:var(--dsw-alias-label-secondary);cursor:pointer;font-size:12px;padding:0}' +
|
|
@@ -98,6 +100,7 @@ const cssId = 'dsh-subscriptions/settings.module.css'
|
|
|
98
100
|
'storedAs': 'Stored as',
|
|
99
101
|
'switchLabel': 'Provider',
|
|
100
102
|
'chipActive': 'Subscriptions',
|
|
103
|
+
'expiryLabel': 'expires',
|
|
101
104
|
'slashLogin': 'Login to a subscription provider',
|
|
102
105
|
'slashLogout': 'Log out a subscription provider',
|
|
103
106
|
'slashStatus': 'Subscription status',
|
|
@@ -152,6 +155,7 @@ const cssId = 'dsh-subscriptions/settings.module.css'
|
|
|
152
155
|
'storedAs': 'Хранится как',
|
|
153
156
|
'switchLabel': 'Провайдер',
|
|
154
157
|
'chipActive': 'Подписки',
|
|
158
|
+
'expiryLabel': 'окончание',
|
|
155
159
|
'slashLogin': 'Вход в провайдера подписки',
|
|
156
160
|
'slashLogout': 'Выйти из провайдера подписки',
|
|
157
161
|
'slashStatus': 'Статус подписок',
|
|
@@ -467,7 +471,8 @@ function PluginCard(props) {
|
|
|
467
471
|
// в карточке не оставалось подчёркиваний из чужого протокола.
|
|
468
472
|
function windowLabel(w){
|
|
469
473
|
var id=String((w&&w.id)||'')
|
|
470
|
-
|
|
474
|
+
var given=w&&(w.ru||w.en)
|
|
475
|
+
if(given&&given!==id)return given
|
|
471
476
|
if(id==='primary_window')return t('windowPrimary')
|
|
472
477
|
if(id==='secondary_window')return t('windowSecondary')
|
|
473
478
|
if(!id)return t('quota')
|
|
@@ -616,31 +621,62 @@ function PluginCard(props) {
|
|
|
616
621
|
const res = await fetch('/dsh-subscriptions/status', { cache: 'no-store' })
|
|
617
622
|
const data = await res.json().catch(() => ({}))
|
|
618
623
|
const loggedIn = (data && data.loggedIn) || {}
|
|
619
|
-
|
|
620
|
-
|
|
624
|
+
const usage = (data && data.usagePercent) || {}
|
|
625
|
+
const logged = ORDER.filter((p) => loggedIn[p])
|
|
626
|
+
const maxUsage = logged.reduce((m, p) => Math.max(m, usage[p] || 0), 0)
|
|
627
|
+
return {
|
|
628
|
+
logged,
|
|
629
|
+
usage: maxUsage,
|
|
630
|
+
expiresAt: (data && data.expiresAt) || {},
|
|
631
|
+
labels: (data && data.labels) || {},
|
|
632
|
+
expiryNotifyDays: (data && data.expiryNotifyDays) || 7,
|
|
633
|
+
}
|
|
634
|
+
} catch { return { logged: [], usage: 0, expiresAt: {}, labels: {}, expiryNotifyDays: 7 } }
|
|
621
635
|
}
|
|
622
636
|
|
|
623
637
|
// #52: компактный чип в шапке сессии со статусом подключённых подписок.
|
|
624
638
|
function HeaderChip(props) {
|
|
625
|
-
const [state, setState] = React.useState({ loading: true, count: 0 })
|
|
639
|
+
const [state, setState] = React.useState({ loading: true, count: 0, usage: 0, expiry: null })
|
|
626
640
|
const t = (props && props.t) || ((k) => k)
|
|
627
641
|
React.useEffect(() => {
|
|
628
642
|
const pull = () => {
|
|
629
|
-
refreshLoggedIn().then((
|
|
630
|
-
|
|
631
|
-
|
|
643
|
+
refreshLoggedIn().then((r) => {
|
|
644
|
+
// #67: ближайшее окончание подписки в пределах expiryNotifyDays.
|
|
645
|
+
let expiry = null
|
|
646
|
+
const now = Date.now()
|
|
647
|
+
const days = r.expiryNotifyDays || 7
|
|
648
|
+
for (const p of r.logged) {
|
|
649
|
+
const at = r.expiresAt[p]
|
|
650
|
+
if (!at) continue
|
|
651
|
+
const msLeft = at - now
|
|
652
|
+
if (msLeft > 0 && msLeft <= days * 24 * 60 * 60 * 1000) {
|
|
653
|
+
if (!expiry || at < expiry.at) expiry = { at, label: r.labels[p] || p }
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
setState({ loading: false, count: r.logged.length, usage: r.usage, expiry })
|
|
657
|
+
}).catch(() => setState({ loading: false, count: 0, usage: 0, expiry: null }))
|
|
632
658
|
}
|
|
633
659
|
pull()
|
|
634
660
|
const id = setInterval(pull, 60 * 1000)
|
|
635
661
|
return () => clearInterval(id)
|
|
636
662
|
}, [])
|
|
637
663
|
const on = state.count > 0
|
|
664
|
+
// #66: цвет чипа по максимальному usagePercent (50/80/100).
|
|
665
|
+
const u = state.usage
|
|
666
|
+
let cls = 'dsub-chip'
|
|
667
|
+
if (on) cls += u >= 100 ? ' dsub-chipDanger' : (u >= 80 ? ' dsub-chipDanger' : (u >= 50 ? ' dsub-chipWarn' : ' dsub-chipOn'))
|
|
668
|
+
// #67: надпись об окончании подписки.
|
|
669
|
+
let text = on ? String(state.count) : '—'
|
|
670
|
+
if (state.expiry) {
|
|
671
|
+
const d = new Date(state.expiry.at).toLocaleDateString()
|
|
672
|
+
text = t('expiryLabel') + ': ' + state.expiry.label + ' ' + d
|
|
673
|
+
}
|
|
638
674
|
return React.createElement('span', {
|
|
639
|
-
className:
|
|
675
|
+
className: cls,
|
|
640
676
|
title: on ? t('chipActive') + ': ' + state.count : t('none'),
|
|
641
677
|
},
|
|
642
678
|
React.createElement('span', { className: 'dsub-chipDot' }),
|
|
643
|
-
|
|
679
|
+
text,
|
|
644
680
|
)
|
|
645
681
|
}
|
|
646
682
|
|
package/lib/history.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { homedir } from 'node:os'
|
|
4
|
+
|
|
5
|
+
// #65: история запросов и стоимости. Хранится в
|
|
6
|
+
// ~/.dsh/storages/dsh-subscriptions/history.json (JSON-массив, новые сверху).
|
|
7
|
+
// Все IO best-effort и синхронные: сбой записи не должен ронять харнесс.
|
|
8
|
+
|
|
9
|
+
export const DEFAULT_HISTORY_DIR = join(homedir(), '.dsh', 'storages', 'dsh-subscriptions')
|
|
10
|
+
|
|
11
|
+
export class HistoryStore {
|
|
12
|
+
constructor(dir = DEFAULT_HISTORY_DIR, ttlMs = 7 * 24 * 60 * 60 * 1000) {
|
|
13
|
+
this.dir = dir
|
|
14
|
+
this.ttlMs = ttlMs
|
|
15
|
+
this.path = join(dir, 'history.json')
|
|
16
|
+
this.rows = []
|
|
17
|
+
this._load()
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
_load() {
|
|
21
|
+
try {
|
|
22
|
+
mkdirSync(this.dir, { recursive: true })
|
|
23
|
+
} catch { /* best-effort */ }
|
|
24
|
+
try {
|
|
25
|
+
const raw = JSON.parse(readFileSync(this.path, 'utf8'))
|
|
26
|
+
if (Array.isArray(raw)) this.rows = raw
|
|
27
|
+
} catch { /* absent/corrupt history is normal */ }
|
|
28
|
+
this._prune()
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
_prune() {
|
|
32
|
+
const cutoff = Date.now() - this.ttlMs
|
|
33
|
+
const before = this.rows.length
|
|
34
|
+
this.rows = this.rows.filter((r) => r && r.ts && r.ts >= cutoff)
|
|
35
|
+
if (this.rows.length !== before) this._persist()
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
_persist() {
|
|
39
|
+
try {
|
|
40
|
+
mkdirSync(this.dir, { recursive: true })
|
|
41
|
+
writeFileSync(this.path, JSON.stringify(this.rows))
|
|
42
|
+
} catch { /* best-effort */ }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Добавить одну запись. Возвращает длину истории. */
|
|
46
|
+
add(entry) {
|
|
47
|
+
if (!entry || typeof entry !== 'object') return this.rows.length
|
|
48
|
+
this.rows.unshift({ ts: Date.now(), ...entry })
|
|
49
|
+
this._prune()
|
|
50
|
+
this._persist()
|
|
51
|
+
return this.rows.length
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Последние N записей (новые сверху). */
|
|
55
|
+
recent(n) {
|
|
56
|
+
return this.rows.slice(0, n)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
all() {
|
|
60
|
+
return this.rows
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
size() {
|
|
64
|
+
return this.rows.length
|
|
65
|
+
}
|
|
66
|
+
}
|
package/lib/index.js
CHANGED
|
@@ -13,6 +13,7 @@ import { createSubscriptionsService } from './subscriptions.js'
|
|
|
13
13
|
import { encryptWithPassphrase, decryptWithPassphrase } from './crypto.js'
|
|
14
14
|
import { quotaSnapshot } from './ratelimit.js'
|
|
15
15
|
import { createAccountStore, normalizeSlots, vendorConfig } from './accounts.js'
|
|
16
|
+
import { HistoryStore } from './history.js'
|
|
16
17
|
import {
|
|
17
18
|
inspectGoogleAccount,
|
|
18
19
|
antigravityMetadata,
|
|
@@ -32,6 +33,8 @@ const Slot = z.object({
|
|
|
32
33
|
.description('Account slot number. Credential ref is <PROVIDER>_OAUTH_<index>.'),
|
|
33
34
|
label: z.string().default('')
|
|
34
35
|
.description('Optional display label. Empty uses the account email after login.'),
|
|
36
|
+
expiresAt: z.number().default(0)
|
|
37
|
+
.description('#67 Optional subscription expiry timestamp (ms). When set and within expiryNotifyDays, the header chip shows the account and expiry date.'),
|
|
35
38
|
})
|
|
36
39
|
|
|
37
40
|
const defaultSlots = PROVIDERS.map((provider) => ({ provider, index: 1, label: '' }))
|
|
@@ -49,6 +52,8 @@ export const Config = z.object({
|
|
|
49
52
|
.description('Background health-check interval in minutes. 0 disables.'),
|
|
50
53
|
notifyLimits: z.boolean().default(true)
|
|
51
54
|
.description('Emit log notices when usage crosses 70/90/100% of a window.'),
|
|
55
|
+
expiryNotifyDays: z.number().default(7)
|
|
56
|
+
.description('#67 Warn in the header chip this many days before a subscription expiry (expiresAt). 0 disables.'),
|
|
52
57
|
slots: z.array(Slot).default(defaultSlots)
|
|
53
58
|
.description('Account slots. Secrets are not stored here; only the credential ref names.'),
|
|
54
59
|
useWebCallback: z.boolean().default(false)
|
|
@@ -132,6 +137,9 @@ export function apply(ctx, config) {
|
|
|
132
137
|
try { ctx.emit && ctx.emit('subscriptions.limit-notice', { provider, ref, window: win.id, usedPercent: win.usedPercent, threshold }) } catch {}
|
|
133
138
|
},
|
|
134
139
|
})
|
|
140
|
+
const history = new HistoryStore()
|
|
141
|
+
const recordHistory = (entry) => history.add(entry)
|
|
142
|
+
|
|
135
143
|
const subscriptions = createSubscriptionsService({
|
|
136
144
|
listAccounts: (provider) => store.listAccounts(provider),
|
|
137
145
|
loadBlob: (ref) => store.loadBlob(ref),
|
|
@@ -149,6 +157,7 @@ export function apply(ctx, config) {
|
|
|
149
157
|
rememberQuota: (ref, snap) => store.rememberQuota(ref, snap),
|
|
150
158
|
rememberRequest: (ref) => store.rememberRequest(ref),
|
|
151
159
|
getRequestCount: (ref) => store.getRequestCount(ref),
|
|
160
|
+
recordHistory,
|
|
152
161
|
fetchImpl: fetch,
|
|
153
162
|
})
|
|
154
163
|
|
|
@@ -205,6 +214,7 @@ export function apply(ctx, config) {
|
|
|
205
214
|
getQuota: (ref) => store.getQuota(ref),
|
|
206
215
|
refreshUsage: (provider) => store.refreshUsage(provider),
|
|
207
216
|
saveBlob: (ref, blob) => store.saveBlob(ref, blob),
|
|
217
|
+
recordHistory,
|
|
208
218
|
fetchImpl: fetch,
|
|
209
219
|
})
|
|
210
220
|
|
|
@@ -508,9 +518,30 @@ export function apply(ctx, config) {
|
|
|
508
518
|
return
|
|
509
519
|
}
|
|
510
520
|
const logged = await store.loggedInProviders()
|
|
521
|
+
// #66/#67: usagePercent (максимум по аккаунтам) и expiresAt для чипа.
|
|
522
|
+
const usage = {}
|
|
523
|
+
const expires = {}
|
|
524
|
+
const labels = {}
|
|
525
|
+
for (const slot of normalizeSlots(live().slots)) {
|
|
526
|
+
try {
|
|
527
|
+
const info = await store.describeRef(slot.ref)
|
|
528
|
+
if (info.usagePercent != null) {
|
|
529
|
+
usage[slot.provider] = Math.max(usage[slot.provider] || 0, info.usagePercent)
|
|
530
|
+
}
|
|
531
|
+
// #67: дата окончания подписки берётся из слота (вводится в настройках).
|
|
532
|
+
if (slot.expiresAt) {
|
|
533
|
+
expires[slot.provider] = Math.max(expires[slot.provider] || 0, slot.expiresAt)
|
|
534
|
+
labels[slot.provider] = slot.label || info.label || slot.provider
|
|
535
|
+
}
|
|
536
|
+
} catch {}
|
|
537
|
+
}
|
|
511
538
|
writeJson(res, 200, {
|
|
512
539
|
ok: true,
|
|
513
540
|
loggedIn: Object.fromEntries(PROVIDERS.map((id) => [id, logged.includes(id)])),
|
|
541
|
+
usagePercent: usage,
|
|
542
|
+
expiresAt: expires,
|
|
543
|
+
labels,
|
|
544
|
+
expiryNotifyDays: live().expiryNotifyDays,
|
|
514
545
|
})
|
|
515
546
|
},
|
|
516
547
|
}), 'dsh-subscriptions: /status')
|
|
@@ -869,6 +900,25 @@ export function apply(ctx, config) {
|
|
|
869
900
|
}), 'dsh-subscriptions: /import-token')
|
|
870
901
|
|
|
871
902
|
// #50: сводная страница /subscriptions (localhost-only).
|
|
903
|
+
// #65: история запросов и стоимости (JSON).
|
|
904
|
+
ctx.effect(() => ctx.webServer.register({
|
|
905
|
+
kind: 'exact',
|
|
906
|
+
path: '/dsh-subscriptions/history',
|
|
907
|
+
handler: async (req, res) => {
|
|
908
|
+
if (req.method !== 'GET') {
|
|
909
|
+
writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET only' } })
|
|
910
|
+
return
|
|
911
|
+
}
|
|
912
|
+
const host = (req.headers.host || '').split(':')[0]
|
|
913
|
+
if (host !== 'localhost' && host !== '127.0.0.1') {
|
|
914
|
+
writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'localhost only' } })
|
|
915
|
+
return
|
|
916
|
+
}
|
|
917
|
+
const limit = Math.min(Number(queryOf(req).get('limit') || '10'), 100)
|
|
918
|
+
writeJson(res, 200, { ok: true, total: history.size(), items: history.recent(limit) })
|
|
919
|
+
},
|
|
920
|
+
}), 'dsh-subscriptions: /history')
|
|
921
|
+
|
|
872
922
|
ctx.effect(() => ctx.webServer.register({
|
|
873
923
|
kind: 'exact',
|
|
874
924
|
path: '/dsh-subscriptions/subscriptions',
|
|
@@ -925,6 +975,18 @@ h1{font-size:20px} .dim{color:#8b949e;font-size:13px}
|
|
|
925
975
|
<div class="dim">/subscriptions — localhost only</div>
|
|
926
976
|
<div class="stats"><span><b>${connected}</b> connected</span><span><b>${accounts.length}</b> accounts</span><span><b>${slots.length}</b> slots</span></div>
|
|
927
977
|
${body}
|
|
978
|
+
<h2>History</h2>
|
|
979
|
+
<div class="dim">last 10 · <a href="/dsh-subscriptions/history?limit=100">show 100</a></div>
|
|
980
|
+
<div class="hist" id="hist"></div>
|
|
981
|
+
<script>
|
|
982
|
+
fetch('/dsh-subscriptions/history?limit=10').then(r=>r.json()).then(d=>{
|
|
983
|
+
const el=document.getElementById('hist')
|
|
984
|
+
if(!d||!d.items||!d.items.length){el.textContent='No requests yet.';return}
|
|
985
|
+
el.innerHTML='<table class="grid"><tr><th>time</th><th>provider</th><th>model</th><th>path</th><th>status</th></tr>'+
|
|
986
|
+
d.items.map(i=>'<tr><td>'+new Date(i.ts).toLocaleString()+'</td><td>'+esc(i.provider)+'</td><td>'+esc(i.model||'')+'</td><td>'+esc(i.path)+'</td><td>'+i.status+'</td></tr>').join('')+'</table>'
|
|
987
|
+
}).catch(()=>{})
|
|
988
|
+
function esc(x){return String(x==null?'':x).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>')}
|
|
989
|
+
</script>
|
|
928
990
|
</body></html>`)
|
|
929
991
|
},
|
|
930
992
|
}), 'dsh-subscriptions: /subscriptions')
|
package/lib/subscriptions.js
CHANGED
|
@@ -159,6 +159,20 @@ export function createSubscriptionsService(deps) {
|
|
|
159
159
|
else err.code = "VENDOR"
|
|
160
160
|
throw err
|
|
161
161
|
}
|
|
162
|
+
// #65: записать в историю запросов и стоимости.
|
|
163
|
+
if (typeof deps.recordHistory === "function") {
|
|
164
|
+
try {
|
|
165
|
+
deps.recordHistory({
|
|
166
|
+
provider,
|
|
167
|
+
ref: account.ref,
|
|
168
|
+
model: (body && typeof body === "object" && body.model) || null,
|
|
169
|
+
path,
|
|
170
|
+
method,
|
|
171
|
+
status: res.status || 200,
|
|
172
|
+
kind: "request",
|
|
173
|
+
})
|
|
174
|
+
} catch {}
|
|
175
|
+
}
|
|
162
176
|
return res
|
|
163
177
|
} catch (err) {
|
|
164
178
|
lastError = err
|
package/lib/usage.js
CHANGED
|
@@ -58,6 +58,8 @@ const WINDOW_LABELS = {
|
|
|
58
58
|
weekly_limit_7_days: { ru: "7д", en: "7d" },
|
|
59
59
|
primary: { ru: "осн.", en: "primary" },
|
|
60
60
|
secondary: { ru: "втор.", en: "secondary" },
|
|
61
|
+
primary_window: { ru: "осн.", en: "primary" },
|
|
62
|
+
secondary_window: { ru: "втор.", en: "secondary" },
|
|
61
63
|
monthly: { ru: "месяц", en: "month" },
|
|
62
64
|
credits: { ru: "кредиты", en: "credits" },
|
|
63
65
|
}
|
package/package.json
CHANGED