@swimmingliu/autovpn 2.0.2 → 2.0.3

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.
@@ -76,6 +76,45 @@ export function readLatestStageStatuses(dbPath) {
76
76
  catch { /* malformed or locked databases are ignored */ }
77
77
  }
78
78
  }
79
+ // Reports are written at stage boundaries. A running UI needs current totals,
80
+ // and opening RunStore would migrate/write the database during observation.
81
+ export function readRunProgress(dbPath) {
82
+ if (!fs.existsSync(dbPath))
83
+ return undefined;
84
+ let db;
85
+ try {
86
+ db = new DatabaseSync(dbPath, { readOnly: true });
87
+ db.exec('PRAGMA busy_timeout=5000; BEGIN');
88
+ const runId = db.prepare('SELECT run_id FROM runs ORDER BY run_id DESC LIMIT 1').get()?.run_id;
89
+ if (typeof runId !== 'number' && typeof runId !== 'bigint')
90
+ return undefined;
91
+ const raw = Number(db.prepare('SELECT COUNT(*) AS n FROM raw_observations WHERE run_id=?').get(runId)?.n ?? 0);
92
+ const deduped = Number(db.prepare('SELECT COUNT(*) AS n FROM pipeline_nodes WHERE run_id=?').get(runId)?.n ?? 0);
93
+ const speed = Number(db.prepare("SELECT COUNT(*) AS n FROM speed_results WHERE run_id=? AND status='speed_passed'").get(runId)?.n ?? 0);
94
+ const availability = Number(db.prepare("SELECT COUNT(*) AS n FROM availability_results WHERE run_id=? AND status='availability_passed'").get(runId)?.n ?? 0);
95
+ const sources = {};
96
+ const completeOwnership = Number(db.prepare('SELECT COUNT(*) AS n FROM pipeline_nodes WHERE run_id=? AND first_source IS NULL').get(runId)?.n ?? 0) === 0;
97
+ for (const row of db.prepare('SELECT source,COUNT(*) AS n FROM raw_observations WHERE run_id=? GROUP BY source ORDER BY MIN(observation_id)').all(runId)) {
98
+ if (typeof row.source === 'string')
99
+ sources[row.source] = { raw_links: Number(row.n), ...(completeOwnership ? { deduped_links: 0 } : {}) };
100
+ }
101
+ if (completeOwnership) {
102
+ for (const row of db.prepare('SELECT first_source AS source,COUNT(*) AS n FROM pipeline_nodes WHERE run_id=? AND first_source IS NOT NULL GROUP BY first_source').all(runId)) {
103
+ if (typeof row.source === 'string')
104
+ sources[row.source] = { raw_links: sources[row.source]?.raw_links ?? 0, deduped_links: Number(row.n) };
105
+ }
106
+ }
107
+ db.exec('COMMIT');
108
+ return { counts: { raw_links: raw, deduped_links: deduped, speedtest_links: speed, availability_links: availability }, source_counts: sources };
109
+ }
110
+ catch {
111
+ // Legacy or incomplete databases retain the existing report/event path.
112
+ return undefined;
113
+ }
114
+ finally {
115
+ db?.close();
116
+ }
117
+ }
79
118
  export class RunStore {
80
119
  db;
81
120
  statements = new Map();
@@ -9,6 +9,7 @@ import { latestJobId, loadJob } from '../jobs/read.js';
9
9
  import { AccountStore } from '../pool/account-store.js';
10
10
  import { normalizeBrand } from '../pool/brands.js';
11
11
  import { parseAccountFile } from '../pool/csv.js';
12
+ import { readRunProgress } from '../pipeline/run-store.js';
12
13
  import { resolveArtifactsRoot, resolveProfilePath } from '../runtime/paths.js';
13
14
  const DEPLOY_SECRET_KEYS = new Set([
14
15
  'cloudflare_api_token',
@@ -66,9 +67,13 @@ function normalizeLatestArtifact(projectRoot, env) {
66
67
  return undefined;
67
68
  }
68
69
  const preview = previewArtifact(String(latest.artifact_dir));
70
+ const progress = readRunProgress(path.join(String(latest.artifact_dir), 'run.db'));
69
71
  return {
70
72
  ...latest,
71
73
  ...preview,
74
+ // The report can still be empty mid-stage. Its display preview must not
75
+ // replace the authoritative totals that already exist in the run database.
76
+ ...(progress ? { counts: { ...(preview.counts ?? {}), ...progress.counts }, source_counts: progress.source_counts, progress_snapshot: progress } : {}),
72
77
  outputFiles: (preview.files ?? []),
73
78
  nodeRows: []
74
79
  };
@@ -345,7 +350,16 @@ export function createServerRuntime(options) {
345
350
  if (nextRunState) {
346
351
  runState = nextRunState;
347
352
  }
348
- publish(event);
353
+ // A follower can deliver old result lines after loadState. Send
354
+ // absolute database totals so those lines cannot add twice.
355
+ const metricEvent = typeof event === 'object' && event !== null && 'type' in event
356
+ && ['extract_iteration', 'extract_source_completed', 'speedtest_result', 'availability_link_result'].includes(String(event.type));
357
+ const job = metricEvent ? loadJob(options.projectRoot, jobId, options.env) : undefined;
358
+ const artifactDir = String(job?.artifact_dir ?? '');
359
+ const progress = artifactDir ? readRunProgress(path.join(artifactDir, 'run.db')) : undefined;
360
+ publish(progress && typeof event === 'object'
361
+ ? { ...event, progress_snapshot: { ...progress, artifact_dir: artifactDir } }
362
+ : event);
349
363
  }
350
364
  }
351
365
  }
@@ -138,7 +138,8 @@ const state = {
138
138
  qr: createQrState(),
139
139
  runStartedAt: null,
140
140
  lastUpdateAt: null,
141
- modalTransform: ''
141
+ modalTransform: '',
142
+ progressSnapshot: null
142
143
  };
143
144
 
144
145
  let settingsDrawerOpener = null;
@@ -222,11 +223,13 @@ function hydrateHistoricalEvents(events = []) {
222
223
  }
223
224
  const previousRunState = state.runState;
224
225
  const previousRunResult = state.runResult;
226
+ const snapshot = state.progressSnapshot;
225
227
  for (const event of events) {
226
228
  handlePipelineEvent(event, { historical: true });
227
229
  }
228
230
  state.runState = previousRunState;
229
231
  state.runResult = previousRunState === 'idle' ? previousRunResult : previousRunState;
232
+ if (snapshot) applyProgressSnapshot(snapshot);
230
233
  }
