@swimmingliu/autovpn 1.8.0 → 2.0.2
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/dist/artifacts/list.js +48 -6
- package/dist/cli/accounts-commands.js +304 -0
- package/dist/cli/commands/index.js +6 -0
- package/dist/cli/native-commands.js +10 -0
- package/dist/cli/output.js +1 -0
- package/dist/config/profile.js +119 -5
- package/dist/events/schema.js +2 -0
- package/dist/pipeline/apk-discovery.js +504 -0
- package/dist/pipeline/brand-cache.js +126 -0
- package/dist/pipeline/brand-protocol.js +210 -0
- package/dist/pipeline/credential-source.js +1406 -0
- package/dist/pipeline/extract.js +55 -2
- package/dist/pipeline/orchestrator.js +88 -9
- package/dist/pool/account-store.js +484 -0
- package/dist/pool/brands.js +110 -0
- package/dist/pool/csv.js +388 -0
- package/dist/server/http.js +25 -0
- package/dist/server/runtime.js +76 -2
- package/dist/server/web-adapter.js +18 -2
- package/dist/web/renderer/THIRD_PARTY_NOTICES.txt +345 -0
- package/dist/web/renderer/analytics.bundle.css +2 -0
- package/dist/web/renderer/analytics.bundle.js +66 -0
- package/dist/web/renderer/app.js +385 -80
- package/dist/web/renderer/appearance.js +76 -0
- package/dist/web/renderer/assets/vpn-auto-logo-v3.png +0 -0
- package/dist/web/renderer/i18n.js +1 -1
- package/dist/web/renderer/icons.js +38 -0
- package/dist/web/renderer/index.html +24 -2
- package/dist/web/renderer/state.js +0 -1
- package/dist/web/renderer/styles.css +584 -48
- package/dist/web/renderer/tables.js +208 -0
- package/dist/web/renderer/views.js +483 -255
- package/dist/web/renderer/workspace.css +381 -0
- package/package.json +4 -3
package/dist/web/renderer/app.js
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import { getMessages, formatMessage } from './i18n.js';
|
|
2
2
|
import { createLogViewState, createQrState, resolveRunControlState } from './state.js';
|
|
3
|
+
// Keep appearance changes separate from profile saves and running jobs, so a
|
|
4
|
+
// theme switch cannot discard a settings draft or restart the pipeline.
|
|
5
|
+
import { getThemePreference, initializeAppearance, selectTheme, toggleSidebar } from './appearance.js';
|
|
6
|
+
import { enhanceTables } from './tables.js';
|
|
7
|
+
import { icon } from './icons.js';
|
|
8
|
+
import { renderAnalytics, clearAnalytics } from './analytics.bundle.js';
|
|
3
9
|
import {
|
|
4
10
|
addAvailabilityTargetDraft,
|
|
5
11
|
applyAvailabilityTargetDraft,
|
|
@@ -7,47 +13,33 @@ import {
|
|
|
7
13
|
buildAvailabilityTargetDraft,
|
|
8
14
|
buildPageMarkup,
|
|
9
15
|
buildDashboardMetricsMarkup,
|
|
16
|
+
buildDashboardStateMarkup,
|
|
10
17
|
buildLogCenterMarkup,
|
|
18
|
+
buildLogStateActions,
|
|
11
19
|
buildRunsCurrentStageMarkup,
|
|
12
20
|
buildRunsStageProgressMarkup,
|
|
21
|
+
buildSpeedTestDraft,
|
|
13
22
|
buildSourceIterationDraft,
|
|
14
23
|
buildSidebarNav,
|
|
15
24
|
buildViewModel,
|
|
16
|
-
|
|
25
|
+
buildAccountPoolRecords,
|
|
17
26
|
removeAvailabilityTargetDraft,
|
|
27
|
+
resolveDashboardRunSummary,
|
|
28
|
+
validateSourceDraft,
|
|
18
29
|
classifyLogEntry,
|
|
19
30
|
escapeHtml,
|
|
20
31
|
extractSourceUrlFromCurl,
|
|
21
32
|
filterLogEntries
|
|
22
33
|
} from './views.js';
|
|
23
34
|
|
|
35
|
+
initializeAppearance();
|
|
36
|
+
|
|
24
37
|
const demoProfile = {
|
|
25
38
|
sources: {
|
|
26
|
-
leiting: {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
},
|
|
31
|
-
heidong: {
|
|
32
|
-
url: 'https://capture-2.vpn.example/api/v1/client/nodes',
|
|
33
|
-
key: 'hd-demo-key',
|
|
34
|
-
enabled: true
|
|
35
|
-
},
|
|
36
|
-
mifeng: {
|
|
37
|
-
url: 'https://capture-3.vpn.example/api/v1/client/subscribe',
|
|
38
|
-
key: 'mf-demo-key',
|
|
39
|
-
enabled: true
|
|
40
|
-
},
|
|
41
|
-
'xuanfeng-area': {
|
|
42
|
-
url: 'https://capture-4.vpn.example/api/v1/client/subscribe',
|
|
43
|
-
key: 'xf1-demo-key',
|
|
44
|
-
enabled: true
|
|
45
|
-
},
|
|
46
|
-
'xuanfeng-all-area': {
|
|
47
|
-
url: 'https://capture-5.vpn.example/api/v1/client/subscribe',
|
|
48
|
-
key: 'xf2-demo-key',
|
|
49
|
-
enabled: false
|
|
50
|
-
}
|
|
39
|
+
leiting: { mode: 'credential', official_site: 'https://www.68123836.com', enabled: true, max_iterations: 4, plateau_limit: 2 },
|
|
40
|
+
heidong: { mode: 'credential', official_site: 'https://www.67986457.com', enabled: true, max_iterations: 4, plateau_limit: 2 },
|
|
41
|
+
mifeng: { mode: 'credential', official_site: 'https://www.86933221.com', enabled: true, max_iterations: 4, plateau_limit: 2 },
|
|
42
|
+
xuanfeng: { mode: 'credential', official_site: 'https://www.16863988.com', enabled: true, max_iterations: 4, plateau_limit: 2 }
|
|
51
43
|
},
|
|
52
44
|
speed_test: {
|
|
53
45
|
min_download_mb_s: 1.0,
|
|
@@ -104,14 +96,31 @@ const state = {
|
|
|
104
96
|
stageStatus: {},
|
|
105
97
|
counts: {},
|
|
106
98
|
sourceCounts: {},
|
|
99
|
+
extractProgress: {},
|
|
107
100
|
language: 'zh-CN',
|
|
101
|
+
themePreference: getThemePreference(),
|
|
108
102
|
activePage: 'dashboard',
|
|
109
103
|
subtabs: {},
|
|
110
|
-
subscriptionFormat: 'Clash',
|
|
111
104
|
logFilter: '全部',
|
|
112
105
|
settingsDrawer: null,
|
|
106
|
+
accountPool: {
|
|
107
|
+
status: 'idle',
|
|
108
|
+
stats: null,
|
|
109
|
+
accounts: [],
|
|
110
|
+
error: '',
|
|
111
|
+
message: '',
|
|
112
|
+
issues: [],
|
|
113
|
+
query: '',
|
|
114
|
+
statusFilter: 'all',
|
|
115
|
+
brandFilter: 'all',
|
|
116
|
+
sortBy: 'brand',
|
|
117
|
+
busy: false
|
|
118
|
+
},
|
|
113
119
|
isDemo: false,
|
|
114
120
|
runtime: document.body.dataset.runtime === 'web' ? 'web' : 'electron',
|
|
121
|
+
runOptions: { skipDeploy: false, skipVerify: false, saveBeforeRun: true },
|
|
122
|
+
activity: [],
|
|
123
|
+
activityUnavailable: 0,
|
|
115
124
|
runState: 'idle',
|
|
116
125
|
runResult: 'idle',
|
|
117
126
|
logEntries: [],
|
|
@@ -141,7 +150,6 @@ const elements = {
|
|
|
141
150
|
sidebarVersion: document.querySelector('#sidebarVersion'),
|
|
142
151
|
sidebarNav: document.querySelector('#sidebarNav'),
|
|
143
152
|
pageTitle: document.querySelector('#pageTitle'),
|
|
144
|
-
pageSubtitle: document.querySelector('#pageSubtitle'),
|
|
145
153
|
runStateBadge: document.querySelector('#runStateBadge'),
|
|
146
154
|
pageActions: document.querySelector('#pageActions'),
|
|
147
155
|
pageContent: document.querySelector('#pageContent'),
|
|
@@ -173,6 +181,7 @@ async function bootstrap() {
|
|
|
173
181
|
hydrateInitialRuntimeState(loadedState);
|
|
174
182
|
hydrateHistoricalEvents(loadedState?.logEvents);
|
|
175
183
|
touchUpdate();
|
|
184
|
+
void hydrateAccountPool();
|
|
176
185
|
await refreshQrCode();
|
|
177
186
|
if (!loadedState?.retryArtifacts) {
|
|
178
187
|
await hydrateRetryArtifacts();
|
|
@@ -186,6 +195,8 @@ async function bootstrap() {
|
|
|
186
195
|
|
|
187
196
|
function hydrateInitialRuntimeState(loadedState = {}) {
|
|
188
197
|
const loadedProfile = loadedState.profile ?? {};
|
|
198
|
+
state.activity = Array.isArray(loadedState.activity) ? loadedState.activity : [];
|
|
199
|
+
state.activityUnavailable = Number(loadedState.activityUnavailable ?? 0);
|
|
189
200
|
state.profile = loadedProfile;
|
|
190
201
|
state.savedProfile = structuredClone(loadedProfile);
|
|
191
202
|
const nextRunState = String(loadedState.runState ?? '');
|
|
@@ -268,8 +279,20 @@ function renderAll() {
|
|
|
268
279
|
document.documentElement.lang = state.language;
|
|
269
280
|
document.title = messages.appTitle;
|
|
270
281
|
document.body.dataset.page = state.activePage;
|
|
282
|
+
const activeElement = document.activeElement;
|
|
283
|
+
const focusKey = activeElement?.dataset
|
|
284
|
+
? (activeElement.dataset.accountSearch !== undefined ? 'account-search'
|
|
285
|
+
: activeElement.dataset.drawerSource && activeElement.dataset.drawerKey
|
|
286
|
+
? `drawer:${activeElement.dataset.drawerSource}:${activeElement.dataset.drawerKey}`
|
|
287
|
+
: '')
|
|
288
|
+
: '';
|
|
289
|
+
const focusSelection = focusKey && 'selectionStart' in activeElement
|
|
290
|
+
? [activeElement.selectionStart, activeElement.selectionEnd]
|
|
291
|
+
: null;
|
|
271
292
|
|
|
272
293
|
renderChrome(messages, viewModel);
|
|
294
|
+
const qrPanelOpen = document.querySelector('.subscription-qr')?.open === true;
|
|
295
|
+
clearAnalytics();
|
|
273
296
|
elements.pageContent.innerHTML = buildPageMarkup(
|
|
274
297
|
state.activePage,
|
|
275
298
|
viewModel,
|
|
@@ -277,6 +300,25 @@ function renderAll() {
|
|
|
277
300
|
state.language,
|
|
278
301
|
state.subtabs
|
|
279
302
|
);
|
|
303
|
+
// Every full render replaces the tables; reapply layout to the current DOM.
|
|
304
|
+
const qrPanel = document.querySelector('.subscription-qr');
|
|
305
|
+
if (qrPanel) qrPanel.open = qrPanelOpen;
|
|
306
|
+
enhanceTables(elements.pageContent);
|
|
307
|
+
renderAnalytics(document.querySelector('#dashboardAnalytics'), viewModel);
|
|
308
|
+
if (focusKey) {
|
|
309
|
+
const selector = focusKey === 'account-search'
|
|
310
|
+
? '[data-account-search]'
|
|
311
|
+
: `[data-drawer-source="${CSS.escape(focusKey.split(':')[1])}"][data-drawer-key="${CSS.escape(focusKey.split(':')[2])}"]`;
|
|
312
|
+
const restored = document.querySelector(selector);
|
|
313
|
+
restored?.focus();
|
|
314
|
+
if (restored && focusSelection && focusSelection[0] !== null) {
|
|
315
|
+
try {
|
|
316
|
+
restored.setSelectionRange(focusSelection[0], focusSelection[1]);
|
|
317
|
+
} catch {
|
|
318
|
+
// Number inputs do not expose a text selection.
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
280
322
|
syncRunDetailsPresentation();
|
|
281
323
|
updateChromeState(messages);
|
|
282
324
|
renderToast();
|
|
@@ -298,16 +340,9 @@ function renderChrome(messages, viewModel) {
|
|
|
298
340
|
elements.sidebarTitle.textContent = messages.sidebarTitle;
|
|
299
341
|
elements.sidebarVersion.textContent = messages.sidebarVersion;
|
|
300
342
|
elements.pageTitle.textContent = messages.pageTitles[state.activePage];
|
|
301
|
-
elements.pageSubtitle.textContent = messages.pageSubtitles[state.activePage];
|
|
302
343
|
elements.sidebarNav.innerHTML = buildSidebarNav(messages, state.activePage);
|
|
303
344
|
const controlState = resolveRunControlState(state.runState);
|
|
304
|
-
elements.pageActions.
|
|
305
|
-
runDisabled: controlState.runDisabled,
|
|
306
|
-
stopDisabled: controlState.stopDisabled,
|
|
307
|
-
runLabel: resolveRunButtonLabel(messages),
|
|
308
|
-
stopLabel: messages.stopButton,
|
|
309
|
-
saveLabel: messages.saveButton
|
|
310
|
-
});
|
|
345
|
+
elements.pageActions.replaceChildren();
|
|
311
346
|
updateChromeState(messages);
|
|
312
347
|
}
|
|
313
348
|
|
|
@@ -318,7 +353,10 @@ function updateChromeState(messages = getMessages(state.language)) {
|
|
|
318
353
|
for (const button of document.querySelectorAll('[data-run-action="start"]')) {
|
|
319
354
|
button.disabled = controlState.runDisabled;
|
|
320
355
|
button.setAttribute('aria-busy', String(controlState.isBusy));
|
|
321
|
-
|
|
356
|
+
// Preserve the action icon when live pipeline status changes its label.
|
|
357
|
+
button.innerHTML = icon('play');
|
|
358
|
+
button.setAttribute('aria-label', resolveRunButtonLabel(messages));
|
|
359
|
+
button.title = resolveRunButtonLabel(messages);
|
|
322
360
|
}
|
|
323
361
|
for (const button of document.querySelectorAll('[data-run-action="stop"]')) {
|
|
324
362
|
button.disabled = controlState.stopDisabled;
|
|
@@ -356,6 +394,16 @@ function runTone() {
|
|
|
356
394
|
}
|
|
357
395
|
|
|
358
396
|
async function handleDocumentClick(event) {
|
|
397
|
+
if (event.target.closest('#sidebarToggle')) {
|
|
398
|
+
toggleSidebar();
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
const themeChoice = event.target.closest('[data-theme-choice]');
|
|
402
|
+
if (themeChoice) {
|
|
403
|
+
state.themePreference = themeChoice.dataset.themeChoice;
|
|
404
|
+
selectTheme(state.themePreference);
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
359
407
|
const jumpLatest = event.target.closest('[data-log-jump-latest]');
|
|
360
408
|
if (jumpLatest) {
|
|
361
409
|
state.logView.follow = true;
|
|
@@ -390,14 +438,6 @@ async function handleDocumentClick(event) {
|
|
|
390
438
|
return;
|
|
391
439
|
}
|
|
392
440
|
|
|
393
|
-
const formatButton = event.target.closest('[data-subscription-format]');
|
|
394
|
-
if (formatButton) {
|
|
395
|
-
state.subscriptionFormat = formatButton.dataset.subscriptionFormat;
|
|
396
|
-
refreshQrCode();
|
|
397
|
-
renderAll();
|
|
398
|
-
return;
|
|
399
|
-
}
|
|
400
|
-
|
|
401
441
|
const logFilterButton = event.target.closest('[data-log-filter]');
|
|
402
442
|
if (logFilterButton) {
|
|
403
443
|
state.logFilter = logFilterButton.dataset.logFilter;
|
|
@@ -486,6 +526,12 @@ async function handleDocumentClick(event) {
|
|
|
486
526
|
return;
|
|
487
527
|
}
|
|
488
528
|
|
|
529
|
+
const accountAction = event.target.closest('[data-account-action]');
|
|
530
|
+
if (accountAction) {
|
|
531
|
+
handleAccountPoolAction(accountAction);
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
|
|
489
535
|
const availabilityAction = event.target.closest('[data-availability-action]');
|
|
490
536
|
if (availabilityAction) {
|
|
491
537
|
handleAvailabilityTargetAction(availabilityAction);
|
|
@@ -508,6 +554,10 @@ async function handleDocumentClick(event) {
|
|
|
508
554
|
}
|
|
509
555
|
|
|
510
556
|
function handleDocumentInput(event) {
|
|
557
|
+
if (event.target.dataset.runOption) {
|
|
558
|
+
state.runOptions[event.target.dataset.runOption] = event.target.checked;
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
511
561
|
const target = event.target;
|
|
512
562
|
|
|
513
563
|
if (!state.profile) {
|
|
@@ -530,6 +580,39 @@ function handleDocumentInput(event) {
|
|
|
530
580
|
return;
|
|
531
581
|
}
|
|
532
582
|
|
|
583
|
+
if (target.matches('[data-account-file]')) {
|
|
584
|
+
handleAccountFileSelected(target);
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
if (target.matches('[data-account-search]')) {
|
|
589
|
+
state.accountPool.query = target.value;
|
|
590
|
+
touchUpdate();
|
|
591
|
+
renderAccountPoolRecords();
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
if (target.matches('[data-account-status-filter]')) {
|
|
596
|
+
state.accountPool.statusFilter = target.value;
|
|
597
|
+
touchUpdate();
|
|
598
|
+
renderAccountPoolRecords();
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
if (target.matches('[data-account-brand-filter]')) {
|
|
603
|
+
state.accountPool.brandFilter = target.value;
|
|
604
|
+
touchUpdate();
|
|
605
|
+
renderAccountPoolRecords();
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
if (target.matches('[data-account-sort]')) {
|
|
610
|
+
state.accountPool.sortBy = target.value;
|
|
611
|
+
touchUpdate();
|
|
612
|
+
renderAccountPoolRecords();
|
|
613
|
+
return;
|
|
614
|
+
}
|
|
615
|
+
|
|
533
616
|
if (target.matches('[data-drawer-source][data-drawer-key]')) {
|
|
534
617
|
if (!state.settingsDrawer?.draft) {
|
|
535
618
|
return;
|
|
@@ -540,17 +623,23 @@ function handleDocumentInput(event) {
|
|
|
540
623
|
if (!sourceDraft?.[sourceName]) {
|
|
541
624
|
return;
|
|
542
625
|
}
|
|
626
|
+
// Only the official site is free text now. `enabled` is a boolean and the
|
|
627
|
+
// round fields are numbers, so nothing here needs the URL normaliser.
|
|
543
628
|
const rawValue = target.type === 'checkbox' ? target.checked : target.value.trim();
|
|
544
|
-
const normalizedValue = key === 'url' && typeof rawValue === 'string'
|
|
545
|
-
? normalizeSourceUrlInput(rawValue)
|
|
546
|
-
: rawValue;
|
|
547
|
-
if (target.type !== 'checkbox' && normalizedValue !== target.value) {
|
|
548
|
-
target.value = normalizedValue;
|
|
549
|
-
}
|
|
550
629
|
sourceDraft[sourceName][key] =
|
|
551
630
|
target.type === 'checkbox'
|
|
552
|
-
?
|
|
553
|
-
: coerceProfileValue(
|
|
631
|
+
? rawValue
|
|
632
|
+
: coerceProfileValue(rawValue, sourceDraft[sourceName][key]);
|
|
633
|
+
// A source that never set a round has no existing number to copy the type
|
|
634
|
+
// from, and `coerceProfileValue` would leave the field a string — which then
|
|
635
|
+
// lands in profile.toml quoted. Round controls are always numbers.
|
|
636
|
+
if (SOURCE_NUMERIC_KEYS.has(key)) {
|
|
637
|
+
const parsed = Number(sourceDraft[sourceName][key]);
|
|
638
|
+
if (Number.isFinite(parsed)) {
|
|
639
|
+
const floor = SOURCE_MINIMUM_ONE_KEYS.has(key) ? 1 : 0;
|
|
640
|
+
sourceDraft[sourceName][key] = Math.max(floor, Math.trunc(parsed));
|
|
641
|
+
}
|
|
642
|
+
}
|
|
554
643
|
return;
|
|
555
644
|
}
|
|
556
645
|
|
|
@@ -670,14 +759,6 @@ function resolveDrawerSourceDraft() {
|
|
|
670
759
|
return state.settingsDrawer.draft?.sources ?? state.settingsDrawer.draft;
|
|
671
760
|
}
|
|
672
761
|
|
|
673
|
-
function normalizeSourceUrlInput(value) {
|
|
674
|
-
const text = String(value ?? '').trim();
|
|
675
|
-
if (!/^curl(?:\s|$)/i.test(text)) {
|
|
676
|
-
return text;
|
|
677
|
-
}
|
|
678
|
-
return extractSourceUrlFromCurl(text) || text;
|
|
679
|
-
}
|
|
680
|
-
|
|
681
762
|
function setObjectPath(root, path, value) {
|
|
682
763
|
const segments = String(path ?? '').split('.').filter(Boolean);
|
|
683
764
|
if (!segments.length || !root) {
|
|
@@ -704,6 +785,23 @@ function coerceProfileValue(value, currentValue) {
|
|
|
704
785
|
return value;
|
|
705
786
|
}
|
|
706
787
|
|
|
788
|
+
/** Source fields that are always numbers, whatever the draft already held. */
|
|
789
|
+
const SOURCE_NUMERIC_KEYS = new Set([
|
|
790
|
+
'max_iterations',
|
|
791
|
+
'min_iterations',
|
|
792
|
+
'plateau_limit',
|
|
793
|
+
'max_runtime_seconds',
|
|
794
|
+
'max_areas',
|
|
795
|
+
'area_min',
|
|
796
|
+
'area_max'
|
|
797
|
+
]);
|
|
798
|
+
|
|
799
|
+
/** Of those, the ones the runner clamps to at least one. */
|
|
800
|
+
const SOURCE_MINIMUM_ONE_KEYS = new Set([
|
|
801
|
+
'max_iterations',
|
|
802
|
+
'plateau_limit'
|
|
803
|
+
]);
|
|
804
|
+
|
|
707
805
|
function resolveErrorMessage(error) {
|
|
708
806
|
if (error instanceof Error && error.message) {
|
|
709
807
|
return error.message;
|
|
@@ -865,11 +963,7 @@ function resolveActiveSubscriptionUrl() {
|
|
|
865
963
|
return '';
|
|
866
964
|
}
|
|
867
965
|
|
|
868
|
-
|
|
869
|
-
if (format === 'Clash') {
|
|
870
|
-
return baseUrl;
|
|
871
|
-
}
|
|
872
|
-
return `${baseUrl}?format=${encodeURIComponent(format.toLowerCase().replaceAll(' ', '-'))}`;
|
|
966
|
+
return baseUrl;
|
|
873
967
|
}
|
|
874
968
|
|
|
875
969
|
function openSettingsDrawer(section, opener = document.activeElement) {
|
|
@@ -937,7 +1031,7 @@ function buildSettingsDraft(section) {
|
|
|
937
1031
|
}
|
|
938
1032
|
|
|
939
1033
|
if (section === 'sources') return buildSourceIterationDraft(state.profile.sources);
|
|
940
|
-
if (section === 'speed_test') return
|
|
1034
|
+
if (section === 'speed_test') return buildSpeedTestDraft(state.profile.speed_test);
|
|
941
1035
|
if (section === 'availability_targets') return buildAvailabilityTargetDraft(state.profile.availability_targets);
|
|
942
1036
|
if (section === 'deploy') return buildDeployDraft(state.profile.deploy);
|
|
943
1037
|
if (section === 'paths') return structuredClone(state.profile.paths);
|
|
@@ -960,6 +1054,13 @@ async function saveSettingsDrawer() {
|
|
|
960
1054
|
}
|
|
961
1055
|
|
|
962
1056
|
const { section, draft } = state.settingsDrawer;
|
|
1057
|
+
if (section === 'sources') {
|
|
1058
|
+
const problem = validateSourceDraft(draft);
|
|
1059
|
+
if (problem) {
|
|
1060
|
+
showToast({ tone: 'danger', message: problem, durationMs: 4200 });
|
|
1061
|
+
return;
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
963
1064
|
if (section !== 'about') {
|
|
964
1065
|
state.profile[section] = resolveSettingsDraftPayload(section, draft);
|
|
965
1066
|
}
|
|
@@ -985,6 +1086,182 @@ async function saveSettingsDrawer() {
|
|
|
985
1086
|
closeSettingsDrawer({ restoreFocus: true });
|
|
986
1087
|
}
|
|
987
1088
|
|
|
1089
|
+
async function hydrateAccountPool() {
|
|
1090
|
+
state.accountPool.status = 'loading';
|
|
1091
|
+
touchUpdate();
|
|
1092
|
+
renderAccountPoolPanelOnly();
|
|
1093
|
+
if (!window.vpnAutomation?.loadAccounts) {
|
|
1094
|
+
state.accountPool.status = 'unavailable';
|
|
1095
|
+
state.accountPool.error = '未连接账号服务';
|
|
1096
|
+
renderAccountPoolPanelOnly();
|
|
1097
|
+
return false;
|
|
1098
|
+
}
|
|
1099
|
+
try {
|
|
1100
|
+
const snapshot = await window.vpnAutomation.loadAccounts();
|
|
1101
|
+
if (!snapshot?.ok) {
|
|
1102
|
+
throw new Error(snapshot?.error || '账号池读取失败');
|
|
1103
|
+
}
|
|
1104
|
+
state.accountPool.status = 'ready';
|
|
1105
|
+
state.accountPool.stats = snapshot.stats ?? null;
|
|
1106
|
+
state.accountPool.accounts = Array.isArray(snapshot.accounts) ? snapshot.accounts : [];
|
|
1107
|
+
state.accountPool.error = '';
|
|
1108
|
+
} catch (error) {
|
|
1109
|
+
state.accountPool.status = 'error';
|
|
1110
|
+
state.accountPool.error = resolveErrorMessage(error);
|
|
1111
|
+
}
|
|
1112
|
+
touchUpdate();
|
|
1113
|
+
renderAccountPoolPanelOnly();
|
|
1114
|
+
return state.accountPool.status === 'ready';
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
function renderAccountPoolPanelOnly() {
|
|
1118
|
+
// A full re-render, not a patched subtree: the panel shares the page with the
|
|
1119
|
+
// settings drawer, and replacing only the panel left the open drawer's draft
|
|
1120
|
+
// bound to detached nodes.
|
|
1121
|
+
renderAll();
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
function renderAccountPoolRecords() {
|
|
1125
|
+
const holder = document.querySelector('#accountPoolRecords');
|
|
1126
|
+
if (!holder) return;
|
|
1127
|
+
const messages = getMessages(state.language);
|
|
1128
|
+
const viewModel = buildViewModel(state, messages, state.language);
|
|
1129
|
+
holder.innerHTML = buildAccountPoolRecords(viewModel.accountPool);
|
|
1130
|
+
enhanceTables(holder);
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
async function handleAccountPoolAction(button) {
|
|
1134
|
+
const action = button.dataset.accountAction;
|
|
1135
|
+
if (action === 'refresh') {
|
|
1136
|
+
const ok = await hydrateAccountPool();
|
|
1137
|
+
showToast(
|
|
1138
|
+
ok
|
|
1139
|
+
? { tone: 'success', message: '账号池已刷新' }
|
|
1140
|
+
: { tone: 'danger', message: state.accountPool.error || '账号池刷新失败' }
|
|
1141
|
+
);
|
|
1142
|
+
return;
|
|
1143
|
+
}
|
|
1144
|
+
if (action === 'upload') {
|
|
1145
|
+
document.querySelector('#accountPoolFile')?.click();
|
|
1146
|
+
return;
|
|
1147
|
+
}
|
|
1148
|
+
if (action === 'template') {
|
|
1149
|
+
await downloadAccountsTemplate();
|
|
1150
|
+
return;
|
|
1151
|
+
}
|
|
1152
|
+
if (action === 'toggle') {
|
|
1153
|
+
await toggleAccountStatus(Number(button.dataset.accountId));
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
async function downloadAccountsTemplate() {
|
|
1158
|
+
try {
|
|
1159
|
+
let content = '';
|
|
1160
|
+
if (window.vpnAutomation?.accountsTemplate) {
|
|
1161
|
+
const result = await window.vpnAutomation.accountsTemplate();
|
|
1162
|
+
content = String(result?.content ?? '');
|
|
1163
|
+
}
|
|
1164
|
+
if (!content) {
|
|
1165
|
+
throw new Error('模板不可用');
|
|
1166
|
+
}
|
|
1167
|
+
const blob = new Blob([content], { type: 'text/csv;charset=utf-8' });
|
|
1168
|
+
const url = URL.createObjectURL(blob);
|
|
1169
|
+
const anchor = document.createElement('a');
|
|
1170
|
+
anchor.href = url;
|
|
1171
|
+
anchor.download = 'accounts-template.csv';
|
|
1172
|
+
anchor.click();
|
|
1173
|
+
URL.revokeObjectURL(url);
|
|
1174
|
+
showToast({ tone: 'success', message: '模板已下载' });
|
|
1175
|
+
} catch (error) {
|
|
1176
|
+
showToast({ tone: 'danger', message: resolveErrorMessage(error) });
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
async function handleAccountFileSelected(input) {
|
|
1181
|
+
const file = input.files?.[0];
|
|
1182
|
+
if (!file) return;
|
|
1183
|
+
input.value = '';
|
|
1184
|
+
if (file.size > 1024 * 1024) {
|
|
1185
|
+
showToast({ tone: 'danger', message: '文件超过 1MB,请拆分后上传' });
|
|
1186
|
+
return;
|
|
1187
|
+
}
|
|
1188
|
+
state.accountPool.busy = true;
|
|
1189
|
+
state.accountPool.message = '';
|
|
1190
|
+
state.accountPool.issues = [];
|
|
1191
|
+
renderAccountPoolPanelOnly();
|
|
1192
|
+
// Tone of the closing toast follows what actually happened. A rejected import
|
|
1193
|
+
// must not end green just because the reload after it succeeded.
|
|
1194
|
+
let importOk = false;
|
|
1195
|
+
try {
|
|
1196
|
+
const content = await file.text();
|
|
1197
|
+
const result = await window.vpnAutomation.importAccounts(content, 'auto');
|
|
1198
|
+
state.accountPool.issues = Array.isArray(result?.issues) ? result.issues : [];
|
|
1199
|
+
if (!result?.ok) {
|
|
1200
|
+
state.accountPool.message = result?.error === 'no_usable_rows'
|
|
1201
|
+
? '文件里没有可导入的账号'
|
|
1202
|
+
: (result?.error || '导入失败');
|
|
1203
|
+
showToast({ tone: 'danger', message: state.accountPool.message });
|
|
1204
|
+
} else {
|
|
1205
|
+
importOk = true;
|
|
1206
|
+
const summary = `导入完成:新增 ${result.inserted ?? 0},更新 ${result.updated ?? 0},改密 ${result.rotated ?? 0},恢复 ${result.revived ?? 0},未变 ${result.unchanged ?? 0}`;
|
|
1207
|
+
state.accountPool.message = state.accountPool.issues.length ? `${summary};${state.accountPool.issues.length} 行有问题` : summary;
|
|
1208
|
+
showToast({ tone: state.accountPool.issues.length ? 'warning' : 'success', message: summary });
|
|
1209
|
+
appendLog(`[账号池] ${summary}`);
|
|
1210
|
+
}
|
|
1211
|
+
} catch (error) {
|
|
1212
|
+
// The served web UI answers a rejected import with HTTP 400 and the parsed
|
|
1213
|
+
// issues; `web-adapter.js` carries that payload on the error so the line
|
|
1214
|
+
// numbers still reach the panel instead of a bare `request_failed`.
|
|
1215
|
+
const payload = error?.payload;
|
|
1216
|
+
if (payload && typeof payload === 'object') {
|
|
1217
|
+
state.accountPool.issues = Array.isArray(payload.issues) ? payload.issues : [];
|
|
1218
|
+
state.accountPool.message = payload.error === 'no_usable_rows'
|
|
1219
|
+
? '文件里没有可导入的账号'
|
|
1220
|
+
: (payload.error || '导入失败');
|
|
1221
|
+
} else {
|
|
1222
|
+
state.accountPool.message = resolveErrorMessage(error);
|
|
1223
|
+
}
|
|
1224
|
+
showToast({ tone: 'danger', message: state.accountPool.message });
|
|
1225
|
+
} finally {
|
|
1226
|
+
state.accountPool.busy = false;
|
|
1227
|
+
}
|
|
1228
|
+
const reloaded = await hydrateAccountPool();
|
|
1229
|
+
if (!importOk) {
|
|
1230
|
+
// The panel now carries the failure reason, so a second toast would only
|
|
1231
|
+
// stack noise on top of the danger toast that already fired.
|
|
1232
|
+
return;
|
|
1233
|
+
}
|
|
1234
|
+
showToast({
|
|
1235
|
+
tone: state.accountPool.issues.length ? 'warning' : 'success',
|
|
1236
|
+
message: state.accountPool.message || (reloaded ? '账号池已更新' : state.accountPool.error)
|
|
1237
|
+
});
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
async function toggleAccountStatus(id) {
|
|
1241
|
+
const account = (state.accountPool.accounts ?? []).find((item) => item.id === id);
|
|
1242
|
+
if (!account || !window.vpnAutomation?.setAccountStatus) {
|
|
1243
|
+
return;
|
|
1244
|
+
}
|
|
1245
|
+
const nextStatus = account.status === 'active' ? 'disabled' : 'active';
|
|
1246
|
+
state.accountPool.busy = true;
|
|
1247
|
+
renderAccountPoolPanelOnly();
|
|
1248
|
+
try {
|
|
1249
|
+
const result = await window.vpnAutomation.setAccountStatus({ id, status: nextStatus, reason: '停用(界面操作)' });
|
|
1250
|
+
if (!result?.ok) {
|
|
1251
|
+
throw new Error(result?.error || '操作失败');
|
|
1252
|
+
}
|
|
1253
|
+
showToast({
|
|
1254
|
+
tone: nextStatus === 'active' ? 'success' : 'neutral',
|
|
1255
|
+
message: `${account.account_masked} 已${nextStatus === 'active' ? '启用' : '停用'}`
|
|
1256
|
+
});
|
|
1257
|
+
} catch (error) {
|
|
1258
|
+
showToast({ tone: 'danger', message: resolveErrorMessage(error) });
|
|
1259
|
+
} finally {
|
|
1260
|
+
state.accountPool.busy = false;
|
|
1261
|
+
}
|
|
1262
|
+
await hydrateAccountPool();
|
|
1263
|
+
}
|
|
1264
|
+
|
|
988
1265
|
async function saveProfile({ silent = false } = {}) {
|
|
989
1266
|
if (!state.profile) {
|
|
990
1267
|
return;
|
|
@@ -1078,6 +1355,7 @@ async function runPipeline() {
|
|
|
1078
1355
|
state.stageStatus = {};
|
|
1079
1356
|
state.counts = {};
|
|
1080
1357
|
state.sourceCounts = {};
|
|
1358
|
+
state.extractProgress = {};
|
|
1081
1359
|
state.extractDedupedFingerprints = new Set();
|
|
1082
1360
|
state.logEntries = [];
|
|
1083
1361
|
state.artifactDir = '';
|
|
@@ -1128,6 +1406,7 @@ async function retryStage() {
|
|
|
1128
1406
|
state.stageStatus = {};
|
|
1129
1407
|
state.counts = {};
|
|
1130
1408
|
state.sourceCounts = {};
|
|
1409
|
+
state.extractProgress = {};
|
|
1131
1410
|
state.extractDedupedFingerprints = new Set();
|
|
1132
1411
|
state.logEntries = [];
|
|
1133
1412
|
state.artifactDir = '';
|
|
@@ -1174,11 +1453,7 @@ async function retryStage() {
|
|
|
1174
1453
|
|
|
1175
1454
|
function collectRunOptions() {
|
|
1176
1455
|
const optionInputs = document.querySelectorAll('[data-run-option]');
|
|
1177
|
-
const options = {
|
|
1178
|
-
skipDeploy: false,
|
|
1179
|
-
skipVerify: false,
|
|
1180
|
-
saveBeforeRun: true
|
|
1181
|
-
};
|
|
1456
|
+
const options = { ...state.runOptions };
|
|
1182
1457
|
for (const input of optionInputs) {
|
|
1183
1458
|
options[input.dataset.runOption] = Boolean(input.checked);
|
|
1184
1459
|
}
|
|
@@ -1315,6 +1590,16 @@ function handlePipelineEvent(event, options = {}) {
|
|
|
1315
1590
|
return;
|
|
1316
1591
|
}
|
|
1317
1592
|
|
|
1593
|
+
if (event.type === 'extract_substage') {
|
|
1594
|
+
if (!['apk_download', 'apk_analysis', 'domain_discovery'].includes(event.substage)
|
|
1595
|
+
|| !['running', 'success', 'failed', 'cached'].includes(event.status)) return;
|
|
1596
|
+
const key = [event.source_name, event.platform, event.substage].join(':');
|
|
1597
|
+
state.extractProgress[key] = { source: String(event.source_name ?? ''), platform: String(event.platform ?? ''), substage: event.substage, status: event.status };
|
|
1598
|
+
touchUpdate();
|
|
1599
|
+
renderRuntimeOnly({ chrome: false });
|
|
1600
|
+
return;
|
|
1601
|
+
}
|
|
1602
|
+
|
|
1318
1603
|
if (event.type === 'stage') {
|
|
1319
1604
|
state.stageStatus[event.stage] = normalizeStageStatus(event.status);
|
|
1320
1605
|
appendLog(`[stage] ${event.stage} ${event.status}`, {
|
|
@@ -1488,6 +1773,7 @@ function resetRunScopedProgress() {
|
|
|
1488
1773
|
state.stageStatus = {};
|
|
1489
1774
|
state.counts = {};
|
|
1490
1775
|
state.sourceCounts = {};
|
|
1776
|
+
state.extractProgress = {};
|
|
1491
1777
|
state.extractDedupedFingerprints = new Set();
|
|
1492
1778
|
state.outputFiles = [];
|
|
1493
1779
|
state.nodeRows = [];
|
|
@@ -1645,7 +1931,12 @@ async function hydrateRetryArtifacts() {
|
|
|
1645
1931
|
return;
|
|
1646
1932
|
}
|
|
1647
1933
|
hydrateRetryArtifactState(result.items ?? []);
|
|
1934
|
+
state.activity = Array.isArray(result.activity) ? result.activity : [];
|
|
1935
|
+
state.activityUnavailable = Number(result.activityUnavailable ?? 0);
|
|
1648
1936
|
touchUpdate();
|
|
1937
|
+
// Completion refreshes history asynchronously, after the last pipeline
|
|
1938
|
+
// event. Update the visible calendar without requiring a page switch.
|
|
1939
|
+
renderRuntimeOnly({ chrome: false });
|
|
1649
1940
|
} catch (error) {
|
|
1650
1941
|
appendLog(formatMessage(getMessages(state.language).openFailed, { error: error.message }));
|
|
1651
1942
|
}
|
|
@@ -1703,13 +1994,8 @@ function renderActiveRuntimeSections(viewModel) {
|
|
|
1703
1994
|
const anchorViewportTop = anchor?.getBoundingClientRect().top;
|
|
1704
1995
|
logCenter.innerHTML = buildLogCenterMarkup(viewModel);
|
|
1705
1996
|
const workspace = logCenter.closest('#logsWorkspace');
|
|
1706
|
-
workspace?.
|
|
1707
|
-
if (
|
|
1708
|
-
logCenter.insertAdjacentHTML('beforebegin', `<button class="btn btn-primary small log-jump-latest" data-log-jump-latest type="button">回到底部 · ${state.logView.unseenCount} 条新消息</button>`);
|
|
1709
|
-
}
|
|
1710
|
-
if (state.logView.clearedSnapshot) {
|
|
1711
|
-
logCenter.insertAdjacentHTML('beforebegin', '<button class="btn btn-secondary small log-undo-clear" data-log-undo-clear type="button">撤销清空</button>');
|
|
1712
|
-
}
|
|
1997
|
+
const stateActions = workspace?.querySelector('.log-state-actions');
|
|
1998
|
+
if (stateActions) stateActions.innerHTML = buildLogStateActions(viewModel);
|
|
1713
1999
|
if (state.logView.follow) {
|
|
1714
2000
|
logCenter.scrollTop = logCenter.scrollHeight;
|
|
1715
2001
|
} else if (anchorId) {
|
|
@@ -1722,14 +2008,33 @@ function renderActiveRuntimeSections(viewModel) {
|
|
|
1722
2008
|
|
|
1723
2009
|
const stageProgress = document.querySelector('#runsStageProgress');
|
|
1724
2010
|
if (stageProgress) {
|
|
2011
|
+
const scrollTop = stageProgress.querySelector('.timeline')?.scrollTop ?? 0;
|
|
1725
2012
|
stageProgress.outerHTML = buildRunsStageProgressMarkup(viewModel);
|
|
2013
|
+
document.querySelector('#runsStageProgress .timeline').scrollTop = scrollTop;
|
|
1726
2014
|
}
|
|
1727
2015
|
|
|
1728
2016
|
const currentStage = document.querySelector('#runsCurrentStage');
|
|
1729
2017
|
if (currentStage) {
|
|
1730
|
-
|
|
2018
|
+
// Preserve the focused run controls while live events refresh details.
|
|
2019
|
+
const template = document.createElement('template');
|
|
2020
|
+
template.innerHTML = buildRunsCurrentStageMarkup(viewModel);
|
|
2021
|
+
currentStage.querySelector('[data-current-stage-body]').replaceWith(template.content.querySelector('[data-current-stage-body]'));
|
|
2022
|
+
currentStage.querySelector('.panel-headline > .badge').replaceWith(template.content.querySelector('.panel-headline > .badge'));
|
|
2023
|
+
}
|
|
2024
|
+
|
|
2025
|
+
const hero = document.querySelector('.status-hero-card');
|
|
2026
|
+
if (hero) {
|
|
2027
|
+
const summary = resolveDashboardRunSummary(viewModel);
|
|
2028
|
+
const copy = hero.querySelector('.dashboard-state-copy');
|
|
2029
|
+
if (copy.querySelector('h3')?.textContent !== viewModel.currentStatusLabel
|
|
2030
|
+
|| Boolean(copy.querySelector('p')) !== summary.failed) {
|
|
2031
|
+
copy.innerHTML = buildDashboardStateMarkup(viewModel);
|
|
2032
|
+
}
|
|
2033
|
+
hero.classList.toggle('is-running', summary.isRunning);
|
|
2034
|
+
hero.dataset.tone = viewModel.runStateTone;
|
|
1731
2035
|
}
|
|
1732
2036
|
|
|
2037
|
+
renderAnalytics(document.querySelector('#dashboardAnalytics'), viewModel);
|
|
1733
2038
|
const dashboardMetrics = document.querySelector('#dashboardMetricsPanel');
|
|
1734
2039
|
if (dashboardMetrics) {
|
|
1735
2040
|
dashboardMetrics.outerHTML = buildDashboardMetricsMarkup(viewModel);
|