dscs-server 0.1.3 → 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 +59 -17
- package/dist/admin-password.js +27 -0
- package/dist/audit.js +73 -0
- package/dist/db.js +174 -3
- package/dist/http.js +433 -85
- package/dist/index.js +23 -7
- package/dist/session-admin.js +214 -0
- package/dist/session-history.js +134 -0
- package/dist/sessions.js +133 -73
- package/dist/shared/blob-batch.js +4 -2
- package/dist/shared/wire.js +1 -0
- package/package.json +1 -1
- package/web/admin.html +1833 -297
package/dist/index.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { chmodSync, mkdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { chmodSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { ONBOARDING_CODE_FILE, ONBOARDING_TTL_MS, createDevice, createHttpServer, listDevices, mintOnboardingCode, revokeDevice, DEFAULT_MAX_BYTES } from './http.js';
|
|
5
|
-
import {
|
|
5
|
+
import { generateAdminPassword, resetAdminPassword } from './admin-password.js';
|
|
6
|
+
import { ensureSetupToken, isAdminInitialized, openDatabase, readMaxBytes } from './db.js';
|
|
6
7
|
function writeOnboardingCode(dataDir, code) {
|
|
7
8
|
mkdirSync(dataDir, {
|
|
8
9
|
recursive: true
|
|
@@ -34,13 +35,15 @@ function usage() {
|
|
|
34
35
|
token list 列出存活设备
|
|
35
36
|
token revoke <deviceId> 吊销某个设备 token
|
|
36
37
|
admin status 查看初始化状态与配额
|
|
37
|
-
admin reset
|
|
38
|
+
admin reset-password 忘记管理密码时重置为新密码
|
|
39
|
+
(默认生成随机密码;--stdin 从标准输入读取自定义密码)
|
|
38
40
|
|
|
39
41
|
选项:
|
|
40
42
|
--data-dir <目录> 数据目录(默认 .tmp/dev-data)
|
|
41
43
|
--host <地址> 绑定地址(默认 127.0.0.1)
|
|
42
44
|
--port <端口> 监听端口(默认 8787)
|
|
43
45
|
--max-bytes <n> 存储上限(默认 10 GiB)
|
|
46
|
+
--stdin admin reset-password 从标准输入读取新密码
|
|
44
47
|
`);
|
|
45
48
|
process.exit(2);
|
|
46
49
|
}
|
|
@@ -50,6 +53,10 @@ function parse(argv) {
|
|
|
50
53
|
for(let index = 0; index < argv.length; index += 1){
|
|
51
54
|
const argument = argv[index];
|
|
52
55
|
if (argument.startsWith('--')) {
|
|
56
|
+
if (argument === '--stdin') {
|
|
57
|
+
flags.set('stdin', '');
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
53
60
|
const value = argv[index + 1];
|
|
54
61
|
if (value === undefined || value.startsWith('--')) usage();
|
|
55
62
|
flags.set(argument.slice(2), value);
|
|
@@ -99,10 +106,19 @@ async function main(parsed) {
|
|
|
99
106
|
}, null, 2));
|
|
100
107
|
return;
|
|
101
108
|
}
|
|
102
|
-
if (action === 'reset') {
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
109
|
+
if (action === 'reset-password') {
|
|
110
|
+
const fromStdin = parsed.flags.has('stdin');
|
|
111
|
+
const password = fromStdin ? readFileSync(0, 'utf8').replace(/\r?\n$/u, '') : generateAdminPassword();
|
|
112
|
+
resetAdminPassword(db, password);
|
|
113
|
+
if (fromStdin) {
|
|
114
|
+
console.log('已重置管理密码。旧管理会话需要重启 server 后失效。');
|
|
115
|
+
} else {
|
|
116
|
+
console.log('已重置管理密码(只显示一次):');
|
|
117
|
+
console.log('');
|
|
118
|
+
console.log(password);
|
|
119
|
+
console.log('');
|
|
120
|
+
console.log('请登录管理台后立即修改;旧管理会话需要重启 server 后失效。');
|
|
121
|
+
}
|
|
106
122
|
return;
|
|
107
123
|
}
|
|
108
124
|
usage();
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { rmSync } from 'node:fs';
|
|
2
|
+
import { blobPath } from './blobs.js';
|
|
3
|
+
import { bumpRevision, DEFAULT_DATASET, withTransaction } from './db.js';
|
|
4
|
+
import { SyncError } from './shared/wire.js';
|
|
5
|
+
import { recordSessionEvent } from './session-history.js';
|
|
6
|
+
export const SESSION_RESET_TTL_MS = 24 * 60 * 60 * 1000;
|
|
7
|
+
function targetRow(db, sessionId, createdAt) {
|
|
8
|
+
return db.handle.prepare('SELECT session_id, created_at, deleted_at, purged_at, stored_bytes, last_seq, manifest_json FROM sessions WHERE dataset = ? AND session_id = ?').get(DEFAULT_DATASET, sessionId);
|
|
9
|
+
}
|
|
10
|
+
function requireTarget(db, sessionId, createdAt) {
|
|
11
|
+
const row = targetRow(db, sessionId, createdAt);
|
|
12
|
+
if (row === undefined) throw new SyncError('not-found', `没有会话 ${sessionId}`);
|
|
13
|
+
if (row.created_at !== createdAt) throw new SyncError('conflict', 'createdAt 与当前生命周期不符');
|
|
14
|
+
return row;
|
|
15
|
+
}
|
|
16
|
+
function manifestBlobRefs(manifest) {
|
|
17
|
+
const frames = Array.isArray(manifest.frames) ? manifest.frames : [];
|
|
18
|
+
const hashes = [];
|
|
19
|
+
for (const frame of frames){
|
|
20
|
+
if (frame === null || typeof frame !== 'object') continue;
|
|
21
|
+
const record = frame;
|
|
22
|
+
if (typeof record.blobSha256 === 'string' && record.blobSha256 !== '') hashes.push(record.blobSha256);
|
|
23
|
+
if (typeof record.attachmentIndexSha256 === 'string' && record.attachmentIndexSha256 !== '') {
|
|
24
|
+
hashes.push(record.attachmentIndexSha256);
|
|
25
|
+
}
|
|
26
|
+
if (Array.isArray(record.attachmentBlobs)) {
|
|
27
|
+
for (const sha of record.attachmentBlobs){
|
|
28
|
+
if (typeof sha === 'string' && sha !== '') hashes.push(sha);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return hashes;
|
|
33
|
+
}
|
|
34
|
+
function purgeSessionBlobs(db, dataDir, sessionId, manifestJson) {
|
|
35
|
+
let hashes;
|
|
36
|
+
try {
|
|
37
|
+
hashes = manifestBlobRefs(JSON.parse(manifestJson));
|
|
38
|
+
} catch {
|
|
39
|
+
throw new SyncError('conflict', '会话 manifest 损坏,拒绝清理');
|
|
40
|
+
}
|
|
41
|
+
const unique = [
|
|
42
|
+
...new Set(hashes)
|
|
43
|
+
];
|
|
44
|
+
if (unique.length === 0) return {
|
|
45
|
+
bytes: 0,
|
|
46
|
+
files: []
|
|
47
|
+
};
|
|
48
|
+
const otherHashes = new Set();
|
|
49
|
+
const otherRows = db.handle.prepare('SELECT manifest_json FROM sessions WHERE dataset = ? AND session_id <> ?').all(DEFAULT_DATASET, sessionId);
|
|
50
|
+
for (const row of otherRows){
|
|
51
|
+
try {
|
|
52
|
+
for (const hash of manifestBlobRefs(JSON.parse(row.manifest_json)))otherHashes.add(hash);
|
|
53
|
+
} catch {
|
|
54
|
+
for (const hash of unique)otherHashes.add(hash);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
const vaultCheck = db.handle.prepare('SELECT 1 FROM vault_records WHERE blob_sha256 = ? LIMIT 1');
|
|
58
|
+
const deleteBlob = db.handle.prepare('DELETE FROM blobs WHERE sha256 = ?');
|
|
59
|
+
const blobInfo = db.handle.prepare('SELECT bytes FROM blobs WHERE sha256 = ?');
|
|
60
|
+
let bytes = 0;
|
|
61
|
+
const files = [];
|
|
62
|
+
for (const hash of unique){
|
|
63
|
+
if (otherHashes.has(hash) || vaultCheck.get(hash) !== undefined) continue;
|
|
64
|
+
const record = blobInfo.get(hash);
|
|
65
|
+
if (record === undefined) continue;
|
|
66
|
+
deleteBlob.run(hash);
|
|
67
|
+
bytes += Number(record.bytes ?? 0);
|
|
68
|
+
files.push(blobPath(dataDir, hash));
|
|
69
|
+
}
|
|
70
|
+
return {
|
|
71
|
+
bytes,
|
|
72
|
+
files
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
function removeFiles(files) {
|
|
76
|
+
for (const file of files){
|
|
77
|
+
try {
|
|
78
|
+
rmSync(file, {
|
|
79
|
+
force: true
|
|
80
|
+
});
|
|
81
|
+
} catch {}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
export function archiveSession(db, sessionId, createdAt, now = Date.now()) {
|
|
85
|
+
withTransaction(db, ()=>{
|
|
86
|
+
const row = requireTarget(db, sessionId, createdAt);
|
|
87
|
+
if (row.deleted_at !== null) throw new SyncError('conflict', '会话已经删除/存档');
|
|
88
|
+
const revision = bumpRevision(db);
|
|
89
|
+
db.handle.prepare('UPDATE sessions SET deleted_at = ?, revision = ?, updated_at = ? WHERE dataset = ? AND session_id = ?').run(now, revision, now, DEFAULT_DATASET, sessionId);
|
|
90
|
+
db.handle.prepare(`INSERT OR REPLACE INTO tombstones (dataset, session_id, created_at, deleted_at, deleted_by)
|
|
91
|
+
VALUES (?, ?, ?, ?, ?)`).run(DEFAULT_DATASET, sessionId, createdAt, now, 'admin:archive');
|
|
92
|
+
db.handle.prepare('DELETE FROM append_requests WHERE dataset = ? AND session_id = ?').run(DEFAULT_DATASET, sessionId);
|
|
93
|
+
recordSessionEvent(db, {
|
|
94
|
+
sessionId,
|
|
95
|
+
at: now,
|
|
96
|
+
kind: 'archive',
|
|
97
|
+
actorKind: 'admin',
|
|
98
|
+
actorId: 'admin',
|
|
99
|
+
lastSeq: row.last_seq ?? null,
|
|
100
|
+
storedBytes: row.stored_bytes ?? null,
|
|
101
|
+
detail: {
|
|
102
|
+
createdAt
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
export function removeSession(db, dataDir, sessionId, createdAt, now = Date.now()) {
|
|
108
|
+
let purged;
|
|
109
|
+
withTransaction(db, ()=>{
|
|
110
|
+
const row = requireTarget(db, sessionId, createdAt);
|
|
111
|
+
if (row.purged_at !== null) throw new SyncError('conflict', '会话已经移除并释放过空间');
|
|
112
|
+
purged = purgeSessionBlobs(db, dataDir, sessionId, row.manifest_json);
|
|
113
|
+
const revision = bumpRevision(db);
|
|
114
|
+
let manifest = {};
|
|
115
|
+
try {
|
|
116
|
+
manifest = JSON.parse(row.manifest_json);
|
|
117
|
+
} catch {
|
|
118
|
+
manifest = {};
|
|
119
|
+
}
|
|
120
|
+
manifest.frames = [];
|
|
121
|
+
db.handle.prepare(`UPDATE sessions
|
|
122
|
+
SET deleted_at = COALESCE(deleted_at, ?), purged_at = ?, revision = ?, updated_at = ?,
|
|
123
|
+
stored_bytes = 0, manifest_json = ?
|
|
124
|
+
WHERE dataset = ? AND session_id = ?`).run(now, now, revision, now, JSON.stringify(manifest), DEFAULT_DATASET, sessionId);
|
|
125
|
+
db.handle.prepare(`INSERT OR REPLACE INTO tombstones (dataset, session_id, created_at, deleted_at, deleted_by)
|
|
126
|
+
VALUES (?, ?, ?, ?, ?)`).run(DEFAULT_DATASET, sessionId, createdAt, now, 'admin:remove');
|
|
127
|
+
db.handle.prepare('DELETE FROM append_requests WHERE dataset = ? AND session_id = ?').run(DEFAULT_DATASET, sessionId);
|
|
128
|
+
recordSessionEvent(db, {
|
|
129
|
+
sessionId,
|
|
130
|
+
at: now,
|
|
131
|
+
kind: 'remove',
|
|
132
|
+
actorKind: 'admin',
|
|
133
|
+
actorId: 'admin',
|
|
134
|
+
bytes: purged?.bytes ?? 0,
|
|
135
|
+
lastSeq: null,
|
|
136
|
+
storedBytes: 0,
|
|
137
|
+
detail: {
|
|
138
|
+
createdAt,
|
|
139
|
+
purgedBytes: purged?.bytes ?? 0
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
const result = purged ?? {
|
|
144
|
+
bytes: 0,
|
|
145
|
+
files: []
|
|
146
|
+
};
|
|
147
|
+
removeFiles(result.files);
|
|
148
|
+
return result.bytes;
|
|
149
|
+
}
|
|
150
|
+
export function resetSession(db, dataDir, sessionId, createdAt, deviceId, now = Date.now()) {
|
|
151
|
+
let purged;
|
|
152
|
+
withTransaction(db, ()=>{
|
|
153
|
+
const row = requireTarget(db, sessionId, createdAt);
|
|
154
|
+
purged = purgeSessionBlobs(db, dataDir, sessionId, row.manifest_json);
|
|
155
|
+
db.handle.prepare('DELETE FROM append_requests WHERE dataset = ? AND session_id = ?').run(DEFAULT_DATASET, sessionId);
|
|
156
|
+
db.handle.prepare('DELETE FROM tombstones WHERE dataset = ? AND session_id = ?').run(DEFAULT_DATASET, sessionId);
|
|
157
|
+
db.handle.prepare('DELETE FROM sessions WHERE dataset = ? AND session_id = ?').run(DEFAULT_DATASET, sessionId);
|
|
158
|
+
db.handle.prepare('DELETE FROM leases WHERE dataset = ? AND resource = ?').run(DEFAULT_DATASET, 'sync');
|
|
159
|
+
let workspaceKey = null;
|
|
160
|
+
try {
|
|
161
|
+
const manifest = JSON.parse(row.manifest_json);
|
|
162
|
+
if (typeof manifest.workspaceKey === 'string' && manifest.workspaceKey !== '') workspaceKey = manifest.workspaceKey;
|
|
163
|
+
} catch {
|
|
164
|
+
workspaceKey = null;
|
|
165
|
+
}
|
|
166
|
+
db.handle.prepare(`INSERT OR REPLACE INTO session_resets
|
|
167
|
+
(dataset, session_id, created_at, reset_at, expires_at, device_id, workspace_key)
|
|
168
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`).run(DEFAULT_DATASET, sessionId, createdAt, now, now + SESSION_RESET_TTL_MS, deviceId, workspaceKey);
|
|
169
|
+
recordSessionEvent(db, {
|
|
170
|
+
sessionId,
|
|
171
|
+
at: now,
|
|
172
|
+
kind: 'reset',
|
|
173
|
+
actorKind: 'admin',
|
|
174
|
+
actorId: 'admin',
|
|
175
|
+
bytes: purged?.bytes ?? 0,
|
|
176
|
+
detail: {
|
|
177
|
+
createdAt,
|
|
178
|
+
deviceId,
|
|
179
|
+
purgedBytes: purged?.bytes ?? 0
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
const result = purged ?? {
|
|
184
|
+
bytes: 0,
|
|
185
|
+
files: []
|
|
186
|
+
};
|
|
187
|
+
removeFiles(result.files);
|
|
188
|
+
return result.bytes;
|
|
189
|
+
}
|
|
190
|
+
export function restoreSession(db, sessionId, createdAt, now = Date.now()) {
|
|
191
|
+
withTransaction(db, ()=>{
|
|
192
|
+
const row = requireTarget(db, sessionId, createdAt);
|
|
193
|
+
if (row.deleted_at === null) throw new SyncError('conflict', '会话没有存档');
|
|
194
|
+
if (row.purged_at !== null) throw new SyncError('conflict', '会话已经释放空间,不能恢复');
|
|
195
|
+
const revision = bumpRevision(db);
|
|
196
|
+
db.handle.prepare('UPDATE sessions SET deleted_at = NULL, revision = ?, updated_at = ? WHERE dataset = ? AND session_id = ?').run(revision, now, DEFAULT_DATASET, sessionId);
|
|
197
|
+
db.handle.prepare('DELETE FROM tombstones WHERE dataset = ? AND session_id = ? AND created_at = ?').run(DEFAULT_DATASET, sessionId, createdAt);
|
|
198
|
+
recordSessionEvent(db, {
|
|
199
|
+
sessionId,
|
|
200
|
+
at: now,
|
|
201
|
+
kind: 'restore',
|
|
202
|
+
actorKind: 'admin',
|
|
203
|
+
actorId: 'admin',
|
|
204
|
+
lastSeq: null,
|
|
205
|
+
storedBytes: row.stored_bytes ?? null,
|
|
206
|
+
detail: {
|
|
207
|
+
createdAt
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
//# sourceURL=session-admin.ts
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { DEFAULT_DATASET } from './db.js';
|
|
2
|
+
export function recordSessionEvent(db, input) {
|
|
3
|
+
db.handle.prepare(`INSERT INTO session_events
|
|
4
|
+
(dataset, session_id, at, kind, device_id, actor_kind, actor_id, events, bytes, seq_from, seq_to,
|
|
5
|
+
batch_count, last_seq, stored_bytes, detail)
|
|
6
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(DEFAULT_DATASET, input.sessionId, input.at, input.kind, input.deviceId ?? null, input.actorKind, input.actorId, Math.max(0, Math.trunc(input.events ?? 0)), Math.max(0, Math.trunc(input.bytes ?? 0)), input.seqFrom ?? null, input.seqTo ?? null, Math.max(0, Math.trunc(input.batchCount ?? 0)), input.lastSeq ?? null, input.storedBytes ?? null, input.detail === undefined ? null : JSON.stringify(input.detail));
|
|
7
|
+
}
|
|
8
|
+
export function listSessionHistory(db, sessionId, options = {}) {
|
|
9
|
+
const limit = Math.min(200, Math.max(1, Math.trunc(options.limit ?? 100)));
|
|
10
|
+
const before = options.before !== undefined && Number.isSafeInteger(options.before) ? options.before : undefined;
|
|
11
|
+
const rows = db.handle.prepare(`SELECT e.id, e.session_id, e.at, e.kind, e.device_id, e.actor_kind, e.actor_id,
|
|
12
|
+
e.events, e.bytes, e.seq_from, e.seq_to, e.batch_count, e.last_seq, e.stored_bytes, e.detail,
|
|
13
|
+
d.label AS device_label, d.color AS device_color
|
|
14
|
+
FROM session_events e
|
|
15
|
+
LEFT JOIN devices d ON d.dataset = e.dataset AND d.device_id = e.device_id
|
|
16
|
+
WHERE e.dataset = ? AND e.session_id = ? ${before === undefined ? '' : 'AND e.id < ?'}
|
|
17
|
+
ORDER BY e.id DESC LIMIT ?`).all(...[
|
|
18
|
+
DEFAULT_DATASET,
|
|
19
|
+
sessionId,
|
|
20
|
+
...before === undefined ? [] : [
|
|
21
|
+
before
|
|
22
|
+
],
|
|
23
|
+
limit
|
|
24
|
+
]);
|
|
25
|
+
const events = rows.map((row)=>({
|
|
26
|
+
id: row.id,
|
|
27
|
+
sessionId: row.session_id,
|
|
28
|
+
at: row.at,
|
|
29
|
+
kind: row.kind,
|
|
30
|
+
deviceId: row.device_id,
|
|
31
|
+
deviceLabel: row.device_label,
|
|
32
|
+
deviceColor: row.device_color,
|
|
33
|
+
actorKind: row.actor_kind,
|
|
34
|
+
actorId: row.actor_id,
|
|
35
|
+
actorLabel: row.actor_kind === 'admin' ? '管理台' : row.actor_kind === 'system' ? '系统' : row.device_label ?? row.actor_id,
|
|
36
|
+
events: Number(row.events ?? 0),
|
|
37
|
+
bytes: Number(row.bytes ?? 0),
|
|
38
|
+
seqFrom: row.seq_from,
|
|
39
|
+
seqTo: row.seq_to,
|
|
40
|
+
batchCount: Number(row.batch_count ?? 0),
|
|
41
|
+
lastSeq: row.last_seq,
|
|
42
|
+
storedBytes: row.stored_bytes,
|
|
43
|
+
detail: row.detail === null ? null : parseDetail(row.detail)
|
|
44
|
+
}));
|
|
45
|
+
const summaryRow = db.handle.prepare(`SELECT COALESCE(SUM(events), 0) AS events, COALESCE(SUM(bytes), 0) AS bytes,
|
|
46
|
+
COALESCE(SUM(batch_count), 0) AS batches,
|
|
47
|
+
COALESCE(SUM(CASE WHEN kind IN ('create', 'append') THEN 1 ELSE 0 END), 0) AS appends,
|
|
48
|
+
COUNT(DISTINCT device_id) AS devices,
|
|
49
|
+
MIN(at) AS first_at, MAX(at) AS last_at,
|
|
50
|
+
COALESCE(MAX(events), 0) AS max_batch_events
|
|
51
|
+
FROM session_events WHERE dataset = ? AND session_id = ?`).get(DEFAULT_DATASET, sessionId);
|
|
52
|
+
return {
|
|
53
|
+
events,
|
|
54
|
+
summary: {
|
|
55
|
+
events: Number(summaryRow?.events ?? 0),
|
|
56
|
+
bytes: Number(summaryRow?.bytes ?? 0),
|
|
57
|
+
batches: Number(summaryRow?.batches ?? 0),
|
|
58
|
+
appends: Number(summaryRow?.appends ?? 0),
|
|
59
|
+
devices: Number(summaryRow?.devices ?? 0),
|
|
60
|
+
firstAt: summaryRow?.first_at ?? null,
|
|
61
|
+
lastAt: summaryRow?.last_at ?? null,
|
|
62
|
+
maxBatchEvents: Number(summaryRow?.max_batch_events ?? 0)
|
|
63
|
+
},
|
|
64
|
+
nextBefore: rows.length === limit ? rows[rows.length - 1].id : null
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
export function listActivity(db, query) {
|
|
68
|
+
const offsetMs = Math.max(-14 * 60, Math.min(14 * 60, Math.trunc(query.tzOffset ?? 0))) * 60_000;
|
|
69
|
+
const where = [
|
|
70
|
+
'e.dataset = ?',
|
|
71
|
+
"e.kind IN ('create', 'append')",
|
|
72
|
+
'e.at >= ?',
|
|
73
|
+
'e.at < ?'
|
|
74
|
+
];
|
|
75
|
+
const params = [
|
|
76
|
+
DEFAULT_DATASET,
|
|
77
|
+
Math.trunc(query.from),
|
|
78
|
+
Math.trunc(query.to)
|
|
79
|
+
];
|
|
80
|
+
if (query.deviceId !== undefined && query.deviceId !== '') {
|
|
81
|
+
where.push('e.device_id = ?');
|
|
82
|
+
params.push(query.deviceId);
|
|
83
|
+
}
|
|
84
|
+
if (query.sessionId !== undefined && query.sessionId !== '') {
|
|
85
|
+
where.push('e.session_id = ?');
|
|
86
|
+
params.push(query.sessionId);
|
|
87
|
+
}
|
|
88
|
+
const rows = db.handle.prepare(`SELECT date((e.at + ?) / 1000, 'unixepoch') AS day,
|
|
89
|
+
COALESCE(SUM(e.events), 0) AS events,
|
|
90
|
+
COALESCE(SUM(e.bytes), 0) AS bytes,
|
|
91
|
+
COUNT(*) AS appends,
|
|
92
|
+
COUNT(DISTINCT e.session_id) AS sessions,
|
|
93
|
+
COUNT(DISTINCT e.device_id) AS devices
|
|
94
|
+
FROM session_events e
|
|
95
|
+
WHERE ${where.join(' AND ')}
|
|
96
|
+
GROUP BY day ORDER BY day ASC`).all(offsetMs, ...params);
|
|
97
|
+
return {
|
|
98
|
+
days: rows.map((row)=>({
|
|
99
|
+
day: row.day,
|
|
100
|
+
events: Number(row.events ?? 0),
|
|
101
|
+
bytes: Number(row.bytes ?? 0),
|
|
102
|
+
appends: Number(row.appends ?? 0),
|
|
103
|
+
sessions: Number(row.sessions ?? 0),
|
|
104
|
+
devices: Number(row.devices ?? 0)
|
|
105
|
+
}))
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
export function listActivitySessions(db, query) {
|
|
109
|
+
const offsetMs = Math.max(-14 * 60, Math.min(14 * 60, Math.trunc(query.tzOffset ?? 0))) * 60_000;
|
|
110
|
+
const rows = db.handle.prepare(`SELECT e.session_id, COALESCE(SUM(e.events), 0) AS events,
|
|
111
|
+
COALESCE(SUM(e.bytes), 0) AS bytes, COUNT(*) AS appends
|
|
112
|
+
FROM session_events e
|
|
113
|
+
WHERE e.dataset = ? AND e.kind IN ('create', 'append')
|
|
114
|
+
AND date((e.at + ?) / 1000, 'unixepoch') = ?
|
|
115
|
+
GROUP BY e.session_id ORDER BY events DESC`).all(DEFAULT_DATASET, offsetMs, query.day);
|
|
116
|
+
return {
|
|
117
|
+
sessions: rows.map((row)=>({
|
|
118
|
+
sessionId: row.session_id,
|
|
119
|
+
events: Number(row.events ?? 0),
|
|
120
|
+
bytes: Number(row.bytes ?? 0),
|
|
121
|
+
appends: Number(row.appends ?? 0)
|
|
122
|
+
}))
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
function parseDetail(raw) {
|
|
126
|
+
try {
|
|
127
|
+
return JSON.parse(raw);
|
|
128
|
+
} catch {
|
|
129
|
+
return raw;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
//# sourceURL=session-history.ts
|