231
234
 
232
235
  function bindActions() {
@@ -1554,7 +1557,24 @@ function finishRun(result = {}) {
1554
1557
  void hydrateRetryArtifacts();
1555
1558
  }
1556
1559
 
1560
+ function applyProgressSnapshot(snapshot) {
1561
+ if (!snapshot?.counts || !snapshot.source_counts) return;
1562
+ state.progressSnapshot = snapshot;
1563
+ state.counts = normalizeCounts({ ...state.counts, ...snapshot.counts });
1564
+ state.sourceCounts = normalizeSourceCounts(snapshot.source_counts);
1565
+ }
1566
+
1557
1567
  function handlePipelineEvent(event, options = {}) {
1568
+ handlePipelineEventDetails(event, options);
1569
+ if (event.progress_snapshot && !options.historical
1570
+ && (!event.progress_snapshot.artifact_dir || event.progress_snapshot.artifact_dir === state.artifactDir)) {
1571
+ applyProgressSnapshot(event.progress_snapshot);
1572
+ touchUpdate();
1573
+ renderRuntimeOnly({ chrome: false });
1574
+ }
1575
+ }
1576
+
1577
+ function handlePipelineEventDetails(event, options = {}) {
1558
1578
  const historical = Boolean(options.historical);
1559
1579
  if (event.type === 'server_state') {
1560
1580
  const nextRunState = String(event.run_state ?? '');
@@ -1703,7 +1723,7 @@ function handlePipelineEvent(event, options = {}) {
1703
1723
  }
1704
1724
 
1705
1725
  if (event.type === 'extract_source_completed') {
1706
- updateExtractMetrics({ source_name: event.source_name, total_links: event.raw_links });
1726
+ if (!event.progress_snapshot && !(historical && state.progressSnapshot)) updateExtractMetrics({ source_name: event.source_name, total_links: event.raw_links });
1707
1727
  appendLog(`[extract] ${event.source_name} 完成,成功 ${event.successful_iterations ?? 0} 次,失败 ${event.failed_iterations ?? 0} 次,原始节点 ${event.raw_links ?? 0} 个`, {
1708
1728
  kind: 'stage',
1709
1729
  stage: 'extract',
@@ -1713,7 +1733,7 @@ function handlePipelineEvent(event, options = {}) {
1713
1733
  }
1714
1734
 
1715
1735
  if (event.type === 'extract_iteration') {
1716
- updateExtractMetrics(event);
1736
+ if (!event.progress_snapshot && !(historical && state.progressSnapshot)) updateExtractMetrics(event);
1717
1737
  appendLog(`[extract] ${event.source_name} #${event.iteration ?? 0} 新增 ${event.new_items ?? 0} 个,本次解析 ${event.extracted_links ?? 0} 个,累计 ${event.total_links ?? 0} 个`, {
1718
1738
  kind: 'stage',
1719
1739
  stage: 'extract',
@@ -1723,6 +1743,7 @@ function handlePipelineEvent(event, options = {}) {
1723
1743
  }
1724
1744
 
1725
1745
  if (event.type === 'speedtest_result') {
1746
+ if (event.progress_snapshot || (historical && state.progressSnapshot)) return;
1726
1747
  if (event.passed_threshold) {
1727
1748
  state.counts.speedtest_links = Number(state.counts.speedtest_links ?? 0) + 1;
1728
1749
  }
@@ -1732,6 +1753,7 @@ function handlePipelineEvent(event, options = {}) {
1732
1753
  }
1733
1754
 
1734
1755
  if (event.type === 'availability_link_result') {
1756
+ if (event.progress_snapshot || (historical && state.progressSnapshot)) return;
1735
1757
  if (event.all_passed) {
1736
1758
  state.counts.availability_links = Number(state.counts.availability_links ?? 0) + 1;
1737
1759
  }
@@ -1770,6 +1792,7 @@ function normalizeStageStatuses(stageStatuses = {}) {
1770
1792
  }
1771
1793
 
1772
1794
  function resetRunScopedProgress() {
1795
+ state.progressSnapshot = null;
1773
1796
  state.stageStatus = {};
1774
1797
  state.counts = {};
1775
1798
  state.sourceCounts = {};
@@ -1875,6 +1898,8 @@ function hydrateArtifactState(result) {
1875
1898
  state.artifactDir = result.artifact_dir;
1876
1899
  state.counts = normalizeCounts(result.counts ?? {});
1877
1900
  state.sourceCounts = normalizeSourceCounts(result.source_counts ?? {});
1901
+ // Bounded log history cannot reconstruct metrics that predate its tail.
1902
+ state.progressSnapshot = result.progress_snapshot ?? null;
1878
1903
  state.extractDedupedFingerprints = new Set();
1879
1904
  state.outputFiles = result.outputFiles ?? [];
1880
1905
  state.nodeRows = result.nodeRows ?? [];
@@ -5,7 +5,7 @@ const ZH_MESSAGES = {
5
5
  locale: 'zh-CN',
6
6
  appTitle: 'AutoVPN',
7
7
  sidebarTitle: 'AutoVPN',
8
- sidebarVersion: 'v.2.0.2',
8
+ sidebarVersion: 'v.2.0.3',
9
9
  brandSubtitle: '概览、运行、结果、订阅、日志、设置统一管理',
10
10
  languageLabel: '',
11
11
  saveButton: '保存配置',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swimmingliu/autovpn",
3
- "version": "2.0.2",
3
+ "version": "2.0.3",
4
4
  "description": "npm wrapper for the AutoVPN headless CLI",
5
5
  "type": "module",
6
6
  "repository": {