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/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,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
- : h(ConfigPanel, { snapshot, reload, initialConfig, initialOfficial, initialEdit }),
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
- 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