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/README.md +18 -3
- package/docs/design-notes.md +432 -1
- package/lib/buckets.js +237 -5
- package/lib/cache-store.js +672 -128
- package/lib/client.js +470 -4
- package/lib/fold.js +20 -10
- package/lib/index.js +537 -7
- package/lib/sync.js +1410 -0
- package/lib/webdav.js +584 -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,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
|
-
:
|
|
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
|
-
|
|
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
|
-
|
|
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
|