@iasiv5/dsh-quota-watch 0.1.28 → 0.1.30
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.md +1 -1
- package/lib/client.js +45 -5
- package/package.json +4 -2
- package/src/client.mjs +80 -4
- package/src/host.mjs +61 -9
- package/src/shared.mjs +4 -0
package/README.md
CHANGED
|
@@ -48,7 +48,7 @@ Web 与 Desktop profile 均已实测适配。按 DSH 插件管理器提示完成
|
|
|
48
48
|
|
|
49
49
|
## 配置
|
|
50
50
|
|
|
51
|
-
本插件不再包含任何自动用卡配置。宿主首次启动时会一次性把历史版本保存的自动用卡开关**持久覆写为关闭**(即使设置文件不存在也会写入全关覆盖),因此回退到旧版本也不会让旧授权复活;覆写失败仅报告回退风险,新宿主本身没有任何自动消费路径。可配置项仅剩 profile patch 中的 `pollIntervalSec`(默认 60
|
|
51
|
+
本插件不再包含任何自动用卡配置。宿主首次启动时会一次性把历史版本保存的自动用卡开关**持久覆写为关闭**(即使设置文件不存在也会写入全关覆盖),因此回退到旧版本也不会让旧授权复活;覆写失败仅报告回退风险,新宿主本身没有任何自动消费路径。可配置项仅剩 profile patch 中的 `pollIntervalSec`(默认 60 秒,只读额度轮询节奏;无任何插件路由请求持续 2 个轮询周期时宿主暂停计划轮询——空闲暂停,下一次请求立即恢复,先答缓存、后台补一轮新数据;暂停不算刷新失败、不标记数据过期)。
|
|
52
52
|
|
|
53
53
|
## 重置卡与手动用卡
|
|
54
54
|
|
package/lib/client.js
CHANGED
|
@@ -198,7 +198,7 @@ function parseInset(computedValue) {
|
|
|
198
198
|
|
|
199
199
|
// src/client.mjs
|
|
200
200
|
var name = "quota-watch-client";
|
|
201
|
-
var inject = [];
|
|
201
|
+
var inject = ["slots"];
|
|
202
202
|
var CARD_SELECTOR = "[data-dsh-quota-watch-card]";
|
|
203
203
|
var FLOAT_SELECTOR = "[data-dsh-quota-watch-float]";
|
|
204
204
|
var FETCH_TIMEOUT_MS = 15e3;
|
|
@@ -655,7 +655,10 @@ function mountQuotaCard({
|
|
|
655
655
|
retryBackoff = 2,
|
|
656
656
|
// Q16: a confirmed SUCCESS notice lingers about five seconds before the
|
|
657
657
|
// panel returns to the latest inventory. Injectable for tests.
|
|
658
|
-
outcomeNoticeMs = 5e3
|
|
658
|
+
outcomeNoticeMs = 5e3,
|
|
659
|
+
// Shell-takeover sentinel (2026-10-13 ds-bot): inject `ctx.slots` to arm it.
|
|
660
|
+
// Injectable so DOM tests and exotic hosts keep the legacy behavior.
|
|
661
|
+
slots = null
|
|
659
662
|
} = {}) {
|
|
660
663
|
if (!doc?.body || typeof fetchImpl !== "function") return () => {
|
|
661
664
|
};
|
|
@@ -1459,12 +1462,22 @@ function mountQuotaCard({
|
|
|
1459
1462
|
let currentFoot = null;
|
|
1460
1463
|
let footTooNarrow = false;
|
|
1461
1464
|
let noDataHidden = false;
|
|
1465
|
+
let shellTakeover = false;
|
|
1466
|
+
const syncShellTakeover = () => {
|
|
1467
|
+
try {
|
|
1468
|
+
shellTakeover = (slots?.entries?.("sidebar.workspaces") ?? []).some(
|
|
1469
|
+
(entry) => (entry.options.priority ?? 0) < 0
|
|
1470
|
+
);
|
|
1471
|
+
} catch {
|
|
1472
|
+
shellTakeover = false;
|
|
1473
|
+
}
|
|
1474
|
+
};
|
|
1462
1475
|
const syncFootNarrowness = () => {
|
|
1463
1476
|
footTooNarrow = currentFoot !== null && currentFoot.clientWidth > 0 && currentFoot.clientWidth < CARD_MIN_WIDTH;
|
|
1464
1477
|
};
|
|
1465
1478
|
const syncCardVisibility = () => {
|
|
1466
1479
|
syncFootNarrowness();
|
|
1467
|
-
const showCard = !surfaceFlags.cardHidden && !footTooNarrow && !noDataHidden;
|
|
1480
|
+
const showCard = !surfaceFlags.cardHidden && !footTooNarrow && !noDataHidden && !shellTakeover;
|
|
1468
1481
|
container.hidden = !showCard;
|
|
1469
1482
|
card.style.display = showCard ? "" : "none";
|
|
1470
1483
|
};
|
|
@@ -1566,7 +1579,21 @@ function mountQuotaCard({
|
|
|
1566
1579
|
if (!disposed) place();
|
|
1567
1580
|
});
|
|
1568
1581
|
};
|
|
1569
|
-
const
|
|
1582
|
+
const SEAT_MARKERS = ["sidebarCol", "footArea"];
|
|
1583
|
+
const seatValid = () => currentFoot !== null && currentFoot.isConnected && container.parentElement === currentFoot && currentFoot.firstElementChild === container;
|
|
1584
|
+
const nodeTouchesSeatDiscovery = (node) => {
|
|
1585
|
+
if (node?.nodeType !== 1) return false;
|
|
1586
|
+
const cls = node.getAttribute("class") ?? "";
|
|
1587
|
+
if (SEAT_MARKERS.some((marker) => cls.includes(marker))) return true;
|
|
1588
|
+
return node.querySelector('[class*="sidebarCol"], [class*="footArea"]') != null;
|
|
1589
|
+
};
|
|
1590
|
+
const batchTouchesSeatDiscovery = (records) => records.some((record) => {
|
|
1591
|
+
const touched = [...record.addedNodes ?? [], ...record.removedNodes ?? []];
|
|
1592
|
+
return touched.some(nodeTouchesSeatDiscovery);
|
|
1593
|
+
});
|
|
1594
|
+
const observer = new win.MutationObserver((records) => {
|
|
1595
|
+
const noLiveFoot = currentFoot === null || !currentFoot.isConnected;
|
|
1596
|
+
if (!batchTouchesSeatDiscovery(records) && (seatValid() || noLiveFoot)) return;
|
|
1570
1597
|
schedulePlace();
|
|
1571
1598
|
});
|
|
1572
1599
|
observer.observe(doc.body, { childList: true, subtree: true });
|
|
@@ -1574,6 +1601,18 @@ function mountQuotaCard({
|
|
|
1574
1601
|
if (!disposed) renderAll();
|
|
1575
1602
|
}) : null;
|
|
1576
1603
|
langObserver?.observe(doc.documentElement, { attributeFilter: ["lang"] });
|
|
1604
|
+
let offShellTakeover = null;
|
|
1605
|
+
try {
|
|
1606
|
+
if (slots && typeof slots.subscribe === "function") {
|
|
1607
|
+
offShellTakeover = slots.subscribe("sidebar.workspaces", () => {
|
|
1608
|
+
if (disposed) return;
|
|
1609
|
+
syncShellTakeover();
|
|
1610
|
+
syncCardVisibility();
|
|
1611
|
+
});
|
|
1612
|
+
}
|
|
1613
|
+
} catch {
|
|
1614
|
+
}
|
|
1615
|
+
syncShellTakeover();
|
|
1577
1616
|
syncFloatVisibility();
|
|
1578
1617
|
place();
|
|
1579
1618
|
const nextPollDelay = () => {
|
|
@@ -1721,6 +1760,7 @@ function mountQuotaCard({
|
|
|
1721
1760
|
outcomeNoticeTimer = void 0;
|
|
1722
1761
|
observer.disconnect();
|
|
1723
1762
|
langObserver?.disconnect();
|
|
1763
|
+
if (offShellTakeover) offShellTakeover();
|
|
1724
1764
|
doc.removeEventListener("visibilitychange", onVisibilityChange);
|
|
1725
1765
|
doc.removeEventListener("pointerdown", onDocPointerDown, true);
|
|
1726
1766
|
doc.removeEventListener("scroll", onDocScroll, true);
|
|
@@ -1739,7 +1779,7 @@ function mountQuotaCard({
|
|
|
1739
1779
|
};
|
|
1740
1780
|
}
|
|
1741
1781
|
function apply(ctx) {
|
|
1742
|
-
ctx.effect(() => mountQuotaCard(), "dsh-quota-watch: sidebar");
|
|
1782
|
+
ctx.effect(() => mountQuotaCard({ slots: ctx.slots ?? null }), "dsh-quota-watch: sidebar");
|
|
1743
1783
|
}
|
|
1744
1784
|
|
|
1745
1785
|
return module.exports;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@iasiv5/dsh-quota-watch",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.30",
|
|
4
4
|
"description": "DSH Web sidebar monitor for GLM Coding Plan quotas and GitHub Copilot credits",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/host.mjs",
|
|
@@ -49,7 +49,9 @@
|
|
|
49
49
|
},
|
|
50
50
|
"client": {
|
|
51
51
|
"platform": "web",
|
|
52
|
-
"inject": [
|
|
52
|
+
"inject": [
|
|
53
|
+
"slots"
|
|
54
|
+
]
|
|
53
55
|
}
|
|
54
56
|
},
|
|
55
57
|
"scripts": {
|
package/src/client.mjs
CHANGED
|
@@ -5,7 +5,12 @@ import { MARGIN, clampPoint, loadFloatPosition, loadSurfaceFlags, saveFloatPosit
|
|
|
5
5
|
import { DRAG_SLOP_MOUSE, DRAG_SLOP_TOUCH, parseInset, clampFrame, dragSlop, grabOffset } from './client/drag.mjs'
|
|
6
6
|
|
|
7
7
|
export const name = 'quota-watch-client'
|
|
8
|
-
|
|
8
|
+
// 2026-10-13: the shell-takeover sentinel reads the OFFICIAL slots ledger, so
|
|
9
|
+
// the client now touches ctx.slots — cordis denies undeclared service access
|
|
10
|
+
// ("cannot get property 'slots' without inject") and the whole activation
|
|
11
|
+
// dies. Declare BEFORE using; the bundle + package.json dsh.client.inject
|
|
12
|
+
// must stay in lockstep (locked by tests/package-shape.test.mjs).
|
|
13
|
+
export const inject = ['slots']
|
|
9
14
|
|
|
10
15
|
const CARD_SELECTOR = '[data-dsh-quota-watch-card]'
|
|
11
16
|
const FLOAT_SELECTOR = '[data-dsh-quota-watch-float]'
|
|
@@ -556,6 +561,9 @@ export function mountQuotaCard({
|
|
|
556
561
|
// Q16: a confirmed SUCCESS notice lingers about five seconds before the
|
|
557
562
|
// panel returns to the latest inventory. Injectable for tests.
|
|
558
563
|
outcomeNoticeMs = 5_000,
|
|
564
|
+
// Shell-takeover sentinel (2026-10-13 ds-bot): inject `ctx.slots` to arm it.
|
|
565
|
+
// Injectable so DOM tests and exotic hosts keep the legacy behavior.
|
|
566
|
+
slots = null,
|
|
559
567
|
} = {}) {
|
|
560
568
|
if (!doc?.body || typeof fetchImpl !== 'function') return () => {}
|
|
561
569
|
if (doc.querySelector(CARD_SELECTOR) || doc.querySelector(FLOAT_SELECTOR)) return () => {}
|
|
@@ -1552,6 +1560,23 @@ export function mountQuotaCard({
|
|
|
1552
1560
|
let currentFoot = null
|
|
1553
1561
|
let footTooNarrow = false
|
|
1554
1562
|
let noDataHidden = false
|
|
1563
|
+
// The sidebar foot belongs to whoever registers sidebar.workspaces
|
|
1564
|
+
// (ui-sidebar doc: "the foot is the sidebar.workspaces registrant's"). The
|
|
1565
|
+
// official workspace tree registers at default priority 0 (ui-workspace); a
|
|
1566
|
+
// NEGATIVE-priority occupant is a takeover shell (ds-bot takes the seat at
|
|
1567
|
+
// -10 for its native bot sidebar) — while one is live the injected card
|
|
1568
|
+
// yields the whole foot, same contract as the dsh-skins/dsh-m footer rows.
|
|
1569
|
+
// The signal is the OFFICIAL slot ledger, not any ds-bot internal.
|
|
1570
|
+
let shellTakeover = false
|
|
1571
|
+
const syncShellTakeover = () => {
|
|
1572
|
+
try {
|
|
1573
|
+
shellTakeover = (slots?.entries?.('sidebar.workspaces') ?? []).some(
|
|
1574
|
+
(entry) => (entry.options.priority ?? 0) < 0,
|
|
1575
|
+
)
|
|
1576
|
+
} catch {
|
|
1577
|
+
shellTakeover = false
|
|
1578
|
+
}
|
|
1579
|
+
}
|
|
1555
1580
|
const syncFootNarrowness = () => {
|
|
1556
1581
|
footTooNarrow = currentFoot !== null && currentFoot.clientWidth > 0 && currentFoot.clientWidth < CARD_MIN_WIDTH
|
|
1557
1582
|
}
|
|
@@ -1564,7 +1589,7 @@ export function mountQuotaCard({
|
|
|
1564
1589
|
// The sidebar card shows ONLY in an expanded sidebar: the width guard hides
|
|
1565
1590
|
// it in narrow rails / squeezed containers (works on every profile), and no
|
|
1566
1591
|
// host collapse signal is consulted at all (they proved host-specific).
|
|
1567
|
-
const showCard = !surfaceFlags.cardHidden && !footTooNarrow && !noDataHidden
|
|
1592
|
+
const showCard = !surfaceFlags.cardHidden && !footTooNarrow && !noDataHidden && !shellTakeover
|
|
1568
1593
|
container.hidden = !showCard
|
|
1569
1594
|
card.style.display = showCard ? '' : 'none'
|
|
1570
1595
|
}
|
|
@@ -1685,7 +1710,42 @@ export function mountQuotaCard({
|
|
|
1685
1710
|
})
|
|
1686
1711
|
}
|
|
1687
1712
|
|
|
1688
|
-
|
|
1713
|
+
// Seat-gate (2026-10-15 perf): the body observer sees EVERY childList batch
|
|
1714
|
+
// in the whole app — during chat streaming that is a near-continuous stream —
|
|
1715
|
+
// and each scheduled place() used to open with an O(document) discovery scan
|
|
1716
|
+
// (`[class*="sidebarCol"]` cannot use a class index). The gate skips the
|
|
1717
|
+
// schedule entirely when this batch cannot change the seating:
|
|
1718
|
+
// • Seat valid (card still first child of a connected foot): only a batch
|
|
1719
|
+
// carrying a sidebarCol/footArea node — added OR removed, the element
|
|
1720
|
+
// itself or anywhere inside it — can change WHICH foot is the doc-order
|
|
1721
|
+
// first one or create a competing one. No such node ⇒ place() would be a
|
|
1722
|
+
// measured no-op.
|
|
1723
|
+
// • No live foot (null, or stale after the sidebar was torn down): a seat
|
|
1724
|
+
// can only appear through a sidebar-introducing node, and class-only
|
|
1725
|
+
// flips were already invisible to this childList-only observer.
|
|
1726
|
+
// A displacement inside a live foot fails seatValid() and re-arms place().
|
|
1727
|
+
// Invariant for the ResizeObserver rebind in place(): any batch that could
|
|
1728
|
+
// change which element IS the foot necessarily carries a marker node, so the
|
|
1729
|
+
// gate never masks a real foot swap (jsdom has no ResizeObserver; this
|
|
1730
|
+
// invariant is argued here, not exercised by tests). Marker-carrying batches
|
|
1731
|
+
// that change nothing still pay one discovery scan — the false-positive cost.
|
|
1732
|
+
const SEAT_MARKERS = ['sidebarCol', 'footArea']
|
|
1733
|
+
const seatValid = () => currentFoot !== null && currentFoot.isConnected
|
|
1734
|
+
&& container.parentElement === currentFoot
|
|
1735
|
+
&& currentFoot.firstElementChild === container
|
|
1736
|
+
const nodeTouchesSeatDiscovery = (node) => {
|
|
1737
|
+
if (node?.nodeType !== 1) return false
|
|
1738
|
+
const cls = node.getAttribute('class') ?? ''
|
|
1739
|
+
if (SEAT_MARKERS.some((marker) => cls.includes(marker))) return true
|
|
1740
|
+
return node.querySelector('[class*="sidebarCol"], [class*="footArea"]') != null
|
|
1741
|
+
}
|
|
1742
|
+
const batchTouchesSeatDiscovery = (records) => records.some((record) => {
|
|
1743
|
+
const touched = [...(record.addedNodes ?? []), ...(record.removedNodes ?? [])]
|
|
1744
|
+
return touched.some(nodeTouchesSeatDiscovery)
|
|
1745
|
+
})
|
|
1746
|
+
const observer = new win.MutationObserver((records) => {
|
|
1747
|
+
const noLiveFoot = currentFoot === null || !currentFoot.isConnected
|
|
1748
|
+
if (!batchTouchesSeatDiscovery(records) && (seatValid() || noLiveFoot)) return
|
|
1689
1749
|
schedulePlace()
|
|
1690
1750
|
})
|
|
1691
1751
|
observer.observe(doc.body, { childList: true, subtree: true })
|
|
@@ -1696,6 +1756,21 @@ export function mountQuotaCard({
|
|
|
1696
1756
|
? new win.MutationObserver(() => { if (!disposed) renderAll() })
|
|
1697
1757
|
: null
|
|
1698
1758
|
langObserver?.observe(doc.documentElement, { attributeFilter: ['lang'] })
|
|
1759
|
+
// Arm the shell-takeover sentinel: re-probe on every workspaces-ledger
|
|
1760
|
+
// mutation (microtask-batched by the slot core) and once up front. Without
|
|
1761
|
+
// an injected slots service (DOM tests, exotic hosts) this is a no-op and
|
|
1762
|
+
// the card behaves exactly as before.
|
|
1763
|
+
let offShellTakeover = null
|
|
1764
|
+
try {
|
|
1765
|
+
if (slots && typeof slots.subscribe === 'function') {
|
|
1766
|
+
offShellTakeover = slots.subscribe('sidebar.workspaces', () => {
|
|
1767
|
+
if (disposed) return
|
|
1768
|
+
syncShellTakeover()
|
|
1769
|
+
syncCardVisibility()
|
|
1770
|
+
})
|
|
1771
|
+
}
|
|
1772
|
+
} catch {}
|
|
1773
|
+
syncShellTakeover()
|
|
1699
1774
|
// Cold until data lands: keep the shell hidden so a slow or failed first
|
|
1700
1775
|
// probe never flashes an empty capsule (startPolling arms the retry chain).
|
|
1701
1776
|
syncFloatVisibility()
|
|
@@ -1870,6 +1945,7 @@ export function mountQuotaCard({
|
|
|
1870
1945
|
outcomeNoticeTimer = undefined
|
|
1871
1946
|
observer.disconnect()
|
|
1872
1947
|
langObserver?.disconnect()
|
|
1948
|
+
if (offShellTakeover) offShellTakeover()
|
|
1873
1949
|
doc.removeEventListener('visibilitychange', onVisibilityChange)
|
|
1874
1950
|
doc.removeEventListener('pointerdown', onDocPointerDown, true)
|
|
1875
1951
|
doc.removeEventListener('scroll', onDocScroll, true)
|
|
@@ -1890,5 +1966,5 @@ export function mountQuotaCard({
|
|
|
1890
1966
|
}
|
|
1891
1967
|
|
|
1892
1968
|
export function apply(ctx) {
|
|
1893
|
-
ctx.effect(() => mountQuotaCard(), 'dsh-quota-watch: sidebar')
|
|
1969
|
+
ctx.effect(() => mountQuotaCard({ slots: ctx.slots ?? null }), 'dsh-quota-watch: sidebar')
|
|
1894
1970
|
}
|
package/src/host.mjs
CHANGED
|
@@ -17,6 +17,7 @@ import { retireLegacyAutoResetSettings } from './core/legacy-auto-settings.mjs'
|
|
|
17
17
|
import { createManualOutcomeJournal } from './core/manual-outcome.mjs'
|
|
18
18
|
import {
|
|
19
19
|
DEFAULT_POLL_INTERVAL_SEC,
|
|
20
|
+
IDLE_AFTER_POLL_INTERVALS,
|
|
20
21
|
MAX_POLL_INTERVAL_SEC,
|
|
21
22
|
MIN_POLL_INTERVAL_SEC,
|
|
22
23
|
PROBE_TIMEOUT_MS,
|
|
@@ -128,6 +129,15 @@ function requestAllowed(ctx, req, res) {
|
|
|
128
129
|
return false
|
|
129
130
|
}
|
|
130
131
|
|
|
132
|
+
/** The access fence plus the idle-pause watcher stamp: any allowed request
|
|
133
|
+
* proves a browser is watching, so it feeds lastClientSeenAt. The call is
|
|
134
|
+
* defensive because route tests may pass minimal service doubles. */
|
|
135
|
+
function requestAllowedAndSeen(ctx, req, res, service) {
|
|
136
|
+
if (!requestAllowed(ctx, req, res)) return false
|
|
137
|
+
service?.noteClientSeen?.()
|
|
138
|
+
return true
|
|
139
|
+
}
|
|
140
|
+
|
|
131
141
|
/**
|
|
132
142
|
* Where the legacy automatic-use switches persisted. The start()-time one-shot
|
|
133
143
|
* retirement keeps this file overwritten all-off, so a rollback to an old
|
|
@@ -171,6 +181,20 @@ export function createQuotaService(ctx, options = {}) {
|
|
|
171
181
|
let coldAttempts = 0
|
|
172
182
|
let legacyRetirement
|
|
173
183
|
|
|
184
|
+
// --- Idle polling pause -----------------------------------------------------
|
|
185
|
+
// The scheduled poll serves browsers only. Once idleAfterMs passes with no
|
|
186
|
+
// request to any plugin route (every allowed route hit stamps
|
|
187
|
+
// lastClientSeenAt), timer fires skip the round entirely; the next client
|
|
188
|
+
// request resumes with a stale-while-revalidate kick. start() stamps once so
|
|
189
|
+
// the boot round always runs (the first client GET then meets data, not
|
|
190
|
+
// 'loading'). An undefined stamp (service built but never started, as in
|
|
191
|
+
// tests) never counts as idle, so direct refresh() calls behave as before.
|
|
192
|
+
const idleAfterMs = options.idleAfterMs ?? pollIntervalSec * 1000 * IDLE_AFTER_POLL_INTERVALS
|
|
193
|
+
let lastClientSeenAt
|
|
194
|
+
let idleActive = false
|
|
195
|
+
const idleNow = () => lastClientSeenAt !== undefined && now() - lastClientSeenAt >= idleAfterMs
|
|
196
|
+
const snapshotStale = () => updatedAt === undefined || now() - updatedAt >= pollIntervalSec * 1000
|
|
197
|
+
|
|
174
198
|
/** The usage slice a browser may see: old values survive only while the failure episode is short. */
|
|
175
199
|
function usageView(usage) {
|
|
176
200
|
if (usage === undefined) return undefined
|
|
@@ -444,6 +468,17 @@ export function createQuotaService(ctx, options = {}) {
|
|
|
444
468
|
// --- Reset cards -----------------------------------------------------------
|
|
445
469
|
|
|
446
470
|
function refresh(forceCards = false) {
|
|
471
|
+
// Idle pause: a timer-driven round with no recent client serves the cache
|
|
472
|
+
// and skips every upstream probe. A forced refresh is exempt, and any
|
|
473
|
+
// client-driven path has just stamped lastClientSeenAt, so it never lands
|
|
474
|
+
// here (one check covers both callers).
|
|
475
|
+
if (forceCards !== true && idleNow()) {
|
|
476
|
+
if (!idleActive) {
|
|
477
|
+
idleActive = true
|
|
478
|
+
logAuto('info', `quota-watch: idle — no client request for ${Math.round(idleAfterMs / 1000)}s, scheduled poll paused`)
|
|
479
|
+
}
|
|
480
|
+
return Promise.resolve(view())
|
|
481
|
+
}
|
|
447
482
|
if (pollPromise !== undefined) {
|
|
448
483
|
// A manual refresh must still re-list cards when it overlaps a scheduled
|
|
449
484
|
// round whose ten-minute gate skipped the list.
|
|
@@ -796,6 +831,19 @@ export function createQuotaService(ctx, options = {}) {
|
|
|
796
831
|
}
|
|
797
832
|
}
|
|
798
833
|
|
|
834
|
+
/** Any allowed route hit: stamp the watcher clock, and — if the snapshot
|
|
835
|
+
* went stale during a pause — kick a background round. Stale-while-
|
|
836
|
+
* revalidate: the caller serves the cached view immediately and never waits
|
|
837
|
+
* for the kick. Returns the kicked round's promise for tests. */
|
|
838
|
+
function noteClientSeen() {
|
|
839
|
+
const resuming = idleActive
|
|
840
|
+
lastClientSeenAt = now()
|
|
841
|
+
idleActive = false
|
|
842
|
+
if (resuming) logAuto('info', 'quota-watch: client request — scheduled poll resumed')
|
|
843
|
+
if (disposed || pollPromise !== undefined || !snapshotStale()) return undefined
|
|
844
|
+
return refresh()
|
|
845
|
+
}
|
|
846
|
+
|
|
799
847
|
function schedule(delayMs) {
|
|
800
848
|
if (disposed) return
|
|
801
849
|
timer = setTimeout(async () => {
|
|
@@ -825,6 +873,7 @@ export function createQuotaService(ctx, options = {}) {
|
|
|
825
873
|
return {
|
|
826
874
|
overview: view,
|
|
827
875
|
refresh,
|
|
876
|
+
noteClientSeen,
|
|
828
877
|
listResetCards,
|
|
829
878
|
useResetCard,
|
|
830
879
|
acknowledgeManualOutcome,
|
|
@@ -842,6 +891,9 @@ export function createQuotaService(ctx, options = {}) {
|
|
|
842
891
|
} else {
|
|
843
892
|
logAuto('warn', `quota-watch: could not overwrite the legacy auto-reset settings (${legacyRetirement.error}); a rollback to an old version could revive its switches, but this host has no automatic spending path`)
|
|
844
893
|
}
|
|
894
|
+
// Boot counts as watched for the first idle window: the initial round
|
|
895
|
+
// always runs, so the first client GET meets data instead of 'loading'.
|
|
896
|
+
lastClientSeenAt = now()
|
|
845
897
|
// Routes register synchronously in apply() before start(), so probing
|
|
846
898
|
// immediately is safe; the legacy 2s delay only padded the cold start.
|
|
847
899
|
schedule(0)
|
|
@@ -861,7 +913,7 @@ export function makeOverviewRoute(ctx, service) {
|
|
|
861
913
|
kind: 'exact',
|
|
862
914
|
path: ROUTES.overview,
|
|
863
915
|
handler: (req, res) => {
|
|
864
|
-
if (!
|
|
916
|
+
if (!requestAllowedAndSeen(ctx, req, res, service) || !methodAllowed(req, res, 'GET')) return
|
|
865
917
|
responseJson(res, 200, service.overview())
|
|
866
918
|
},
|
|
867
919
|
}
|
|
@@ -872,7 +924,7 @@ export function makeRefreshRoute(ctx, service) {
|
|
|
872
924
|
kind: 'exact',
|
|
873
925
|
path: ROUTES.refresh,
|
|
874
926
|
handler: async (req, res) => {
|
|
875
|
-
if (!
|
|
927
|
+
if (!requestAllowedAndSeen(ctx, req, res, service) || !methodAllowed(req, res, 'POST')) return
|
|
876
928
|
try {
|
|
877
929
|
await service.refresh(true)
|
|
878
930
|
responseJson(res, 200, service.overview())
|
|
@@ -889,7 +941,7 @@ export function makeResetCardsRoute(ctx, service) {
|
|
|
889
941
|
kind: 'exact',
|
|
890
942
|
path: ROUTES.resetCards,
|
|
891
943
|
handler: async (req, res) => {
|
|
892
|
-
if (!
|
|
944
|
+
if (!requestAllowedAndSeen(ctx, req, res, service) || !methodAllowed(req, res, 'GET')) return
|
|
893
945
|
try {
|
|
894
946
|
const result = await service.listResetCards()
|
|
895
947
|
responseJson(res, result.ok ? 200 : 502, result)
|
|
@@ -912,7 +964,7 @@ export function makeResetUseRoute(ctx, service) {
|
|
|
912
964
|
kind: 'exact',
|
|
913
965
|
path: ROUTES.resetUse,
|
|
914
966
|
handler: async (req, res) => {
|
|
915
|
-
if (!
|
|
967
|
+
if (!requestAllowedAndSeen(ctx, req, res, service) || !methodAllowed(req, res, 'POST')) return
|
|
916
968
|
const payload = await readJsonBody(req)
|
|
917
969
|
if (payload === undefined) {
|
|
918
970
|
responseJson(res, 400, { ok: false, error: 'invalid-body', message: '请求体不是合法 JSON' })
|
|
@@ -948,7 +1000,7 @@ export function makeResetAcknowledgeRoute(ctx, service) {
|
|
|
948
1000
|
kind: 'exact',
|
|
949
1001
|
path: ROUTES.resetAcknowledge,
|
|
950
1002
|
handler: async (req, res) => {
|
|
951
|
-
if (!
|
|
1003
|
+
if (!requestAllowedAndSeen(ctx, req, res, service) || !methodAllowed(req, res, 'POST')) return
|
|
952
1004
|
const payload = await readJsonBody(req)
|
|
953
1005
|
if (payload === undefined) {
|
|
954
1006
|
responseJson(res, 400, { ok: false, error: 'invalid-body', message: '请求体不是合法 JSON' })
|
|
@@ -975,7 +1027,7 @@ export function makeResetReceiptRoute(ctx, service) {
|
|
|
975
1027
|
kind: 'exact',
|
|
976
1028
|
path: ROUTES.resetReceipt,
|
|
977
1029
|
handler: async (req, res) => {
|
|
978
|
-
if (!
|
|
1030
|
+
if (!requestAllowedAndSeen(ctx, req, res, service) || !methodAllowed(req, res, 'POST')) return
|
|
979
1031
|
const payload = await readJsonBody(req)
|
|
980
1032
|
if (payload === undefined) {
|
|
981
1033
|
responseJson(res, 400, { ok: false, error: 'invalid-body', message: '请求体不是合法 JSON' })
|
|
@@ -1000,12 +1052,12 @@ const RETIRED_AUTO_RESET_PATH = '/api/dsh-quota-watch/auto-reset'
|
|
|
1000
1052
|
* The path is a private host constant precisely so it never re-enters the
|
|
1001
1053
|
* shared client contract in src/shared.mjs.
|
|
1002
1054
|
*/
|
|
1003
|
-
export function makeRetiredAutoResetRoute(ctx) {
|
|
1055
|
+
export function makeRetiredAutoResetRoute(ctx, service) {
|
|
1004
1056
|
return {
|
|
1005
1057
|
kind: 'exact',
|
|
1006
1058
|
path: RETIRED_AUTO_RESET_PATH,
|
|
1007
1059
|
handler: async (req, res) => {
|
|
1008
|
-
if (!
|
|
1060
|
+
if (!requestAllowedAndSeen(ctx, req, res, service) || !methodAllowed(req, res, 'POST')) return
|
|
1009
1061
|
responseJson(res, 410, { ok: false, error: 'retired', message: '自动用卡功能已退役;重置卡请在面板手动确认使用' })
|
|
1010
1062
|
},
|
|
1011
1063
|
}
|
|
@@ -1039,7 +1091,7 @@ export function apply(ctx, config = {}) {
|
|
|
1039
1091
|
ctx.webServer.register(makeResetUseRoute(ctx, service)),
|
|
1040
1092
|
ctx.webServer.register(makeResetAcknowledgeRoute(ctx, service)),
|
|
1041
1093
|
ctx.webServer.register(makeResetReceiptRoute(ctx, service)),
|
|
1042
|
-
ctx.webServer.register(makeRetiredAutoResetRoute(ctx)),
|
|
1094
|
+
ctx.webServer.register(makeRetiredAutoResetRoute(ctx, service)),
|
|
1043
1095
|
]
|
|
1044
1096
|
service.start()
|
|
1045
1097
|
ctx.effect(() => () => {
|
package/src/shared.mjs
CHANGED
|
@@ -31,3 +31,7 @@ export const MIN_POLL_INTERVAL_SEC = 30
|
|
|
31
31
|
export const MAX_POLL_INTERVAL_SEC = 3600
|
|
32
32
|
export const PROBE_TIMEOUT_MS = 10_000
|
|
33
33
|
export const CLIENT_POLL_INTERVAL_MS = 30_000
|
|
34
|
+
// Idle polling pause: a scheduled round is skipped once this many poll
|
|
35
|
+
// intervals have passed without any request to a plugin route (every allowed
|
|
36
|
+
// route hit stamps the watcher clock).
|
|
37
|
+
export const IDLE_AFTER_POLL_INTERVALS = 2
|