magpie-page-token-widget 0.1.0 → 0.2.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.md +150 -14
- package/lib/atomic.mjs +66 -0
- package/lib/collector.mjs +431 -123
- package/lib/forward.mjs +40 -19
- package/lib/normalize.mjs +66 -12
- package/lib/server.mjs +375 -58
- package/lib/state.mjs +197 -10
- package/lib/tailnet.mjs +140 -11
- package/mobile/app.js +242 -0
- package/mobile/index.html +66 -0
- package/mobile/style.css +9 -0
- package/package.json +2 -1
- package/scriptable/SetupUsageWidget.js +58 -58
- package/scriptable/UsageWidget.js +11 -2
- package/ui/app.js +292 -28
- package/ui/index.html +42 -1
- package/ui/style.css +81 -0
package/lib/collector.mjs
CHANGED
|
@@ -1,123 +1,431 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import path from 'node:path';
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
1
|
+
import { mkdir, readFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { writeAtomic } from './atomic.mjs';
|
|
4
|
+
import {
|
|
5
|
+
resolveQuotas, normalizeUsage, normalizeUsageSummary, sanitizeCachedLabels,
|
|
6
|
+
sanitizeCachedSummary, shanghaiDay, project, providerFamily, accountKey,
|
|
7
|
+
} from './normalize.mjs';
|
|
8
|
+
|
|
9
|
+
// The snapshot of the service is good for a minute. A reading that arrives
|
|
10
|
+
// later waits for the refresh that is running, and waits at most a few
|
|
11
|
+
// seconds for it.
|
|
12
|
+
const SNAPSHOT_TTL_MS = 60_000;
|
|
13
|
+
const MAX_WAIT_MS = 2_500;
|
|
14
|
+
// The plain quota answer is asked for at most once every half a minute, so a
|
|
15
|
+
// service that keeps finding nothing does not hammer the gateway.
|
|
16
|
+
const PLAIN_QUOTA_GAP_MS = 30_000;
|
|
17
|
+
// After ten minutes without a reader the service slows down; after half an
|
|
18
|
+
// hour it stops asking on its own and waits to be asked.
|
|
19
|
+
const IDLE_AFTER_MS = 10 * 60_000;
|
|
20
|
+
const IDLE_STOP_MS = 30 * 60_000;
|
|
21
|
+
const IDLE_CHECK_MS = 5 * 60_000;
|
|
22
|
+
|
|
23
|
+
// How often the service reads on its own. The user may pick a fixed rhythm
|
|
24
|
+
// instead; the smart one keeps the reading fresh while somebody is watching.
|
|
25
|
+
export const REFRESH_STRATEGIES = {
|
|
26
|
+
smart: {
|
|
27
|
+
id: 'smart', label: '智能更新', intervalMs: SNAPSHOT_TTL_MS,
|
|
28
|
+
idleMs: IDLE_AFTER_MS, stopMs: IDLE_STOP_MS, idleIntervalMs: IDLE_CHECK_MS, checkMs: IDLE_CHECK_MS,
|
|
29
|
+
},
|
|
30
|
+
'1m': { id: '1m', label: '每分钟', intervalMs: 60_000 },
|
|
31
|
+
'2m': { id: '2m', label: '每两分钟', intervalMs: 120_000 },
|
|
32
|
+
'5m': { id: '5m', label: '每五分钟', intervalMs: 300_000 },
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export const refreshStrategy = value => (REFRESH_STRATEGIES[value] ? value : 'smart');
|
|
36
|
+
|
|
37
|
+
const sleep = ms => new Promise(resolve => { setTimeout(resolve, ms).unref?.(); });
|
|
38
|
+
|
|
39
|
+
// On the disk the accounts are written as the name of the account alone: the
|
|
40
|
+
// whole address stays in the memory of the service and never reaches the
|
|
41
|
+
// phone.
|
|
42
|
+
const snapshotForDisk = (snapshot) => {
|
|
43
|
+
const parts = { ...snapshot.parts };
|
|
44
|
+
const today = parts.today;
|
|
45
|
+
if (today?.data?.accounts) {
|
|
46
|
+
parts.today = {
|
|
47
|
+
...today,
|
|
48
|
+
data: {
|
|
49
|
+
...today.data,
|
|
50
|
+
accounts: today.data.accounts.map((row) => ({
|
|
51
|
+
...row,
|
|
52
|
+
account: String(row.account || '').split('@')[0],
|
|
53
|
+
})),
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
return { ...snapshot, parts };
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export class Collector {
|
|
61
|
+
constructor({ upstream, magpieDir, snapshotFile, fetcher = fetch, clock = Date.now, display = {}, strategy = 'smart', ttlMs = SNAPSHOT_TTL_MS, maxWaitMs = MAX_WAIT_MS, requestTimeoutMs = 15_000 }) {
|
|
62
|
+
this.upstream = upstream;
|
|
63
|
+
this.magpieDir = magpieDir;
|
|
64
|
+
this.snapshotFile = snapshotFile;
|
|
65
|
+
this.fetcher = fetcher;
|
|
66
|
+
this.clock = clock;
|
|
67
|
+
this.display = display;
|
|
68
|
+
this.strategy = refreshStrategy(strategy);
|
|
69
|
+
this.ttlMs = ttlMs;
|
|
70
|
+
this.maxWaitMs = maxWaitMs;
|
|
71
|
+
this.requestTimeoutMs = requestTimeoutMs;
|
|
72
|
+
this.snapshot = { version: 1, parts: {}, failures: {} };
|
|
73
|
+
// The whole address of an account is kept in the memory of the service
|
|
74
|
+
// only. On the disk it is written as the name of the account alone,
|
|
75
|
+
// which is what the console shows anyway.
|
|
76
|
+
this.addresses = new Map();
|
|
77
|
+
// The last good reading of every account, so a reading that fails keeps
|
|
78
|
+
// the numbers of the last good one.
|
|
79
|
+
this.usage = new Map();
|
|
80
|
+
this.pending = null;
|
|
81
|
+
this.lastCollectedAt = 0;
|
|
82
|
+
this.lastPlainQuotaAt = 0;
|
|
83
|
+
this.lastRequestAt = 0;
|
|
84
|
+
this.expired = false;
|
|
85
|
+
this.timer = null;
|
|
86
|
+
this.stopped = true;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// A snapshot that was written by an earlier run is read back, but it is
|
|
90
|
+
// marked as old: a fresh reading has to replace it.
|
|
91
|
+
async load() {
|
|
92
|
+
try {
|
|
93
|
+
const saved = JSON.parse(await readFile(this.snapshotFile, 'utf8'));
|
|
94
|
+
if (!saved || saved.version !== 1 || !saved.parts || !saved.failures) return;
|
|
95
|
+
for (const key of ['quotas', 'today', 'week']) {
|
|
96
|
+
const part = saved.parts[key];
|
|
97
|
+
if (!part || !part.data) continue;
|
|
98
|
+
part.data = key === 'today' ? sanitizeCachedSummary(part.data) : sanitizeCachedLabels(part.data, key);
|
|
99
|
+
saved.failures[key] = true;
|
|
100
|
+
}
|
|
101
|
+
this.snapshot = saved;
|
|
102
|
+
} catch { /* Without a snapshot the first reading writes one. */ }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async request(endpoint, timeout = this.requestTimeoutMs) {
|
|
106
|
+
const response = await this.fetcher(new URL(endpoint, this.upstream), {
|
|
107
|
+
signal: AbortSignal.timeout(timeout),
|
|
108
|
+
headers: { Accept: 'application/json' },
|
|
109
|
+
redirect: 'error',
|
|
110
|
+
});
|
|
111
|
+
if (!response.ok) throw new Error('Magpie unavailable');
|
|
112
|
+
return response.json();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// The quota readings Magpie has already written down. They are the same
|
|
116
|
+
// numbers the kept answer carries, and they are here for the case where the
|
|
117
|
+
// gateway is busy.
|
|
118
|
+
async quotaCache() {
|
|
119
|
+
try {
|
|
120
|
+
return JSON.parse(await readFile(path.join(this.magpieDir, 'quotas.json'), 'utf8'));
|
|
121
|
+
} catch { return {}; }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// The plain answer is the one that asks the vendors. It is only asked for
|
|
125
|
+
// when the kept answer holds nothing at all and nothing is kept anywhere
|
|
126
|
+
// else, and never more than once every half a minute.
|
|
127
|
+
plainQuotaAllowed() {
|
|
128
|
+
return this.clock() - this.lastPlainQuotaAt >= PLAIN_QUOTA_GAP_MS;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async plainQuota() {
|
|
132
|
+
this.lastPlainQuotaAt = this.clock();
|
|
133
|
+
try {
|
|
134
|
+
return await this.request('/v1/magpie/quotas');
|
|
135
|
+
} catch { return null; }
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// The quota of the subscriptions. The kept answer is the one Magpie has
|
|
139
|
+
// already read, so it costs nothing. The plain answer is the fallback of a
|
|
140
|
+
// cold start, when the kept answer is empty and nothing is known yet.
|
|
141
|
+
async quotas() {
|
|
142
|
+
const cache = await this.quotaCache();
|
|
143
|
+
const previous = this.snapshot.parts.quotas?.data || [];
|
|
144
|
+
let rows = null;
|
|
145
|
+
let failure = null;
|
|
146
|
+
try {
|
|
147
|
+
rows = await this.request('/v1/magpie/quotas?kept=1');
|
|
148
|
+
} catch (error) {
|
|
149
|
+
failure = error;
|
|
150
|
+
}
|
|
151
|
+
try {
|
|
152
|
+
return { cards: resolveQuotas(rows, cache, previous), rows };
|
|
153
|
+
} catch (error) {
|
|
154
|
+
const empty = !failure && Array.isArray(rows?.data) && rows.data.length === 0;
|
|
155
|
+
if (empty && this.plainQuotaAllowed()) {
|
|
156
|
+
const plain = await this.plainQuota();
|
|
157
|
+
if (plain) {
|
|
158
|
+
try {
|
|
159
|
+
return { cards: resolveQuotas(plain, cache, previous), rows: plain };
|
|
160
|
+
} catch { /* The plain answer holds nothing usable either. */ }
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
// The old snapshot stays as it is: the reader keeps the last numbers it
|
|
164
|
+
// saw, marked as old.
|
|
165
|
+
throw failure || error;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// The address of every account of every vendor. Two accounts of one vendor
|
|
170
|
+
// that share the name before the "@" are marked as unknown, so they are
|
|
171
|
+
// never asked about and never swap places.
|
|
172
|
+
rememberAddresses(rows) {
|
|
173
|
+
const fresh = new Map();
|
|
174
|
+
for (const row of Array.isArray(rows?.data) ? rows.data : []) {
|
|
175
|
+
const family = providerFamily(row?.provider);
|
|
176
|
+
const address = typeof row?.user === 'string' ? row.user.trim() : '';
|
|
177
|
+
if (!family || !address.includes('@')) continue;
|
|
178
|
+
const key = accountKey(family, address);
|
|
179
|
+
if (!key) continue;
|
|
180
|
+
const known = fresh.get(key);
|
|
181
|
+
if (known === undefined) fresh.set(key, address);
|
|
182
|
+
else if (known !== address) fresh.set(key, null);
|
|
183
|
+
}
|
|
184
|
+
if (!fresh.size) return;
|
|
185
|
+
for (const [key, value] of fresh) this.addresses.set(key, value);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// The reading of today: the totals, the hours, the vendors and the accounts
|
|
189
|
+
// all come with one answer of the gateway.
|
|
190
|
+
async todayUsage(cards = []) {
|
|
191
|
+
try {
|
|
192
|
+
return normalizeUsageSummary(await this.request('/v1/magpie/usage?period=today'));
|
|
193
|
+
} catch (error) {
|
|
194
|
+
// An older Magpie does not know this answer. The old way is used then:
|
|
195
|
+
// the totals of the day, and one answer per account.
|
|
196
|
+
return this.legacyToday(cards);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// The way of the older Magpie: the totals of the day come from the requests
|
|
201
|
+
// of the day, and the numbers of an account are asked for one by one. It is
|
|
202
|
+
// only used when the newer answer is not there.
|
|
203
|
+
async legacyToday(cards) {
|
|
204
|
+
const raw = await this.request('/v1/magpie/usage/requests?period=today&limit=1');
|
|
205
|
+
const accounts = [];
|
|
206
|
+
for (const card of cards) {
|
|
207
|
+
if (!card.account) continue;
|
|
208
|
+
const address = this.addresses.get(card.id);
|
|
209
|
+
if (!address) continue;
|
|
210
|
+
// Every account stands on its own: one that fails does not take the
|
|
211
|
+
// numbers of the others with it.
|
|
212
|
+
let one = null;
|
|
213
|
+
try {
|
|
214
|
+
one = await this.request(
|
|
215
|
+
`/v1/magpie/usage/requests?period=today&limit=1&account=${encodeURIComponent(address)}`,
|
|
216
|
+
5000,
|
|
217
|
+
);
|
|
218
|
+
} catch { one = null; }
|
|
219
|
+
if (!one) continue;
|
|
220
|
+
accounts.push({
|
|
221
|
+
provider: card.provider,
|
|
222
|
+
account: address,
|
|
223
|
+
calls: one.calls,
|
|
224
|
+
input: one.input,
|
|
225
|
+
output: one.output,
|
|
226
|
+
cache_read: one.cache_read,
|
|
227
|
+
cache_write: one.cache_write,
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
return normalizeUsageSummary({
|
|
231
|
+
...raw,
|
|
232
|
+
providerKeys: Array.isArray(raw.by?.provider) ? raw.by.provider : [],
|
|
233
|
+
accounts,
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// The reading of the week keeps the answer of the requests, because it is
|
|
238
|
+
// the one that carries the models, the agents and the computers.
|
|
239
|
+
async weekUsage() {
|
|
240
|
+
return normalizeUsage(await this.request('/v1/magpie/usage/requests?period=7d&limit=1'));
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// The usage of an account belongs to the account it was taken for: the
|
|
244
|
+
// vendor and the whole address have to match. A name before the "@" is
|
|
245
|
+
// never enough.
|
|
246
|
+
attachUsage(cards, summary) {
|
|
247
|
+
for (const row of Array.isArray(summary?.accounts) ? summary.accounts : []) {
|
|
248
|
+
const family = row.family || providerFamily(row.provider);
|
|
249
|
+
const address = typeof row.account === 'string' ? row.account.trim() : '';
|
|
250
|
+
if (!family || !address.includes('@')) continue;
|
|
251
|
+
const key = accountKey(family, address);
|
|
252
|
+
if (!key) continue;
|
|
253
|
+
if (this.addresses.get(key) !== address) continue;
|
|
254
|
+
const usage = { today: { totalTokens: row.totalTokens, requests: row.requests } };
|
|
255
|
+
this.usage.set(key, usage);
|
|
256
|
+
const card = cards.find(entry => entry.id === key);
|
|
257
|
+
if (card) card.accountUsage = usage;
|
|
258
|
+
}
|
|
259
|
+
for (const card of cards) {
|
|
260
|
+
if (!card.accountUsage && card.account) card.accountUsage = this.usage.get(card.id) || null;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async collect() {
|
|
265
|
+
const now = this.clock();
|
|
266
|
+
const periodDay = shanghaiDay(now);
|
|
267
|
+
// The quota and the week are asked for at the same time. The reading of
|
|
268
|
+
// the day comes after the quota, because on an older Magpie it needs to
|
|
269
|
+
// know which account belongs to which vendor.
|
|
270
|
+
const [quotas, week] = await Promise.allSettled([
|
|
271
|
+
this.quotas(), this.weekUsage(),
|
|
272
|
+
]);
|
|
273
|
+
if (quotas.status === 'fulfilled') this.rememberAddresses(quotas.value.rows);
|
|
274
|
+
const cards = quotas.status === 'fulfilled' ? quotas.value.cards : [];
|
|
275
|
+
const today = await this.todayUsage(cards).then(
|
|
276
|
+
value => ({ status: 'fulfilled', value }),
|
|
277
|
+
reason => ({ status: 'rejected', reason }),
|
|
278
|
+
);
|
|
279
|
+
const observedAt = new Date(this.clock()).toISOString();
|
|
280
|
+
// Every part stands on its own: the quota of a failure never touches the
|
|
281
|
+
// reading of today, and a day that fails never touches the week.
|
|
282
|
+
if (quotas.status === 'fulfilled') {
|
|
283
|
+
const { cards: fresh } = quotas.value;
|
|
284
|
+
if (today.status === 'fulfilled') this.attachUsage(fresh, today.value);
|
|
285
|
+
else for (const card of fresh) if (!card.accountUsage && card.account) card.accountUsage = this.usage.get(card.id) || null;
|
|
286
|
+
this.snapshot.parts.quotas = { observedAt, periodDay, data: fresh };
|
|
287
|
+
this.snapshot.failures.quotas = !fresh.some(card => !card.stale);
|
|
288
|
+
} else {
|
|
289
|
+
this.snapshot.failures.quotas = true;
|
|
290
|
+
}
|
|
291
|
+
if (today.status === 'fulfilled') {
|
|
292
|
+
this.snapshot.parts.today = { observedAt, periodDay, data: today.value };
|
|
293
|
+
this.snapshot.failures.today = false;
|
|
294
|
+
} else {
|
|
295
|
+
this.snapshot.failures.today = true;
|
|
296
|
+
}
|
|
297
|
+
if (week.status === 'fulfilled') {
|
|
298
|
+
this.snapshot.parts.week = { observedAt, periodDay, data: week.value };
|
|
299
|
+
this.snapshot.failures.week = false;
|
|
300
|
+
} else {
|
|
301
|
+
this.snapshot.failures.week = true;
|
|
302
|
+
}
|
|
303
|
+
this.lastCollectedAt = this.clock();
|
|
304
|
+
await this.save();
|
|
305
|
+
return this.snapshot;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
async save() {
|
|
309
|
+
try {
|
|
310
|
+
await mkdir(path.dirname(this.snapshotFile), { recursive: true });
|
|
311
|
+
await writeAtomic(this.snapshotFile, JSON.stringify(snapshotForDisk(this.snapshot), null, 2), { mode: 0o600 });
|
|
312
|
+
} catch (error) {
|
|
313
|
+
console.log(`Snapshot could not be saved; serving the in-memory snapshot. ${error?.message || error}`);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Only one reading runs at a time. Every request that asks for a refresh
|
|
318
|
+
// joins the one that is running.
|
|
319
|
+
refresh() {
|
|
320
|
+
if (this.pending) return this.pending;
|
|
321
|
+
const job = this.collect()
|
|
322
|
+
.catch(() => { /* The parts that failed are already marked. */ })
|
|
323
|
+
.finally(() => { this.pending = null; });
|
|
324
|
+
this.pending = job;
|
|
325
|
+
return job;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
fresh() {
|
|
329
|
+
return this.lastCollectedAt > 0 && this.clock() - this.lastCollectedAt <= this.ttlMs;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Somebody is looking at the numbers. The console of the computer counts as
|
|
333
|
+
// well, otherwise a computer without a phone would never read anything
|
|
334
|
+
// again after the first reading.
|
|
335
|
+
noteActivity() {
|
|
336
|
+
this.lastRequestAt = this.clock();
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// The answer of the service. A reading that is old is waited for, but never
|
|
340
|
+
// for long: when the wait runs out the old numbers are given back, marked
|
|
341
|
+
// as old, and the reading continues in the background.
|
|
342
|
+
async ensureFresh() {
|
|
343
|
+
this.lastRequestAt = this.clock();
|
|
344
|
+
if (this.fresh()) {
|
|
345
|
+
this.expired = false;
|
|
346
|
+
return this.overview();
|
|
347
|
+
}
|
|
348
|
+
const job = this.refresh();
|
|
349
|
+
const outcome = await Promise.race([
|
|
350
|
+
job.then(() => 'done', () => 'done'),
|
|
351
|
+
sleep(this.maxWaitMs).then(() => 'late'),
|
|
352
|
+
]);
|
|
353
|
+
this.expired = outcome === 'late';
|
|
354
|
+
return this.overview();
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
overview() {
|
|
358
|
+
return project(this.snapshot, this.clock(), this.display, { expired: this.expired });
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// Every subscription the gateway knows, whatever the display choices are.
|
|
362
|
+
// The console shows the whole list so the user can pick a part of it.
|
|
363
|
+
allSubscriptions() {
|
|
364
|
+
const cards = this.snapshot.parts.quotas?.data || [];
|
|
365
|
+
return cards.map(card => ({
|
|
366
|
+
id: card.id,
|
|
367
|
+
provider: card.provider,
|
|
368
|
+
label: card.label,
|
|
369
|
+
account: card.account || null,
|
|
370
|
+
alias: card.alias || null,
|
|
371
|
+
}));
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
setDisplay(display) {
|
|
375
|
+
this.display = display;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// The rhythm of the reading. The user may pick a fixed one; the smart one
|
|
379
|
+
// keeps the numbers fresh while somebody is watching and slows down when
|
|
380
|
+
// nobody is.
|
|
381
|
+
setStrategy(value) {
|
|
382
|
+
this.strategy = refreshStrategy(value);
|
|
383
|
+
this.schedule();
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
start() {
|
|
387
|
+
this.stopped = false;
|
|
388
|
+
this.schedule();
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// The reading that is still on its way is waited for, so that the service
|
|
392
|
+
// does not go away in the middle of a write.
|
|
393
|
+
async stop() {
|
|
394
|
+
this.stopped = true;
|
|
395
|
+
clearTimeout(this.timer);
|
|
396
|
+
this.timer = null;
|
|
397
|
+
if (this.pending) {
|
|
398
|
+
try { await this.pending; } catch { /* The parts that failed are already marked. */ }
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
schedule() {
|
|
403
|
+
clearTimeout(this.timer);
|
|
404
|
+
if (this.stopped) return;
|
|
405
|
+
this.timer = setTimeout(() => {
|
|
406
|
+
this.timer = null;
|
|
407
|
+
this.tick();
|
|
408
|
+
this.schedule();
|
|
409
|
+
}, this.nextDelay());
|
|
410
|
+
this.timer.unref?.();
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
nextDelay() {
|
|
414
|
+
const strategy = REFRESH_STRATEGIES[this.strategy];
|
|
415
|
+
if (!strategy.idleMs) return strategy.intervalMs;
|
|
416
|
+
const idle = this.clock() - this.lastRequestAt;
|
|
417
|
+
if (idle >= strategy.stopMs) return strategy.checkMs;
|
|
418
|
+
if (idle >= strategy.idleMs) return strategy.idleIntervalMs;
|
|
419
|
+
return strategy.intervalMs;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
tick() {
|
|
423
|
+
const strategy = REFRESH_STRATEGIES[this.strategy];
|
|
424
|
+
const idle = this.clock() - this.lastRequestAt;
|
|
425
|
+
if (strategy.idleMs && idle >= strategy.stopMs) return;
|
|
426
|
+
if (this.clock() - this.lastCollectedAt < strategy.intervalMs) return;
|
|
427
|
+
void this.refresh();
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
export const createCollector = options => new Collector(options);
|
package/lib/forward.mjs
CHANGED
|
@@ -10,8 +10,8 @@ import { call } from './tailnet.mjs';
|
|
|
10
10
|
// through the tailnet can never be mistaken for one from this computer.
|
|
11
11
|
export const targetFor = port => '127.0.0.1:' + port;
|
|
12
12
|
|
|
13
|
-
export async function serveState() {
|
|
14
|
-
const result = await call(['serve', 'status', '--json']);
|
|
13
|
+
export async function serveState(runner) {
|
|
14
|
+
const result = await call(['serve', 'status', '--json'], 15000, runner);
|
|
15
15
|
if (!result.ok) return { ok: false, error: result.error };
|
|
16
16
|
try {
|
|
17
17
|
return { ok: true, data: JSON.parse(result.stdout) };
|
|
@@ -23,9 +23,20 @@ export async function serveState() {
|
|
|
23
23
|
// The status of Tailscale lists the published ports under "TCP".
|
|
24
24
|
export const publishedFor = (status, port) => status?.data?.TCP?.[String(port)] || null;
|
|
25
25
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
26
|
+
// What the plugin writes down about the forwarding it made, so a forwarding
|
|
27
|
+
// that was made by hand is never taken away and a forwarding of an older
|
|
28
|
+
// version can be found again after a restart.
|
|
29
|
+
export const forwardRecord = (publishedPort, phonePort, extra = {}) => ({
|
|
30
|
+
publishedPort,
|
|
31
|
+
phonePort,
|
|
32
|
+
target: targetFor(phonePort),
|
|
33
|
+
createdAt: new Date().toISOString(),
|
|
34
|
+
...extra,
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
// The plan of the forwarding. It never changes anything by itself: it says
|
|
38
|
+
// what has to be run.
|
|
39
|
+
export function planForward(publishedPort, phonePort, status, owned = null) {
|
|
29
40
|
const existing = publishedFor(status, publishedPort);
|
|
30
41
|
const target = targetFor(phonePort);
|
|
31
42
|
// An older version of this plugin pointed the published port at the console
|
|
@@ -38,30 +49,40 @@ export async function planForward(publishedPort, phonePort) {
|
|
|
38
49
|
const commands = existing && existing.TCPForward === target
|
|
39
50
|
? []
|
|
40
51
|
: [['serve', '--bg', '--tcp=' + publishedPort, 'tcp://' + target]];
|
|
41
|
-
return {
|
|
52
|
+
return {
|
|
53
|
+
ok: true,
|
|
54
|
+
commands,
|
|
55
|
+
ready: true,
|
|
56
|
+
target,
|
|
57
|
+
// A forwarding that was already there and points at us is taken over and
|
|
58
|
+
// written down, so it is known to be ours afterwards.
|
|
59
|
+
adopted: Boolean(existing) && !owned,
|
|
60
|
+
legacy: Boolean(existing) && existing.TCPForward === legacy,
|
|
61
|
+
};
|
|
42
62
|
}
|
|
43
63
|
|
|
44
|
-
export async function applyForward(publishedPort, phonePort) {
|
|
45
|
-
const
|
|
64
|
+
export async function applyForward(publishedPort, phonePort, runner, owned = null) {
|
|
65
|
+
const status = await serveState(runner);
|
|
66
|
+
if (!status.ok) return { ok: false, error: status.error };
|
|
67
|
+
const plan = planForward(publishedPort, phonePort, status, owned);
|
|
46
68
|
if (!plan.ok) return plan;
|
|
47
69
|
for (const command of plan.commands) {
|
|
48
|
-
const result = await call(command);
|
|
70
|
+
const result = await call(command, 15000, runner);
|
|
49
71
|
if (!result.ok) return { ok: false, error: result.error };
|
|
50
72
|
}
|
|
51
|
-
return { ok: true };
|
|
73
|
+
return { ok: true, target: plan.target, adopted: plan.adopted, legacy: plan.legacy };
|
|
52
74
|
}
|
|
53
75
|
|
|
54
|
-
// Only a forwarding that
|
|
55
|
-
|
|
56
|
-
|
|
76
|
+
// Only a forwarding that is written down as ours, and that still points where
|
|
77
|
+
// it was pointed, is removed.
|
|
78
|
+
export async function removeForward(publishedPort, phonePort, runner, owned = null) {
|
|
79
|
+
const status = await serveState(runner);
|
|
57
80
|
const existing = status.ok ? publishedFor(status, publishedPort) : null;
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
);
|
|
62
|
-
if (!ours) {
|
|
81
|
+
const recorded = owned && owned.publishedPort === publishedPort ? owned : null;
|
|
82
|
+
const target = recorded?.target || null;
|
|
83
|
+
if (!existing || !target || existing.TCPForward !== target) {
|
|
63
84
|
return { ok: false, error: '没有找到属于这个插件的转发。' };
|
|
64
85
|
}
|
|
65
|
-
const result = await call(['serve', '--tcp=' + publishedPort, 'off']);
|
|
86
|
+
const result = await call(['serve', '--tcp=' + publishedPort, 'off'], 15000, runner);
|
|
66
87
|
return result.ok ? { ok: true } : { ok: false, error: result.error };
|
|
67
88
|
}
|