dsh-usage-badge 0.1.4 → 0.1.5
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 +18 -3
- package/docs/design-notes.md +389 -1
- package/lib/buckets.js +237 -5
- package/lib/cache-store.js +574 -129
- package/lib/client.js +348 -3
- package/lib/fold.js +20 -10
- package/lib/index.js +469 -5
- package/lib/sync.js +1179 -0
- package/lib/webdav.js +532 -0
- package/package.json +3 -1
package/lib/client.js
CHANGED
|
@@ -38,6 +38,7 @@ window.__ModuleLoader__.load({
|
|
|
38
38
|
const TABS = [
|
|
39
39
|
{ id: 'usage', label: '用量' },
|
|
40
40
|
{ id: 'config', label: '单价配置' },
|
|
41
|
+
{ id: 'sync', label: '同步' },
|
|
41
42
|
]
|
|
42
43
|
|
|
43
44
|
/**
|
|
@@ -56,7 +57,7 @@ window.__ModuleLoader__.load({
|
|
|
56
57
|
* and in it a write the host does not understand comes back 200 and does nothing. The
|
|
57
58
|
* panel compares this number and says so rather than letting a button look broken.
|
|
58
59
|
*/
|
|
59
|
-
const REQUIRED_HOST_API =
|
|
60
|
+
const REQUIRED_HOST_API = 7
|
|
60
61
|
|
|
61
62
|
const CSS = `
|
|
62
63
|
.dub-pill{display:flex;align-items:center;gap:8px;box-sizing:border-box;width:100%;height:36px;padding:0 8px;border:0;border-radius:12px;background:0 0;color:var(--dsw-alias-label-secondary,#666);font:inherit;font-size:14px;line-height:22px;cursor:pointer;text-align:left}
|
|
@@ -1255,6 +1256,12 @@ window.__ModuleLoader__.load({
|
|
|
1255
1256
|
*/
|
|
1256
1257
|
let cachedConfig = null
|
|
1257
1258
|
let cachedOfficial = null
|
|
1259
|
+
/**
|
|
1260
|
+
* The last `/sync` payload, kept for the page's lifetime so returning to the tab renders at once
|
|
1261
|
+
* instead of flashing empty. The tab itself re-reads on every visit *only while a run is in
|
|
1262
|
+
* flight* — a page left open makes no requests.
|
|
1263
|
+
*/
|
|
1264
|
+
let cachedSync = null
|
|
1258
1265
|
|
|
1259
1266
|
/** The numeric fields the row editor manages, and how to name them in an error. */
|
|
1260
1267
|
const ROW_NUMBERS = [
|
|
@@ -2102,6 +2109,340 @@ window.__ModuleLoader__.load({
|
|
|
2102
2109
|
)
|
|
2103
2110
|
}
|
|
2104
2111
|
|
|
2112
|
+
/**
|
|
2113
|
+
* The 同步 panel.
|
|
2114
|
+
*
|
|
2115
|
+
* Cloud sync is **opt-in and manual**: this panel is the only place it is turned on, and it is the
|
|
2116
|
+
* only place that triggers it (plus one push after the price table changes). There is no polling of
|
|
2117
|
+
* the server — the plugin reaches the network when a person asks it to, which is why the state it
|
|
2118
|
+
* shows is the last run's, verbatim.
|
|
2119
|
+
*
|
|
2120
|
+
* The credential is entered here as plaintext, hashed **by the host half** into the digest HA1 the
|
|
2121
|
+
* server itself stores, and never written down in plaintext anywhere. That is also why the field
|
|
2122
|
+
* comes back empty: the page can say a credential exists, never what it is.
|
|
2123
|
+
*/
|
|
2124
|
+
function SyncPanel({ initialSync = null }) {
|
|
2125
|
+
const [status, setStatus] = React.useState(initialSync ?? cachedSync)
|
|
2126
|
+
const [url, setUrl] = React.useState(initialSync?.config?.url ?? '')
|
|
2127
|
+
const [username, setUsername] = React.useState(initialSync?.config?.username ?? '')
|
|
2128
|
+
const [password, setPassword] = React.useState('')
|
|
2129
|
+
const [realm, setRealm] = React.useState(initialSync?.config?.realm ?? '')
|
|
2130
|
+
const [deviceId, setDeviceId] = React.useState(initialSync?.config?.deviceId ?? '')
|
|
2131
|
+
const [windowYears, setWindowYears] = React.useState(String(initialSync?.config?.windowYears ?? 5))
|
|
2132
|
+
const [allowInsecureTls, setAllowInsecureTls] = React.useState(Boolean(initialSync?.config?.allowInsecureTls))
|
|
2133
|
+
const [state, setState] = React.useState('idle')
|
|
2134
|
+
const [note, setNote] = React.useState('')
|
|
2135
|
+
const [devices, setDevices] = React.useState(null)
|
|
2136
|
+
const [runNote, setRunNote] = React.useState('')
|
|
2137
|
+
// The failure verbatim and shown as-is: a sliced string is what once turned a connection error into
|
|
2138
|
+
// a blank line beside the word 失败. Kept beside the state so the state stays a name.
|
|
2139
|
+
const [failNote, setFailNote] = React.useState('')
|
|
2140
|
+
|
|
2141
|
+
const load = React.useCallback(async () => {
|
|
2142
|
+
try {
|
|
2143
|
+
const response = await fetch('/usage-badge/sync', { cache: 'no-store' })
|
|
2144
|
+
if (!response.ok) return null
|
|
2145
|
+
cachedSync = await response.json()
|
|
2146
|
+
setStatus(cachedSync)
|
|
2147
|
+
return cachedSync
|
|
2148
|
+
} catch {
|
|
2149
|
+
return null
|
|
2150
|
+
}
|
|
2151
|
+
}, [])
|
|
2152
|
+
|
|
2153
|
+
React.useEffect(() => {
|
|
2154
|
+
void load()
|
|
2155
|
+
}, [load])
|
|
2156
|
+
|
|
2157
|
+
// While a run is in flight the panel shows what the host is doing, so it asks again. The condition
|
|
2158
|
+
// includes the local `state` on purpose: the host's `running` flag only becomes true once *its* run
|
|
2159
|
+
// has started, and a 立即同步 press would otherwise sit through the whole sync without a single
|
|
2160
|
+
// refresh, looking like a button that did nothing.
|
|
2161
|
+
const running = Boolean(status?.running) || state === 'running'
|
|
2162
|
+
React.useEffect(() => {
|
|
2163
|
+
if (!running) return undefined
|
|
2164
|
+
const timer = setInterval(() => void load(), 2000)
|
|
2165
|
+
return () => clearInterval(timer)
|
|
2166
|
+
}, [running, load])
|
|
2167
|
+
|
|
2168
|
+
const save = async () => {
|
|
2169
|
+
setState('saving')
|
|
2170
|
+
setNote('')
|
|
2171
|
+
setFailNote('')
|
|
2172
|
+
try {
|
|
2173
|
+
const sentPassword = password !== ''
|
|
2174
|
+
const payload = { url, username, realm, deviceId, windowYears: Number(windowYears), allowInsecureTls }
|
|
2175
|
+
if (sentPassword) payload.password = password
|
|
2176
|
+
const response = await fetch('/usage-badge/sync', {
|
|
2177
|
+
method: 'POST',
|
|
2178
|
+
headers: { 'content-type': 'application/json' },
|
|
2179
|
+
body: JSON.stringify(payload),
|
|
2180
|
+
})
|
|
2181
|
+
const body = await response.json()
|
|
2182
|
+
if (!response.ok) throw new Error(body?.error ?? `HTTP ${response.status}`)
|
|
2183
|
+
cachedSync = body
|
|
2184
|
+
setStatus(body)
|
|
2185
|
+
setPassword('')
|
|
2186
|
+
if (body.config?.realm) setRealm(body.config.realm)
|
|
2187
|
+
setState('saved')
|
|
2188
|
+
// Saving only proves the values were accepted, not that they work — so the note says what the
|
|
2189
|
+
// next press is for instead of implying this one settled it.
|
|
2190
|
+
setNote(
|
|
2191
|
+
sentPassword
|
|
2192
|
+
? '已保存。点「测试连接」能确认地址、用户名、密码都对 —— 连不上时它会把原因说出来。'
|
|
2193
|
+
: '已保存。',
|
|
2194
|
+
)
|
|
2195
|
+
} catch (error) {
|
|
2196
|
+
setState('failed')
|
|
2197
|
+
setFailNote(String(error?.message ?? error))
|
|
2198
|
+
}
|
|
2199
|
+
}
|
|
2200
|
+
|
|
2201
|
+
const test = async () => {
|
|
2202
|
+
setState('testing')
|
|
2203
|
+
setNote('')
|
|
2204
|
+
setFailNote('')
|
|
2205
|
+
try {
|
|
2206
|
+
const response = await fetch('/usage-badge/sync/test', { method: 'POST' })
|
|
2207
|
+
const body = await response.json()
|
|
2208
|
+
if (!body.ok) throw new Error(body.error ?? '连接失败')
|
|
2209
|
+
setDevices(body.devices ?? [])
|
|
2210
|
+
setState('tested')
|
|
2211
|
+
setNote(`连上了:realm ${body.realm ?? '未知'},DAV ${body.dav || '未声明'}${body.devices?.length ? `,已有 ${body.devices.length} 台设备` : ''}${body.warning ? ` ⚠ ${body.warning}` : ''}`)
|
|
2212
|
+
if (!realm && body.realm) setRealm(body.realm)
|
|
2213
|
+
} catch (error) {
|
|
2214
|
+
setDevices(null)
|
|
2215
|
+
setState('failed')
|
|
2216
|
+
setFailNote(String(error?.message ?? error))
|
|
2217
|
+
}
|
|
2218
|
+
}
|
|
2219
|
+
|
|
2220
|
+
const run = async () => {
|
|
2221
|
+
setState('running')
|
|
2222
|
+
setRunNote('')
|
|
2223
|
+
setFailNote('')
|
|
2224
|
+
try {
|
|
2225
|
+
const response = await fetch('/usage-badge/sync/run', { method: 'POST' })
|
|
2226
|
+
const body = await response.json()
|
|
2227
|
+
// A 500 from the route carries `error`, not `message` — reading only `message` turned a total
|
|
2228
|
+
// failure into a run that reported success with nothing to say.
|
|
2229
|
+
if (!response.ok) throw new Error(body?.error ?? `HTTP ${response.status}`)
|
|
2230
|
+
if (body?.result === 'locked' || body?.result === 'failed') {
|
|
2231
|
+
setRunNote(body.message ?? '')
|
|
2232
|
+
setState('failed')
|
|
2233
|
+
setFailNote(body.message ?? '同步没有进行')
|
|
2234
|
+
return
|
|
2235
|
+
}
|
|
2236
|
+
setRunNote(body.message ?? '')
|
|
2237
|
+
setState('ran')
|
|
2238
|
+
await load()
|
|
2239
|
+
} catch (error) {
|
|
2240
|
+
setRunNote(String(error?.message ?? error))
|
|
2241
|
+
setState('failed')
|
|
2242
|
+
setFailNote(String(error?.message ?? error))
|
|
2243
|
+
}
|
|
2244
|
+
}
|
|
2245
|
+
|
|
2246
|
+
const toggleEnabled = async () => {
|
|
2247
|
+
const next = !status?.config?.enabled
|
|
2248
|
+
setState('saving')
|
|
2249
|
+
setNote('')
|
|
2250
|
+
setFailNote('')
|
|
2251
|
+
try {
|
|
2252
|
+
const response = await fetch('/usage-badge/sync', {
|
|
2253
|
+
method: 'POST',
|
|
2254
|
+
headers: { 'content-type': 'application/json' },
|
|
2255
|
+
body: JSON.stringify({ enabled: next }),
|
|
2256
|
+
})
|
|
2257
|
+
const body = await response.json()
|
|
2258
|
+
if (!response.ok) throw new Error(body?.error ?? `HTTP ${response.status}`)
|
|
2259
|
+
cachedSync = body
|
|
2260
|
+
setStatus(body)
|
|
2261
|
+
setState('saved')
|
|
2262
|
+
setNote(next ? '已开启同步。' : '已关闭同步 —— 远端上的东西不会被删掉。')
|
|
2263
|
+
} catch (error) {
|
|
2264
|
+
setState('failed')
|
|
2265
|
+
setFailNote(String(error?.message ?? error))
|
|
2266
|
+
}
|
|
2267
|
+
}
|
|
2268
|
+
|
|
2269
|
+
const config = status?.config ?? {}
|
|
2270
|
+
const remote = Object.entries(status?.remoteDevices ?? {})
|
|
2271
|
+
const published = Object.keys(status?.published ?? {}).length
|
|
2272
|
+
const conflicts = status?.conflictFiles ?? []
|
|
2273
|
+
const last = status?.last ?? {}
|
|
2274
|
+
|
|
2275
|
+
const row = (label, control, hint) =>
|
|
2276
|
+
h(
|
|
2277
|
+
'div',
|
|
2278
|
+
{ className: 'dub-form-row', key: label },
|
|
2279
|
+
h('span', { className: 'dub-stat-k' }, label),
|
|
2280
|
+
control,
|
|
2281
|
+
hint ? h('span', { className: 'dub-note' }, hint) : null,
|
|
2282
|
+
)
|
|
2283
|
+
|
|
2284
|
+
return h(
|
|
2285
|
+
'div',
|
|
2286
|
+
{ className: 'dub-body' },
|
|
2287
|
+
h(
|
|
2288
|
+
'div',
|
|
2289
|
+
{ className: 'dub-block' },
|
|
2290
|
+
h(
|
|
2291
|
+
'div',
|
|
2292
|
+
{ className: 'dub-block-head' },
|
|
2293
|
+
h('div', { className: 'dub-block-title' }, '云同步'),
|
|
2294
|
+
h(Info, {
|
|
2295
|
+
text: '把每天的用量与价格表备份到一台 WebDAV 服务器,多台机器互相合并。拉取与合并都在本机完成:远端只存按月压缩的分片,每台机器只写自己那一份,远端从不会因为本机的删除而删除。认证只支持 Digest(bytemark/webdav 这类 Apache 服务端)。',
|
|
2296
|
+
}),
|
|
2297
|
+
h(
|
|
2298
|
+
'button',
|
|
2299
|
+
{ className: `dub-btn${config.enabled ? ' dub-btn-primary' : ''}`, onClick: toggleEnabled, disabled: state === 'saving' },
|
|
2300
|
+
config.enabled ? '已开启 · 点此关闭' : '已关闭 · 点此开启',
|
|
2301
|
+
),
|
|
2302
|
+
),
|
|
2303
|
+
!status
|
|
2304
|
+
? h('div', { className: 'dub-note' }, '正在读取同步配置…')
|
|
2305
|
+
: h(
|
|
2306
|
+
'div',
|
|
2307
|
+
{ className: 'dub-form' },
|
|
2308
|
+
row(
|
|
2309
|
+
'WebDAV 地址',
|
|
2310
|
+
h('input', {
|
|
2311
|
+
className: 'dub-op',
|
|
2312
|
+
style: { flex: '1 1 260px' },
|
|
2313
|
+
value: url,
|
|
2314
|
+
placeholder: 'http://nas:19347/',
|
|
2315
|
+
onChange: (event) => setUrl(event.target.value),
|
|
2316
|
+
}),
|
|
2317
|
+
'必须以 / 结尾,且指到集合(目录)',
|
|
2318
|
+
),
|
|
2319
|
+
row('用户名', h('input', { className: 'dub-op', value: username, onChange: (event) => setUsername(event.target.value) })),
|
|
2320
|
+
row(
|
|
2321
|
+
'密码',
|
|
2322
|
+
h('input', {
|
|
2323
|
+
className: 'dub-op',
|
|
2324
|
+
type: 'password',
|
|
2325
|
+
value: password,
|
|
2326
|
+
placeholder: config.hasCredential ? `已配置(${config.credentialPrefix}…),留空则不改` : '只用于当场算出 HA1',
|
|
2327
|
+
onChange: (event) => setPassword(event.target.value),
|
|
2328
|
+
}),
|
|
2329
|
+
'明文不会落盘:宿主半立刻算成服务端同款的 HA1',
|
|
2330
|
+
),
|
|
2331
|
+
row(
|
|
2332
|
+
'设备',
|
|
2333
|
+
h('input', {
|
|
2334
|
+
className: 'dub-op',
|
|
2335
|
+
list: 'dub-sync-devices',
|
|
2336
|
+
value: deviceId,
|
|
2337
|
+
placeholder: '重装后填回原来的设备名即可接着同一台记',
|
|
2338
|
+
onChange: (event) => setDeviceId(event.target.value),
|
|
2339
|
+
}),
|
|
2340
|
+
devices ? `远端已有:${devices.join('、') || '(还没有)'}` : '点「测试连接」可以列出远端已有的设备',
|
|
2341
|
+
),
|
|
2342
|
+
h('datalist', { id: 'dub-sync-devices' }, (devices ?? []).map((name) => h('option', { key: name, value: name }))),
|
|
2343
|
+
row(
|
|
2344
|
+
'首次拉取',
|
|
2345
|
+
h('input', {
|
|
2346
|
+
className: 'dub-op',
|
|
2347
|
+
style: { width: '72px' },
|
|
2348
|
+
value: windowYears,
|
|
2349
|
+
onChange: (event) => setWindowYears(event.target.value),
|
|
2350
|
+
}),
|
|
2351
|
+
'年内(0 = 全都要);只影响拉取,不影响保留',
|
|
2352
|
+
),
|
|
2353
|
+
row(
|
|
2354
|
+
'自签名证书',
|
|
2355
|
+
h('input', {
|
|
2356
|
+
type: 'checkbox',
|
|
2357
|
+
checked: allowInsecureTls,
|
|
2358
|
+
onChange: (event) => setAllowInsecureTls(event.target.checked),
|
|
2359
|
+
}),
|
|
2360
|
+
'HTTPS 但证书不受信任时才勾',
|
|
2361
|
+
),
|
|
2362
|
+
h(
|
|
2363
|
+
'div',
|
|
2364
|
+
{ className: 'dub-form-actions' },
|
|
2365
|
+
h('button', { className: 'dub-btn dub-btn-primary', onClick: save, disabled: state === 'saving' }, '保存'),
|
|
2366
|
+
h('button', { className: 'dub-btn', onClick: test, disabled: state === 'testing' }, '测试连接'),
|
|
2367
|
+
h(
|
|
2368
|
+
'button',
|
|
2369
|
+
{
|
|
2370
|
+
className: 'dub-btn',
|
|
2371
|
+
onClick: run,
|
|
2372
|
+
disabled: state === 'running' || !config.hasCredential || !config.enabled,
|
|
2373
|
+
title: config.enabled ? '' : '先开启同步',
|
|
2374
|
+
},
|
|
2375
|
+
running ? '同步中…' : '立即同步',
|
|
2376
|
+
),
|
|
2377
|
+
),
|
|
2378
|
+
),
|
|
2379
|
+
state === 'failed' ? h('div', { className: 'dub-warn' }, `失败:${failNote || '未知错误'}`) : null,
|
|
2380
|
+
// The device name is not a label, it is the directory the shards go in. Two installations
|
|
2381
|
+
// using one name would overwrite each other's months with no error at all, so this says it in
|
|
2382
|
+
// place, next to the field that has to change, rather than only in the log.
|
|
2383
|
+
status?.collision
|
|
2384
|
+
? h(
|
|
2385
|
+
'div',
|
|
2386
|
+
{ className: 'dub-warn' },
|
|
2387
|
+
`设备名「${status.collision.device}」已经被另一台机器占用` +
|
|
2388
|
+
(status.collision.owner ? `(那台的标识是 ${status.collision.owner})` : '') +
|
|
2389
|
+
' —— 同名会互相覆盖分片,所以这台机器拒绝发布。要么把上面的「设备」改成一个没用过的名字,要么去服务器上删掉 ' +
|
|
2390
|
+
`usage-badge/${status.collision.device}/ 再来(删之前确认里面没有你还要的历史)。`,
|
|
2391
|
+
)
|
|
2392
|
+
: null,
|
|
2393
|
+
// The note is shown for every outcome that has one — the test result included. (It used to be
|
|
2394
|
+
// dropped for `tested`, so a successful connection check rendered an empty line, and a failure
|
|
2395
|
+
// was sliced out of the state name, which rendered blank whenever the message was empty.)
|
|
2396
|
+
state === 'ran' && runNote
|
|
2397
|
+
? h('div', { className: 'dub-note' }, runNote)
|
|
2398
|
+
: (state === 'saved' || state === 'tested') && note
|
|
2399
|
+
? h('div', { className: 'dub-note' }, note)
|
|
2400
|
+
: null,
|
|
2401
|
+
),
|
|
2402
|
+
h(
|
|
2403
|
+
'div',
|
|
2404
|
+
{ className: 'dub-block' },
|
|
2405
|
+
h('div', { className: 'dub-block-head' }, h('div', { className: 'dub-block-title' }, '状态')),
|
|
2406
|
+
status?.lock
|
|
2407
|
+
? h('div', { className: 'dub-warn' }, `这个数据目录正被另一个 DSH 实例使用(pid ${status.lock.owner?.pid ?? '?'}),本实例只读:${status.lock.reason ?? ''}`)
|
|
2408
|
+
: null,
|
|
2409
|
+
h(
|
|
2410
|
+
'div',
|
|
2411
|
+
{ className: 'dub-stats' },
|
|
2412
|
+
h('div', { className: 'dub-stat' }, h('div', { className: 'dub-stat-k' }, '上次同步'), h('div', { className: 'dub-stat-v' }, last.at ? new Date(last.at).toLocaleString() : '从未')),
|
|
2413
|
+
h('div', { className: 'dub-stat' }, h('div', { className: 'dub-stat-k' }, '结果'), h('div', { className: 'dub-stat-v' }, last.result ?? '—')),
|
|
2414
|
+
h('div', { className: 'dub-stat' }, h('div', { className: 'dub-stat-k' }, '已经发布的月份'), h('div', { className: 'dub-stat-v' }, String(published))),
|
|
2415
|
+
),
|
|
2416
|
+
last.message ? h('div', { className: 'dub-note' }, last.message) : null,
|
|
2417
|
+
h('div', { className: 'dub-note' }, `本机设备名:${config.deviceId || '(保存后生成)'}${config.hasCredential ? ' · 凭据已配置' : ' · 还没有凭据'}`),
|
|
2418
|
+
// Two different counts, both useful and easy to confuse: what this device has actually read
|
|
2419
|
+
// (which is what makes the numbers) and what the server holds (which says whether every other
|
|
2420
|
+
// machine has managed to publish).
|
|
2421
|
+
h('div', { className: 'dub-note' }, `本机已读到的远端月份:${remote.length ? remote.map(([name, count]) => `${name}(${count} 个月)`).join('、') : '(还没有读到)'}`),
|
|
2422
|
+
h(
|
|
2423
|
+
'div',
|
|
2424
|
+
{ className: 'dub-note' },
|
|
2425
|
+
`服务器上的设备:${
|
|
2426
|
+
status?.remoteDevicesOnServer?.length
|
|
2427
|
+
? status.remoteDevicesOnServer.join('、')
|
|
2428
|
+
: status?.remoteError
|
|
2429
|
+
? `(读不到:${status.remoteError})`
|
|
2430
|
+
: '(还没有问过服务器)'
|
|
2431
|
+
}`,
|
|
2432
|
+
),
|
|
2433
|
+
conflicts.length
|
|
2434
|
+
? h('div', { className: 'dub-warn' }, `价格表冲突过:本目录留着 ${conflicts.join('、')} —— 两边的价格表都被保留,请自己挑一份合进 pricing.json`)
|
|
2435
|
+
: null,
|
|
2436
|
+
h(
|
|
2437
|
+
'div',
|
|
2438
|
+
{ className: 'dub-note' },
|
|
2439
|
+
'远端所有设备的历史都会合并,统计的是它们的合计;选「设备」只决定这台机器把新数据写进哪个分区。日粒度仍只显示最近 430 天,更早的在「按年」里。',
|
|
2440
|
+
),
|
|
2441
|
+
h('div', { className: 'dub-note' }, `配置文件:${status?.paths?.sync ?? ''}`),
|
|
2442
|
+
),
|
|
2443
|
+
)
|
|
2444
|
+
}
|
|
2445
|
+
|
|
2105
2446
|
function UsageDialog({
|
|
2106
2447
|
snapshot,
|
|
2107
2448
|
onClose,
|
|
@@ -2114,6 +2455,7 @@ window.__ModuleLoader__.load({
|
|
|
2114
2455
|
initialYear = null,
|
|
2115
2456
|
initialYearData = null,
|
|
2116
2457
|
initialEdit = null,
|
|
2458
|
+
initialSync = null,
|
|
2117
2459
|
}) {
|
|
2118
2460
|
const [tab, setTab] = React.useState(initialTab)
|
|
2119
2461
|
const [config, setConfig] = React.useState(initialConfig ?? cachedConfig)
|
|
@@ -2198,7 +2540,9 @@ window.__ModuleLoader__.load({
|
|
|
2198
2540
|
// pressed (`cachedOfficial` doubles as the "has it been fetched" flag).
|
|
2199
2541
|
tab === 'usage'
|
|
2200
2542
|
? h(UsagePanel, { snapshot, initialRange, initialHidden, initialYear, initialYearData })
|
|
2201
|
-
:
|
|
2543
|
+
: tab === 'sync'
|
|
2544
|
+
? h(SyncPanel, { initialSync: initialSync ?? cachedSync })
|
|
2545
|
+
: h(ConfigPanel, { snapshot, reload, initialConfig, initialOfficial, initialEdit }),
|
|
2202
2546
|
),
|
|
2203
2547
|
)
|
|
2204
2548
|
}
|
|
@@ -2230,6 +2574,7 @@ window.__ModuleLoader__.load({
|
|
|
2230
2574
|
initialYear = null,
|
|
2231
2575
|
initialYearData = null,
|
|
2232
2576
|
initialEdit = null,
|
|
2577
|
+
initialSync = null,
|
|
2233
2578
|
} = {}) {
|
|
2234
2579
|
const [snapshot, setSnapshot] = React.useState(initialSnapshot)
|
|
2235
2580
|
const [failed, setFailed] = React.useState(false)
|
|
@@ -2280,7 +2625,7 @@ window.__ModuleLoader__.load({
|
|
|
2280
2625
|
h('span', { className: 'dub-pill-amount' }, text),
|
|
2281
2626
|
),
|
|
2282
2627
|
open && snapshot
|
|
2283
|
-
? h(UsageDialog, { snapshot, onClose: () => setOpen(false), reload: load, initialTab, initialConfig, initialOfficial, initialRange, initialHidden, initialYear, initialYearData, initialEdit })
|
|
2628
|
+
? h(UsageDialog, { snapshot, onClose: () => setOpen(false), reload: load, initialTab, initialConfig, initialOfficial, initialRange, initialHidden, initialYear, initialYearData, initialEdit, initialSync })
|
|
2284
2629
|
: null,
|
|
2285
2630
|
)
|
|
2286
2631
|
}
|
package/lib/fold.js
CHANGED
|
@@ -22,7 +22,7 @@ import { homedir } from 'node:os'
|
|
|
22
22
|
import { join, resolve } from 'node:path'
|
|
23
23
|
import { zstdDecompressSync } from 'node:zlib'
|
|
24
24
|
|
|
25
|
-
import { addBucket, emptyBucket, UNKNOWN } from './buckets.js'
|
|
25
|
+
import { addBucket, emptyBucket, LOCAL_SOURCE, resolveRows, UNKNOWN } from './buckets.js'
|
|
26
26
|
import { dayKey, localHour } from './pricing.js'
|
|
27
27
|
|
|
28
28
|
export { UNKNOWN }
|
|
@@ -298,6 +298,10 @@ export function createAggregator({ sessionsRoot, store, log = () => {} }) {
|
|
|
298
298
|
* show. Sessions whose days all fall outside the window still come back — with their
|
|
299
299
|
* cursor, so their logs are never folded again — just without the buckets that no view
|
|
300
300
|
* asks for. Their rows stay in the store.
|
|
301
|
+
*
|
|
302
|
+
* The store has already resolved the copies it may hold for a `(session, date)` down to the
|
|
303
|
+
* fuller log version; what arrives here is the merged view, with the raw `rows` alongside for
|
|
304
|
+
* anything that needs to know where a day came from.
|
|
301
305
|
*/
|
|
302
306
|
function loadCache() {
|
|
303
307
|
let loaded
|
|
@@ -367,7 +371,21 @@ export function createAggregator({ sessionsRoot, store, log = () => {} }) {
|
|
|
367
371
|
// ones on the way past. Leaving the cursor untouched also means the next refresh retries the
|
|
368
372
|
// fold, which is what a transient read failure wants.
|
|
369
373
|
if (record && record !== previous) {
|
|
370
|
-
|
|
374
|
+
folded++
|
|
375
|
+
// Written back one session at a time, right here: a save costs the changed session's
|
|
376
|
+
// days, not the whole history, so there is nothing to batch and no timer to lose it in.
|
|
377
|
+
//
|
|
378
|
+
// What the store hands back is what this process must go on believing. It is normally
|
|
379
|
+
// the fold just produced, but when the log got shorter the store keeps the fuller copy
|
|
380
|
+
// it already had — and then *that* is the truth, because the whole point of the row
|
|
381
|
+
// model is that a shortened log cannot make history disappear.
|
|
382
|
+
let effective = record
|
|
383
|
+
try {
|
|
384
|
+
effective = store.put(record, { source: LOCAL_SOURCE }) ?? record
|
|
385
|
+
} catch (error) {
|
|
386
|
+
log(`cache write failed for ${sessionId}: ${error?.message ?? error}`)
|
|
387
|
+
}
|
|
388
|
+
records.set(sessionId, effective)
|
|
371
389
|
cursors.set(sessionId, {
|
|
372
390
|
seq: record.seq,
|
|
373
391
|
provider: record.provider,
|
|
@@ -376,14 +394,6 @@ export function createAggregator({ sessionsRoot, store, log = () => {} }) {
|
|
|
376
394
|
fileMtimeMs: record.fileMtimeMs,
|
|
377
395
|
path: record.path,
|
|
378
396
|
})
|
|
379
|
-
folded++
|
|
380
|
-
// Written back one session at a time, right here: a save costs the changed session's
|
|
381
|
-
// days, not the whole history, so there is nothing to batch and no timer to lose it in.
|
|
382
|
-
try {
|
|
383
|
-
store.put(record)
|
|
384
|
-
} catch (error) {
|
|
385
|
-
log(`cache write failed for ${sessionId}: ${error?.message ?? error}`)
|
|
386
|
-
}
|
|
387
397
|
}
|
|
388
398
|
if (++sinceYield >= YIELD_EVERY) {
|
|
389
399
|
sinceYield = 0
|