dsh-usage-badge 0.1.4 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -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 = 6
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,459 @@ 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
+ /**
2125
+ * What the sync form should show, given what was saved and what is already in the fields.
2126
+ *
2127
+ * Pure and exported for the test suite: the browser half cannot be mounted in this project's tests
2128
+ * (`react-dom/test-utils` sits behind a package export here), so the one piece of behaviour that
2129
+ * matters is kept where it can be tested directly instead of only inspected by eye.
2130
+ *
2131
+ * The rule is **fill, never overwrite**. The status arrives after the first render, when the fields are
2132
+ * empty because `/sync` had not been read yet — that is the bug this exists for. A field that already
2133
+ * holds something was either typed by the person or filled from an earlier answer, and replacing either
2134
+ * silently is worse than leaving a value alone.
2135
+ *
2136
+ * @param {object} status - a `/usage-badge/sync` response, or anything shaped like one.
2137
+ * @param {{url?:string, username?:string, realm?:string, deviceId?:string, windowYears?:string|number}} current
2138
+ * @returns {object} only the fields that need to change
2139
+ */
2140
+ function fillSyncForm(status, current = {}) {
2141
+ const config = status?.config ?? {}
2142
+ const patch = {}
2143
+ const fill = (name, saved) => {
2144
+ const had = current[name]
2145
+ if (saved === undefined || saved === null || saved === '') return
2146
+ if (had !== undefined && had !== null && String(had) !== '') return
2147
+ patch[name] = String(saved)
2148
+ }
2149
+ fill('url', config.url)
2150
+ fill('username', config.username)
2151
+ fill('realm', config.realm)
2152
+ fill('deviceId', config.deviceId)
2153
+ if (config.windowYears !== undefined && config.windowYears !== null) fill('windowYears', String(config.windowYears))
2154
+ // A checkbox has no "empty" to leave alone: it is on only when the saved configuration says so, and
2155
+ // turning it back on is a deliberate act the person can repeat.
2156
+ if (config.allowInsecureTls) patch.allowInsecureTls = true
2157
+ return patch
2158
+ }
2159
+
2160
+ /**
2161
+ * Whether a `/usage-badge/sync` reply is a status the panel can use.
2162
+ *
2163
+ * The route answers `{error}` with HTTP 200 when it cannot produce one, so "the request succeeded" is
2164
+ * not the same as "this is a status". Adopting an error object would leave `config` undefined and blank
2165
+ * every field — the same complaint this tab already had, arriving by a second road. Pure, and exported
2166
+ * for the same reason as `fillSyncForm`: there is no way to mount a component in this project's tests.
2167
+ */
2168
+ function isSyncStatus(body) {
2169
+ return Boolean(body) && typeof body === 'object' && typeof body.config === 'object' && body.config !== null
2170
+ }
2171
+
2172
+ function SyncPanel({ initialSync = null }) {
2173
+ const [status, setStatus] = React.useState(initialSync ?? cachedSync)
2174
+ const [url, setUrl] = React.useState(initialSync?.config?.url ?? '')
2175
+ const [username, setUsername] = React.useState(initialSync?.config?.username ?? '')
2176
+ const [password, setPassword] = React.useState('')
2177
+ const [realm, setRealm] = React.useState(initialSync?.config?.realm ?? '')
2178
+ const [deviceId, setDeviceId] = React.useState(initialSync?.config?.deviceId ?? '')
2179
+ // Seeded empty rather than with the default: the form is only rendered once a status exists, and by
2180
+ // then `fillSyncForm` puts the saved value in. Seeding `5` would make the field non-empty, and
2181
+ // "fill only what is empty" would then leave a saved 0 or 30 looking like the default forever.
2182
+ const [windowYears, setWindowYears] = React.useState(String(initialSync?.config?.windowYears ?? ''))
2183
+ const [allowInsecureTls, setAllowInsecureTls] = React.useState(Boolean(initialSync?.config?.allowInsecureTls))
2184
+ const [state, setState] = React.useState('idle')
2185
+ const [note, setNote] = React.useState('')
2186
+ const [devices, setDevices] = React.useState(null)
2187
+ const [runNote, setRunNote] = React.useState('')
2188
+ // The failure verbatim and shown as-is: a sliced string is what once turned a connection error into
2189
+ // a blank line beside the word 失败. Kept beside the state so the state stays a name.
2190
+ const [failNote, setFailNote] = React.useState('')
2191
+ // Whether the person has typed into the form since it appeared. The status arrives **after** the
2192
+ // first render (there is nothing cached on a fresh page load), so the fields have to be filled in
2193
+ // when it does — but only while they still hold what this component put there.
2194
+ const formDirty = React.useRef(false)
2195
+ const filledFrom = React.useRef(null)
2196
+
2197
+ const load = React.useCallback(async () => {
2198
+ try {
2199
+ const response = await fetch('/usage-badge/sync', { cache: 'no-store' })
2200
+ if (!response.ok) return null
2201
+ const body = await response.json()
2202
+ // A reply without a `config` is not a status: the route answers `{error}` with a 200 when it cannot
2203
+ // produce one. Adopting that would blank every field, so the last good answer is kept and the
2204
+ // failure is shown instead.
2205
+ if (!isSyncStatus(body)) {
2206
+ if (body?.error) setFailNote(String(body.error))
2207
+ // `failed`, not just a note: the note is only rendered for that state, so a status the host
2208
+ // could not produce used to leave the tab saying "正在读取同步配置…" forever.
2209
+ setState('failed')
2210
+ return null
2211
+ }
2212
+ cachedSync = body
2213
+ setStatus(cachedSync)
2214
+ return cachedSync
2215
+ } catch {
2216
+ return null
2217
+ }
2218
+ }, [])
2219
+
2220
+ React.useEffect(() => {
2221
+ void load()
2222
+ }, [load])
2223
+
2224
+ /**
2225
+ * Fill the form from the saved configuration.
2226
+ *
2227
+ * This is the whole reason the tab used to look empty after a restart: the fields were seeded from
2228
+ * `initialSync` once, and `initialSync` is `null` on a page that has not read `/sync` yet — so
2229
+ * `load()` updated the status *below* the form and never the form itself. The configuration was
2230
+ * always there; it just was not shown.
2231
+ *
2232
+ * Skipped once the person has typed: silently replacing an edit in progress is worse than leaving a
2233
+ * field empty, and an empty field can be typed into again.
2234
+ */
2235
+ React.useEffect(() => {
2236
+ if (!status || formDirty.current) return
2237
+ const saved = status.config ?? {}
2238
+ // The checkbox belongs in the signature too: it is the one field whose saved value can change from
2239
+ // `true` to `false`, and without this the signature would look unchanged and the box would keep
2240
+ // showing the old answer.
2241
+ const signature = `${saved.url ?? ''}\u0000${saved.username ?? ''}\u0000${saved.realm ?? ''}\u0000${saved.deviceId ?? ''}\u0000${saved.windowYears ?? ''}\u0000${Boolean(saved.allowInsecureTls)}`
2242
+ if (filledFrom.current === signature) return
2243
+ filledFrom.current = signature
2244
+ const patch = fillSyncForm(status, { url, username, realm, deviceId, windowYears })
2245
+ if (patch.url !== undefined) setUrl(patch.url)
2246
+ if (patch.username !== undefined) setUsername(patch.username)
2247
+ if (patch.realm !== undefined) setRealm(patch.realm)
2248
+ if (patch.deviceId !== undefined) setDeviceId(patch.deviceId)
2249
+ if (patch.windowYears !== undefined) setWindowYears(patch.windowYears)
2250
+ // Two-way, unlike the text fields: a checkbox has no empty state to protect, and the saved answer is
2251
+ // the answer. `formDirty` still stops this from fighting someone who has just clicked it.
2252
+ setAllowInsecureTls(Boolean(saved.allowInsecureTls))
2253
+ }, [status, url, username, realm, deviceId, windowYears])
2254
+
2255
+ // While a run is in flight the panel shows what the host is doing, so it asks again. The condition
2256
+ // includes the local `state` on purpose: the host's `running` flag only becomes true once *its* run
2257
+ // has started, and a 立即同步 press would otherwise sit through the whole sync without a single
2258
+ // refresh, looking like a button that did nothing.
2259
+ const running = Boolean(status?.running) || state === 'running'
2260
+ React.useEffect(() => {
2261
+ if (!running) return undefined
2262
+ const timer = setInterval(() => void load(), 2000)
2263
+ return () => clearInterval(timer)
2264
+ }, [running, load])
2265
+
2266
+ const save = async () => {
2267
+ setState('saving')
2268
+ setNote('')
2269
+ setFailNote('')
2270
+ try {
2271
+ const sentPassword = password !== ''
2272
+ const payload = { url, username, realm, deviceId, windowYears: Number(windowYears), allowInsecureTls }
2273
+ if (sentPassword) payload.password = password
2274
+ const response = await fetch('/usage-badge/sync', {
2275
+ method: 'POST',
2276
+ headers: { 'content-type': 'application/json' },
2277
+ body: JSON.stringify(payload),
2278
+ })
2279
+ const body = await response.json()
2280
+ if (!response.ok) throw new Error(body?.error ?? `HTTP ${response.status}`)
2281
+ cachedSync = body
2282
+ setStatus(body)
2283
+ setPassword('')
2284
+ if (body.config?.realm) setRealm(body.config.realm)
2285
+ // The host normalises the address (a collection needs its trailing slash); show what it stored
2286
+ // rather than what was typed, so the field cannot disagree with the configuration.
2287
+ if (body.config?.url) setUrl(body.config.url)
2288
+ setState('saved')
2289
+ // Saving only proves the values were accepted, not that they work — so the note says what the
2290
+ // next press is for instead of implying this one settled it.
2291
+ setNote(
2292
+ sentPassword
2293
+ ? '已保存。点「测试连接」能确认地址、用户名、密码都对 —— 连不上时它会把原因说出来。'
2294
+ : '已保存。',
2295
+ )
2296
+ } catch (error) {
2297
+ setState('failed')
2298
+ setFailNote(String(error?.message ?? error))
2299
+ }
2300
+ }
2301
+
2302
+ const test = async () => {
2303
+ setState('testing')
2304
+ setNote('')
2305
+ setFailNote('')
2306
+ try {
2307
+ const response = await fetch('/usage-badge/sync/test', { method: 'POST' })
2308
+ const body = await response.json()
2309
+ if (!body.ok) {
2310
+ setDevices(null)
2311
+ setState('failed')
2312
+ // The server answers a failed check with the realm it announced. If that is not the realm the
2313
+ // saved password was hashed against, nothing can work until it is re-hashed — and this is the
2314
+ // only moment the panel can learn it. (`WebDAV` is the default, and the panel offers no realm
2315
+ // field, so a server using anything else would otherwise be unconfigurable: the saved credential
2316
+ // would simply never match, with no way to say why.)
2317
+ const announced = body.realm ? String(body.realm) : ''
2318
+ if (announced && announced !== realm) {
2319
+ setRealm(announced)
2320
+ setFailNote(`${body.error ?? '连接失败'} —— 服务器宣告的 realm 是「${announced}」,与此前用的「${realm || '(空)'}」不同,所以密码是按错的 realm 算的。请重新输入一次密码再保存,这次会用正确的 realm 计算。`)
2321
+ return
2322
+ }
2323
+ setFailNote(String(body.error ?? '连接失败'))
2324
+ return
2325
+ }
2326
+ setDevices(body.devices ?? [])
2327
+ setState('tested')
2328
+ setNote(`连上了:realm ${body.realm ?? '未知'},DAV ${body.dav || '未声明'}${body.devices?.length ? `,已有 ${body.devices.length} 台设备` : ''}${body.warning ? ` ⚠ ${body.warning}` : ''}`)
2329
+ // The realm the server actually announced is the authoritative one; keeping the guessed default
2330
+ // would leave the panel disagreeing with the credential that just worked.
2331
+ if (body.realm) setRealm(String(body.realm))
2332
+ } catch (error) {
2333
+ setDevices(null)
2334
+ setState('failed')
2335
+ setFailNote(String(error?.message ?? error))
2336
+ }
2337
+ }
2338
+
2339
+ const run = async () => {
2340
+ setState('running')
2341
+ setRunNote('')
2342
+ setFailNote('')
2343
+ try {
2344
+ const response = await fetch('/usage-badge/sync/run', { method: 'POST' })
2345
+ const body = await response.json()
2346
+ // A 500 from the route carries `error`, not `message` — reading only `message` turned a total
2347
+ // failure into a run that reported success with nothing to say.
2348
+ if (!response.ok) throw new Error(body?.error ?? `HTTP ${response.status}`)
2349
+ if (body?.result === 'locked' || body?.result === 'failed') {
2350
+ setRunNote(body.message ?? '')
2351
+ setState('failed')
2352
+ setFailNote(body.message ?? '同步没有进行')
2353
+ return
2354
+ }
2355
+ setRunNote(body.message ?? '')
2356
+ setState('ran')
2357
+ await load()
2358
+ } catch (error) {
2359
+ setRunNote(String(error?.message ?? error))
2360
+ setState('failed')
2361
+ setFailNote(String(error?.message ?? error))
2362
+ }
2363
+ }
2364
+
2365
+ const toggleEnabled = async () => {
2366
+ const next = !status?.config?.enabled
2367
+ setState('saving')
2368
+ setNote('')
2369
+ setFailNote('')
2370
+ try {
2371
+ const response = await fetch('/usage-badge/sync', {
2372
+ method: 'POST',
2373
+ headers: { 'content-type': 'application/json' },
2374
+ body: JSON.stringify({ enabled: next }),
2375
+ })
2376
+ const body = await response.json()
2377
+ if (!response.ok) throw new Error(body?.error ?? `HTTP ${response.status}`)
2378
+ cachedSync = body
2379
+ setStatus(body)
2380
+ setState('saved')
2381
+ setNote(next ? '已开启同步。' : '已关闭同步 —— 远端上的东西不会被删掉。')
2382
+ } catch (error) {
2383
+ setState('failed')
2384
+ setFailNote(String(error?.message ?? error))
2385
+ }
2386
+ }
2387
+
2388
+ const config = status?.config ?? {}
2389
+ const remote = Object.entries(status?.remoteDevices ?? {})
2390
+ const published = Object.keys(status?.published ?? {}).length
2391
+ const conflicts = status?.conflictFiles ?? []
2392
+ const last = status?.last ?? {}
2393
+
2394
+ const row = (label, control, hint) =>
2395
+ h(
2396
+ 'div',
2397
+ { className: 'dub-form-row', key: label },
2398
+ h('span', { className: 'dub-stat-k' }, label),
2399
+ control,
2400
+ hint ? h('span', { className: 'dub-note' }, hint) : null,
2401
+ )
2402
+
2403
+ return h(
2404
+ 'div',
2405
+ { className: 'dub-body' },
2406
+ h(
2407
+ 'div',
2408
+ { className: 'dub-block' },
2409
+ h(
2410
+ 'div',
2411
+ { className: 'dub-block-head' },
2412
+ h('div', { className: 'dub-block-title' }, '云同步'),
2413
+ h(Info, {
2414
+ text: '把每天的用量与价格表备份到一台 WebDAV 服务器,多台机器互相合并。拉取与合并都在本机完成:远端只存按月压缩的分片,每台机器只写自己那一份,远端从不会因为本机的删除而删除。认证只支持 Digest(bytemark/webdav 这类 Apache 服务端)。',
2415
+ }),
2416
+ h(
2417
+ 'button',
2418
+ { className: `dub-btn${config.enabled ? ' dub-btn-primary' : ''}`, onClick: toggleEnabled, disabled: state === 'saving' },
2419
+ config.enabled ? '已开启 · 点此关闭' : '已关闭 · 点此开启',
2420
+ ),
2421
+ ),
2422
+ !status
2423
+ ? h('div', { className: 'dub-note' }, '正在读取同步配置…')
2424
+ : h(
2425
+ 'div',
2426
+ { className: 'dub-form' },
2427
+ row(
2428
+ 'WebDAV 地址',
2429
+ h('input', {
2430
+ className: 'dub-op',
2431
+ style: { flex: '1 1 260px' },
2432
+ value: url,
2433
+ placeholder: 'http://nas:19347/',
2434
+ onChange: (event) => { formDirty.current = true; setUrl(event.target.value) },
2435
+ }),
2436
+ '必须以 / 结尾,且指到集合(目录)',
2437
+ ),
2438
+ row('用户名', h('input', { className: 'dub-op', value: username, onChange: (event) => { formDirty.current = true; setUsername(event.target.value) } })),
2439
+ row(
2440
+ '密码',
2441
+ h('input', {
2442
+ className: 'dub-op',
2443
+ type: 'password',
2444
+ value: password,
2445
+ placeholder: config.hasCredential ? `已配置(${config.credentialPrefix}…),留空则不改` : '只用于当场算出 HA1',
2446
+ onChange: (event) => { formDirty.current = true; setPassword(event.target.value) },
2447
+ }),
2448
+ '明文不会落盘:宿主半立刻算成服务端同款的 HA1',
2449
+ ),
2450
+ row(
2451
+ '设备',
2452
+ h('input', {
2453
+ className: 'dub-op',
2454
+ list: 'dub-sync-devices',
2455
+ value: deviceId,
2456
+ placeholder: '重装后填回原来的设备名即可接着同一台记',
2457
+ onChange: (event) => { formDirty.current = true; setDeviceId(event.target.value) },
2458
+ }),
2459
+ devices ? `远端已有:${devices.join('、') || '(还没有)'}` : '点「测试连接」可以列出远端已有的设备',
2460
+ ),
2461
+ h('datalist', { id: 'dub-sync-devices' }, (devices ?? []).map((name) => h('option', { key: name, value: name }))),
2462
+ row(
2463
+ '首次拉取',
2464
+ h('input', {
2465
+ className: 'dub-op',
2466
+ style: { width: '72px' },
2467
+ value: windowYears,
2468
+ onChange: (event) => { formDirty.current = true; setWindowYears(event.target.value) },
2469
+ }),
2470
+ '年内(0 = 全都要);只影响拉取,不影响保留',
2471
+ ),
2472
+ row(
2473
+ '自签名证书',
2474
+ h('input', {
2475
+ type: 'checkbox',
2476
+ checked: allowInsecureTls,
2477
+ onChange: (event) => { formDirty.current = true; setAllowInsecureTls(event.target.checked) },
2478
+ }),
2479
+ 'HTTPS 但证书不受信任时才勾',
2480
+ ),
2481
+ h(
2482
+ 'div',
2483
+ { className: 'dub-form-actions' },
2484
+ h('button', { className: 'dub-btn dub-btn-primary', onClick: save, disabled: state === 'saving' }, '保存'),
2485
+ h('button', { className: 'dub-btn', onClick: test, disabled: state === 'testing' }, '测试连接'),
2486
+ h(
2487
+ 'button',
2488
+ {
2489
+ className: 'dub-btn',
2490
+ onClick: run,
2491
+ disabled: state === 'running' || !config.hasCredential || !config.enabled,
2492
+ title: config.enabled ? '' : '先开启同步',
2493
+ },
2494
+ running ? '同步中…' : '立即同步',
2495
+ ),
2496
+ ),
2497
+ ),
2498
+ state === 'failed' ? h('div', { className: 'dub-warn' }, `失败:${failNote || '未知错误'}`) : null,
2499
+ // The device name is not a label, it is the directory the shards go in. Two installations
2500
+ // using one name would overwrite each other's months with no error at all, so this says it in
2501
+ // place, next to the field that has to change, rather than only in the log.
2502
+ status?.collision
2503
+ ? h(
2504
+ 'div',
2505
+ { className: 'dub-warn' },
2506
+ `设备名「${status.collision.device}」已经被另一台机器占用` +
2507
+ (status.collision.owner ? `(那台的标识是 ${status.collision.owner})` : '') +
2508
+ ' —— 同名会互相覆盖分片,所以这台机器拒绝发布。要么把上面的「设备」改成一个没用过的名字,要么去服务器上删掉 ' +
2509
+ `usage-badge/${status.collision.device}/ 再来(删之前确认里面没有你还要的历史)。`,
2510
+ )
2511
+ : null,
2512
+ // The note is shown for every outcome that has one — the test result included. (It used to be
2513
+ // dropped for `tested`, so a successful connection check rendered an empty line, and a failure
2514
+ // was sliced out of the state name, which rendered blank whenever the message was empty.)
2515
+ state === 'ran' && runNote
2516
+ ? h('div', { className: 'dub-note' }, runNote)
2517
+ : (state === 'saved' || state === 'tested') && note
2518
+ ? h('div', { className: 'dub-note' }, note)
2519
+ : null,
2520
+ ),
2521
+ h(
2522
+ 'div',
2523
+ { className: 'dub-block' },
2524
+ h('div', { className: 'dub-block-head' }, h('div', { className: 'dub-block-title' }, '状态')),
2525
+ status?.lock
2526
+ ? h('div', { className: 'dub-warn' }, `这个数据目录正被另一个 DSH 实例使用(pid ${status.lock.owner?.pid ?? '?'}),本实例只读:${status.lock.reason ?? ''}`)
2527
+ : null,
2528
+ h(
2529
+ 'div',
2530
+ { className: 'dub-stats' },
2531
+ h('div', { className: 'dub-stat' }, h('div', { className: 'dub-stat-k' }, '上次同步'), h('div', { className: 'dub-stat-v' }, last.at ? new Date(last.at).toLocaleString() : '从未')),
2532
+ h('div', { className: 'dub-stat' }, h('div', { className: 'dub-stat-k' }, '结果'), h('div', { className: 'dub-stat-v' }, last.result ?? '—')),
2533
+ h('div', { className: 'dub-stat' }, h('div', { className: 'dub-stat-k' }, '已经发布的月份'), h('div', { className: 'dub-stat-v' }, String(published))),
2534
+ ),
2535
+ last.message ? h('div', { className: 'dub-note' }, last.message) : null,
2536
+ h('div', { className: 'dub-note' }, `本机设备名:${config.deviceId || '(保存后生成)'}${config.hasCredential ? ' · 凭据已配置' : ' · 还没有凭据'}`),
2537
+ // Two different counts, both useful and easy to confuse: what this device has actually read
2538
+ // (which is what makes the numbers) and what the server holds (which says whether every other
2539
+ // machine has managed to publish).
2540
+ h('div', { className: 'dub-note' }, `本机已读到的远端月份:${remote.length ? remote.map(([name, count]) => `${name}(${count} 个月)`).join('、') : '(还没有读到)'}`),
2541
+ h(
2542
+ 'div',
2543
+ { className: 'dub-note' },
2544
+ `服务器上的设备:${
2545
+ status?.remoteDevicesOnServer?.length
2546
+ ? status.remoteDevicesOnServer.join('、')
2547
+ : status?.remoteError
2548
+ ? `(读不到:${status.remoteError})`
2549
+ : '(还没有问过服务器)'
2550
+ }`,
2551
+ ),
2552
+ conflicts.length
2553
+ ? h('div', { className: 'dub-warn' }, `价格表冲突过:本目录留着 ${conflicts.join('、')} —— 两边的价格表都被保留,请自己挑一份合进 pricing.json`)
2554
+ : null,
2555
+ h(
2556
+ 'div',
2557
+ { className: 'dub-note' },
2558
+ '远端所有设备的历史都会合并,统计的是它们的合计;选「设备」只决定这台机器把新数据写进哪个分区。日粒度仍只显示最近 430 天,更早的在「按年」里。',
2559
+ ),
2560
+ h('div', { className: 'dub-note' }, `配置文件:${status?.paths?.sync ?? ''}`),
2561
+ ),
2562
+ )
2563
+ }
2564
+
2105
2565
  function UsageDialog({
2106
2566
  snapshot,
2107
2567
  onClose,
@@ -2114,6 +2574,7 @@ window.__ModuleLoader__.load({
2114
2574
  initialYear = null,
2115
2575
  initialYearData = null,
2116
2576
  initialEdit = null,
2577
+ initialSync = null,
2117
2578
  }) {
2118
2579
  const [tab, setTab] = React.useState(initialTab)
2119
2580
  const [config, setConfig] = React.useState(initialConfig ?? cachedConfig)
@@ -2198,7 +2659,9 @@ window.__ModuleLoader__.load({
2198
2659
  // pressed (`cachedOfficial` doubles as the "has it been fetched" flag).
2199
2660
  tab === 'usage'
2200
2661
  ? h(UsagePanel, { snapshot, initialRange, initialHidden, initialYear, initialYearData })
2201
- : h(ConfigPanel, { snapshot, reload, initialConfig, initialOfficial, initialEdit }),
2662
+ : tab === 'sync'
2663
+ ? h(SyncPanel, { initialSync: initialSync ?? cachedSync })
2664
+ : h(ConfigPanel, { snapshot, reload, initialConfig, initialOfficial, initialEdit }),
2202
2665
  ),
2203
2666
  )
2204
2667
  }
@@ -2230,6 +2693,7 @@ window.__ModuleLoader__.load({
2230
2693
  initialYear = null,
2231
2694
  initialYearData = null,
2232
2695
  initialEdit = null,
2696
+ initialSync = null,
2233
2697
  } = {}) {
2234
2698
  const [snapshot, setSnapshot] = React.useState(initialSnapshot)
2235
2699
  const [failed, setFailed] = React.useState(false)
@@ -2280,7 +2744,7 @@ window.__ModuleLoader__.load({
2280
2744
  h('span', { className: 'dub-pill-amount' }, text),
2281
2745
  ),
2282
2746
  open && snapshot
2283
- ? h(UsageDialog, { snapshot, onClose: () => setOpen(false), reload: load, initialTab, initialConfig, initialOfficial, initialRange, initialHidden, initialYear, initialYearData, initialEdit })
2747
+ ? h(UsageDialog, { snapshot, onClose: () => setOpen(false), reload: load, initialTab, initialConfig, initialOfficial, initialRange, initialHidden, initialYear, initialYearData, initialEdit, initialSync })
2284
2748
  : null,
2285
2749
  )
2286
2750
  }
@@ -2310,6 +2774,8 @@ window.__ModuleLoader__.load({
2310
2774
  )
2311
2775
  }
2312
2776
 
2313
- return { apply, inject }
2777
+ // `fillSyncForm` and `isSyncStatus` ride along so the test suite can exercise them: this bundle is
2778
+ // loaded through the DSH module loader, and there is no way to mount a component in a plain Node test.
2779
+ return { apply, inject, fillSyncForm, isSyncStatus }
2314
2780
  },
2315
2781
  })
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
- records.set(sessionId, record)
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