@swimmingliu/autovpn 1.4.2 → 1.5.0

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.
@@ -0,0 +1,1332 @@
1
+ import { getMessages, formatMessage } from './i18n.js';
2
+ import { resolveRunControlState } from './state.js';
3
+ import {
4
+ addAvailabilityTargetDraft,
5
+ applyAvailabilityTargetDraft,
6
+ applySourceIterationDraft,
7
+ buildAvailabilityTargetDraft,
8
+ buildPageMarkup,
9
+ buildDashboardMetricsMarkup,
10
+ buildLogCenterMarkup,
11
+ buildRunsCurrentStageMarkup,
12
+ buildRunsStageProgressMarkup,
13
+ buildSourceIterationDraft,
14
+ buildSidebarNav,
15
+ buildViewModel,
16
+ buildTopbarActions,
17
+ removeAvailabilityTargetDraft,
18
+ classifyLogEntry,
19
+ escapeHtml,
20
+ extractSourceUrlFromCurl,
21
+ filterLogEntries
22
+ } from './views.js';
23
+
24
+ const demoProfile = {
25
+ sources: {
26
+ leiting: {
27
+ url: 'https://capture-1.vpn.example/api/v1/client/subscribe',
28
+ key: 'lt-demo-key',
29
+ enabled: true
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
+ }
51
+ },
52
+ speed_test: {
53
+ min_download_mb_s: 1.0,
54
+ timeout_seconds: 20,
55
+ concurrency: 3,
56
+ urls: [
57
+ 'https://speed-1.vpn.example/1mb.dat',
58
+ 'https://speed-2.vpn.example/1mb.dat',
59
+ 'https://speed-3.vpn.example/1mb.dat'
60
+ ]
61
+ },
62
+ availability_targets: {
63
+ gemini: {
64
+ url: 'https://gemini.google.com',
65
+ enabled: true
66
+ },
67
+ chatgpt_ios: {
68
+ url: 'https://ios.chat.openai.com/',
69
+ enabled: true
70
+ },
71
+ chatgpt_web: {
72
+ url: 'https://api.openai.com/compliance/cookie_requirements',
73
+ enabled: true
74
+ },
75
+ claude: {
76
+ url: 'https://claude.ai/cdn-cgi/trace',
77
+ enabled: true
78
+ }
79
+ },
80
+ deploy: {
81
+ project_name: 'sub-nodes',
82
+ pages_project_url: 'https://sub-nodes.pages.dev',
83
+ subscription_url: 'https://vpn.example.top/179ba8dd-3854-4747-b853-fc1868ef3937',
84
+ verify_subscription_url: 'https://www.swimmingliu.online/sub?token=8410fb43eb2176497f5beafc0c39f5bc',
85
+ cloudflare_api_token: '',
86
+ pages_secret_admin: 'swimmingliu'
87
+ },
88
+ paths: {
89
+ project_root: '/Users/user/vpn-sub',
90
+ artifacts_root: '/Users/user/vpn-sub/artifacts'
91
+ },
92
+ workspace: {
93
+ project_root: '/Users/user/vpn-sub',
94
+ artifacts_root: '/Users/user/vpn-sub/artifacts',
95
+ state_root: '/Users/user/vpn-sub/state',
96
+ profile_path: '/Users/user/vpn-sub/state/profile.toml'
97
+ }
98
+ };
99
+
100
+ const state = {
101
+ profile: null,
102
+ savedProfile: null,
103
+ unsubscribe: null,
104
+ stageStatus: {},
105
+ counts: {},
106
+ sourceCounts: {},
107
+ language: 'zh-CN',
108
+ activePage: 'dashboard',
109
+ subtabs: {},
110
+ subscriptionFormat: 'Clash',
111
+ logFilter: '全部',
112
+ settingsDrawer: null,
113
+ isDemo: false,
114
+ runState: 'idle',
115
+ runResult: 'idle',
116
+ logEntries: [],
117
+ artifactDir: '',
118
+ retryArtifacts: [],
119
+ selectedRetryArtifactDir: '',
120
+ selectedRetryStage: '',
121
+ retryContext: {},
122
+ deployment: {},
123
+ outputFiles: [],
124
+ nodeRows: [],
125
+ toast: null,
126
+ qrDataUrl: '',
127
+ runStartedAt: null,
128
+ lastUpdateAt: null,
129
+ modalTransform: ''
130
+ };
131
+
132
+ const elements = {
133
+ sidebarTitle: document.querySelector('#sidebarTitle'),
134
+ sidebarVersion: document.querySelector('#sidebarVersion'),
135
+ sidebarNav: document.querySelector('#sidebarNav'),
136
+ pageTitle: document.querySelector('#pageTitle'),
137
+ pageSubtitle: document.querySelector('#pageSubtitle'),
138
+ runStateBadge: document.querySelector('#runStateBadge'),
139
+ pageActions: document.querySelector('#pageActions'),
140
+ pageContent: document.querySelector('#pageContent'),
141
+ toastRoot: document.querySelector('#toastRoot')
142
+ };
143
+
144
+ let toastTimer = null;
145
+
146
+ async function bootstrap() {
147
+ state.language = 'zh-CN';
148
+ renderAll();
149
+ bindActions();
150
+
151
+ if (!window.vpnAutomation) {
152
+ state.isDemo = true;
153
+ state.profile = structuredClone(demoProfile);
154
+ state.savedProfile = structuredClone(demoProfile);
155
+ touchUpdate();
156
+ renderAll();
157
+ appendLog(getMessages(state.language).demoMode);
158
+ return;
159
+ }
160
+
161
+ state.isDemo = false;
162
+ const loadedProfile = await window.vpnAutomation.loadProfile();
163
+ state.profile = loadedProfile;
164
+ state.savedProfile = structuredClone(loadedProfile);
165
+ touchUpdate();
166
+ await refreshQrCode();
167
+ await hydrateRetryArtifacts();
168
+ await hydrateLatestArtifact();
169
+ renderAll();
170
+ state.unsubscribe = window.vpnAutomation.onPipelineEvent(handlePipelineEvent);
171
+ }
172
+
173
+ function bindActions() {
174
+ document.addEventListener('click', handleDocumentClick);
175
+ document.addEventListener('input', handleDocumentInput);
176
+ document.addEventListener('change', handleDocumentInput);
177
+
178
+ document.addEventListener('mousedown', (e) => {
179
+ const header = e.target.closest('.settings-drawer-head');
180
+ if (!header) return;
181
+
182
+ const panel = header.closest('.settings-drawer-panel');
183
+ if (!panel) return;
184
+
185
+ e.preventDefault();
186
+
187
+ let startX = e.clientX;
188
+ let startY = e.clientY;
189
+
190
+ const style = window.getComputedStyle(panel);
191
+ // Parse matrix to get current X/Y translate
192
+ const matrix = new DOMMatrixReadOnly(style.transform);
193
+ let currentX = matrix.m41;
194
+ let currentY = matrix.m42;
195
+
196
+ const onMouseMove = (moveEvent) => {
197
+ const dx = moveEvent.clientX - startX;
198
+ const dy = moveEvent.clientY - startY;
199
+ const newTransform = `scale(1) translate(${currentX + dx}px, ${currentY + dy}px)`;
200
+ panel.style.transform = newTransform;
201
+ state.modalTransform = newTransform;
202
+ };
203
+
204
+ const onMouseUp = () => {
205
+ document.removeEventListener('mousemove', onMouseMove);
206
+ document.removeEventListener('mouseup', onMouseUp);
207
+ };
208
+
209
+ document.addEventListener('mousemove', onMouseMove);
210
+ document.addEventListener('mouseup', onMouseUp);
211
+ });
212
+ }
213
+
214
+ function renderAll() {
215
+ const messages = getMessages(state.language);
216
+ const viewModel = buildViewModel(state, messages, state.language);
217
+ document.documentElement.lang = state.language;
218
+ document.title = messages.appTitle;
219
+ document.body.dataset.page = state.activePage;
220
+
221
+ renderChrome(messages, viewModel);
222
+ elements.pageContent.innerHTML = buildPageMarkup(
223
+ state.activePage,
224
+ viewModel,
225
+ messages,
226
+ state.language,
227
+ state.subtabs
228
+ );
229
+ renderToast();
230
+ }
231
+
232
+ function renderChrome(messages, viewModel) {
233
+ const controlState = resolveRunControlState(state.runState);
234
+ elements.sidebarTitle.textContent = messages.sidebarTitle;
235
+ elements.sidebarVersion.textContent = messages.sidebarVersion;
236
+ elements.pageTitle.textContent = messages.pageTitles[state.activePage];
237
+ elements.pageSubtitle.textContent = messages.pageSubtitles[state.activePage];
238
+ elements.runStateBadge.textContent = messages.runStateLabels[state.runState] ?? messages.runStateLabels.idle;
239
+ elements.runStateBadge.className = `badge ${runTone()}`;
240
+ elements.sidebarNav.innerHTML = buildSidebarNav(messages, state.activePage);
241
+ elements.pageActions.innerHTML = buildTopbarActions(state.activePage, viewModel, messages, {
242
+ runDisabled: controlState.runDisabled,
243
+ stopDisabled: controlState.stopDisabled,
244
+ runLabel: resolveRunButtonLabel(messages),
245
+ stopLabel: messages.stopButton,
246
+ saveLabel: messages.saveButton
247
+ });
248
+ }
249
+
250
+ function resolveRunButtonLabel(messages) {
251
+ if (state.runState === 'running') {
252
+ return messages.runButtonRunning;
253
+ }
254
+ if (state.runState === 'stopping') {
255
+ return messages.runButtonStopping;
256
+ }
257
+ return messages.runButton;
258
+ }
259
+
260
+ function runTone() {
261
+ if (state.runState === 'running') {
262
+ return 'success';
263
+ }
264
+ if (state.runState === 'stopping') {
265
+ return 'warning';
266
+ }
267
+ return 'neutral';
268
+ }
269
+
270
+ function handleDocumentClick(event) {
271
+ const navButton = event.target.closest('[data-page-target]');
272
+ if (navButton) {
273
+ state.activePage = navButton.dataset.pageTarget;
274
+ renderAll();
275
+ return;
276
+ }
277
+
278
+ const subtab = event.target.closest('[data-subtab-page]');
279
+ if (subtab) {
280
+ state.subtabs[subtab.dataset.subtabPage] = subtab.dataset.subtab;
281
+ renderAll();
282
+ return;
283
+ }
284
+
285
+ const formatButton = event.target.closest('[data-subscription-format]');
286
+ if (formatButton) {
287
+ state.subscriptionFormat = formatButton.dataset.subscriptionFormat;
288
+ refreshQrCode();
289
+ renderAll();
290
+ return;
291
+ }
292
+
293
+ const logFilterButton = event.target.closest('[data-log-filter]');
294
+ if (logFilterButton) {
295
+ state.logFilter = logFilterButton.dataset.logFilter;
296
+ renderAll();
297
+ return;
298
+ }
299
+
300
+ const copyButton = event.target.closest('[data-copy-text]');
301
+ if (copyButton) {
302
+ copyText(copyButton.dataset.copyText);
303
+ return;
304
+ }
305
+
306
+ const runAction = event.target.closest('[data-run-action]');
307
+ if (runAction?.dataset.runAction === 'start') {
308
+ runPipeline();
309
+ return;
310
+ }
311
+ if (runAction?.dataset.runAction === 'stop') {
312
+ stopPipeline();
313
+ return;
314
+ }
315
+
316
+ const openUrlButton = event.target.closest('[data-open-url]');
317
+ if (openUrlButton) {
318
+ openUrl(openUrlButton.dataset.openUrl);
319
+ return;
320
+ }
321
+
322
+ const action = event.target.closest('[data-action]');
323
+ if (action?.dataset.action === 'open-settings') {
324
+ state.activePage = 'settings';
325
+ renderAll();
326
+ return;
327
+ }
328
+ if (action?.dataset.action === 'save-profile') {
329
+ saveProfile();
330
+ return;
331
+ }
332
+ if (action?.dataset.action === 'open-artifact-dir') {
333
+ openArtifactDir();
334
+ return;
335
+ }
336
+ if (action?.dataset.action === 'copy-nodes') {
337
+ const messages = getMessages(state.language);
338
+ const nodeLinks = (state.nodeRows ?? []).map((row) => row.link || row.name).filter(Boolean);
339
+ copyText(nodeLinks.join('\n'), {
340
+ emptyToast: messages.nothingToCopyMessage,
341
+ successToast: formatMessage(messages.copiedNodesToastMessage, { count: nodeLinks.length }),
342
+ successLog: formatMessage(messages.copiedNodesLogMessage, { count: nodeLinks.length }),
343
+ failureToast: formatMessage(messages.copyFailedToastMessage, { error: '{error}' }),
344
+ failureLog: formatMessage(messages.copyFailedLogMessage, { error: '{error}' })
345
+ });
346
+ return;
347
+ }
348
+ if (action?.dataset.action === 'retry-stage') {
349
+ retryStage();
350
+ return;
351
+ }
352
+ if (action?.dataset.action === 'copy-log') {
353
+ copyText(resolveVisibleLogEntries().map((entry) => entry.line).join('\n'));
354
+ return;
355
+ }
356
+ if (action?.dataset.action === 'clear-log') {
357
+ state.logEntries = [];
358
+ renderAll();
359
+ return;
360
+ }
361
+ if (action?.dataset.action === 'open-log-file') {
362
+ openCurrentLogFile();
363
+ return;
364
+ }
365
+
366
+ const settingsCard = event.target.closest('[data-settings-card]');
367
+ if (settingsCard) {
368
+ openSettingsDrawer(settingsCard.dataset.settingsCard);
369
+ return;
370
+ }
371
+
372
+ const availabilityAction = event.target.closest('[data-availability-action]');
373
+ if (availabilityAction) {
374
+ handleAvailabilityTargetAction(availabilityAction);
375
+ return;
376
+ }
377
+
378
+ if (event.target.closest('[data-drawer-dismiss="backdrop"]')) {
379
+ state.settingsDrawer = null;
380
+ state.modalTransform = '';
381
+ renderAll();
382
+ return;
383
+ }
384
+
385
+ if (event.target.closest('[data-drawer-close="cancel"]')) {
386
+ state.settingsDrawer = null;
387
+ state.modalTransform = '';
388
+ renderAll();
389
+ return;
390
+ }
391
+
392
+ if (event.target.closest('[data-drawer-save="save"]')) {
393
+ saveSettingsDrawer();
394
+ }
395
+ }
396
+
397
+ function handleDocumentInput(event) {
398
+ const target = event.target;
399
+
400
+ if (!state.profile) {
401
+ return;
402
+ }
403
+
404
+ if (target.matches('[data-run-retry-artifact]')) {
405
+ state.selectedRetryArtifactDir = target.value;
406
+ const selectedArtifact = (state.retryArtifacts ?? []).find((item) => item.artifact_dir === target.value);
407
+ state.selectedRetryStage = resolveDefaultRetryStage(selectedArtifact);
408
+ touchUpdate();
409
+ renderAll();
410
+ return;
411
+ }
412
+
413
+ if (target.matches('[data-run-retry-stage]')) {
414
+ state.selectedRetryStage = target.value;
415
+ touchUpdate();
416
+ renderAll();
417
+ return;
418
+ }
419
+
420
+ if (target.matches('[data-drawer-source][data-drawer-key]')) {
421
+ if (!state.settingsDrawer?.draft) {
422
+ return;
423
+ }
424
+ const sourceDraft = resolveDrawerSourceDraft();
425
+ const sourceName = target.dataset.drawerSource;
426
+ const key = target.dataset.drawerKey;
427
+ if (!sourceDraft?.[sourceName]) {
428
+ return;
429
+ }
430
+ const rawValue = target.type === 'checkbox' ? target.checked : target.value.trim();
431
+ const normalizedValue = key === 'url' && typeof rawValue === 'string'
432
+ ? normalizeSourceUrlInput(rawValue)
433
+ : rawValue;
434
+ if (target.type !== 'checkbox' && normalizedValue !== target.value) {
435
+ target.value = normalizedValue;
436
+ }
437
+ sourceDraft[sourceName][key] =
438
+ target.type === 'checkbox'
439
+ ? normalizedValue
440
+ : coerceProfileValue(normalizedValue, sourceDraft[sourceName][key]);
441
+ return;
442
+ }
443
+
444
+ if (target.matches('[data-availability-index][data-availability-key]')) {
445
+ updateAvailabilityTargetDraft(target);
446
+ return;
447
+ }
448
+
449
+ if (target.matches('[data-drawer-path]')) {
450
+ handleDeployDrawerInput(target.dataset.drawerPath, target.value.trim(), target);
451
+ return;
452
+ }
453
+
454
+ if (target.matches('[data-source][data-key]')) {
455
+ const sourceName = target.dataset.source;
456
+ const key = target.dataset.key;
457
+ if (!state.profile.sources[sourceName]) {
458
+ return;
459
+ }
460
+ state.profile.sources[sourceName][key] =
461
+ target.type === 'checkbox' ? target.checked : coerceProfileValue(target.value.trim(), state.profile.sources[sourceName][key]);
462
+ renderAll();
463
+ return;
464
+ }
465
+
466
+ if (target.matches('[data-profile-path]')) {
467
+ setProfilePath(target.dataset.profilePath, target.value.trim());
468
+ if (target.dataset.profilePath === 'deploy.subscription_url') {
469
+ refreshQrCode();
470
+ }
471
+ renderAll();
472
+ }
473
+ }
474
+
475
+ function setProfilePath(path, value) {
476
+ if (!state.profile) {
477
+ return;
478
+ }
479
+ setObjectPath(state.profile, path, value);
480
+ }
481
+
482
+ function normalizeDrawerPath(section, path) {
483
+ return String(path ?? '').startsWith(`${section}.`)
484
+ ? String(path).slice(section.length + 1)
485
+ : String(path ?? '');
486
+ }
487
+
488
+ function setDrawerPath(path, value) {
489
+ if (!state.settingsDrawer?.draft) {
490
+ return;
491
+ }
492
+
493
+ const section = state.settingsDrawer.section;
494
+ const normalizedPath = normalizeDrawerPath(section, path);
495
+ setObjectPath(state.settingsDrawer.draft, normalizedPath, value);
496
+ }
497
+
498
+ function derivePagesProjectUrl(projectName) {
499
+ const normalizedProjectName = String(projectName ?? '').trim();
500
+ if (!normalizedProjectName) {
501
+ return '';
502
+ }
503
+ return `https://${normalizedProjectName}.pages.dev`;
504
+ }
505
+
506
+ function buildDeployDraft(deploy = {}) {
507
+ const draft = structuredClone(deploy);
508
+ const derivedUrl = derivePagesProjectUrl(draft.project_name);
509
+ if (!draft.pages_project_url && derivedUrl) {
510
+ draft.pages_project_url = derivedUrl;
511
+ }
512
+ draft.__autoLinkedPagesProjectUrl = draft.pages_project_url === derivePagesProjectUrl(draft.project_name);
513
+ return draft;
514
+ }
515
+
516
+ function sanitizeDeployDraft(draft = {}) {
517
+ const sanitizedDraft = structuredClone(draft);
518
+ delete sanitizedDraft.__autoLinkedPagesProjectUrl;
519
+ return sanitizedDraft;
520
+ }
521
+
522
+ function handleDeployDrawerInput(path, value, target) {
523
+ if (state.settingsDrawer?.section !== 'deploy' || !state.settingsDrawer.draft) {
524
+ setDrawerPath(path, value);
525
+ return;
526
+ }
527
+
528
+ const normalizedPath = normalizeDrawerPath('deploy', path);
529
+ if (normalizedPath === 'project_name') {
530
+ state.settingsDrawer.draft.project_name = value;
531
+ if (state.settingsDrawer.draft.__autoLinkedPagesProjectUrl) {
532
+ const derivedUrl = derivePagesProjectUrl(value);
533
+ state.settingsDrawer.draft.pages_project_url = derivedUrl;
534
+ const pagesUrlInput = document.querySelector('[data-drawer-path="deploy.pages_project_url"]');
535
+ if (pagesUrlInput && pagesUrlInput !== target) {
536
+ pagesUrlInput.value = derivedUrl;
537
+ }
538
+ }
539
+ return;
540
+ }
541
+
542
+ if (normalizedPath === 'pages_project_url') {
543
+ state.settingsDrawer.draft.pages_project_url = value;
544
+ state.settingsDrawer.draft.__autoLinkedPagesProjectUrl =
545
+ value === derivePagesProjectUrl(state.settingsDrawer.draft.project_name);
546
+ return;
547
+ }
548
+
549
+ setDrawerPath(path, value);
550
+ }
551
+
552
+ function resolveDrawerSourceDraft() {
553
+ if (state.settingsDrawer?.section !== 'sources') {
554
+ return null;
555
+ }
556
+ return state.settingsDrawer.draft?.sources ?? state.settingsDrawer.draft;
557
+ }
558
+
559
+ function normalizeSourceUrlInput(value) {
560
+ const text = String(value ?? '').trim();
561
+ if (!/^curl(?:\s|$)/i.test(text)) {
562
+ return text;
563
+ }
564
+ return extractSourceUrlFromCurl(text) || text;
565
+ }
566
+
567
+ function setObjectPath(root, path, value) {
568
+ const segments = String(path ?? '').split('.').filter(Boolean);
569
+ if (!segments.length || !root) {
570
+ return;
571
+ }
572
+
573
+ let cursor = root;
574
+ for (const segment of segments.slice(0, -1)) {
575
+ if (!cursor[segment]) {
576
+ return;
577
+ }
578
+ cursor = cursor[segment];
579
+ }
580
+
581
+ const key = segments.at(-1);
582
+ cursor[key] = coerceProfileValue(value, cursor[key]);
583
+ }
584
+
585
+ function coerceProfileValue(value, currentValue) {
586
+ if (typeof currentValue === 'number') {
587
+ const parsed = Number(value);
588
+ return Number.isFinite(parsed) ? parsed : currentValue;
589
+ }
590
+ return value;
591
+ }
592
+
593
+ function resolveErrorMessage(error) {
594
+ if (error instanceof Error && error.message) {
595
+ return error.message;
596
+ }
597
+ return String(error ?? 'unknown');
598
+ }
599
+
600
+ async function writeClipboardText(text) {
601
+ if (window.vpnAutomation?.copyText) {
602
+ const result = await window.vpnAutomation.copyText(text);
603
+ if (!result?.ok) {
604
+ throw new Error(result?.error ?? 'clipboard_write_failed');
605
+ }
606
+ return;
607
+ }
608
+ if (navigator.clipboard?.writeText) {
609
+ await navigator.clipboard.writeText(text);
610
+ return;
611
+ }
612
+ throw new Error('clipboard_unavailable');
613
+ }
614
+
615
+ function showToast({ tone = 'neutral', message, durationMs = 2400 }) {
616
+ if (!message) {
617
+ return;
618
+ }
619
+ state.toast = { tone, message };
620
+ renderToast();
621
+ if (toastTimer) {
622
+ clearTimeout(toastTimer);
623
+ }
624
+ toastTimer = window.setTimeout(() => {
625
+ state.toast = null;
626
+ renderToast();
627
+ toastTimer = null;
628
+ }, durationMs);
629
+ }
630
+
631
+ function renderToast() {
632
+ if (!elements.toastRoot) {
633
+ return;
634
+ }
635
+ if (!state.toast?.message) {
636
+ elements.toastRoot.innerHTML = '';
637
+ return;
638
+ }
639
+ elements.toastRoot.innerHTML = `
640
+ <div class="toast ${escapeHtml(state.toast.tone || 'neutral')}" data-toast data-toast-tone="${escapeHtml(state.toast.tone || 'neutral')}">
641
+ ${escapeHtml(state.toast.message)}
642
+ </div>
643
+ `;
644
+ }
645
+
646
+ async function copyText(value, options = {}) {
647
+ const text = String(value ?? '').trim();
648
+ const messages = getMessages(state.language);
649
+ if (!text) {
650
+ showToast({ tone: 'neutral', message: options.emptyToast ?? messages.nothingToCopyMessage });
651
+ return;
652
+ }
653
+
654
+ try {
655
+ await writeClipboardText(text);
656
+ showToast({ tone: 'success', message: options.successToast ?? messages.copiedToastMessage });
657
+ appendLog(options.successLog ?? formatMessage(messages.copiedMessage, { value: text }));
658
+ } catch (error) {
659
+ const detail = resolveErrorMessage(error);
660
+ showToast({
661
+ tone: 'danger',
662
+ message: (options.failureToast ?? formatMessage(messages.copyFailedToastMessage, { error: detail })).replace('{error}', detail)
663
+ });
664
+ appendLog(
665
+ (options.failureLog ?? formatMessage(messages.copyFailedLogMessage, { error: detail })).replace('{error}', detail)
666
+ );
667
+ }
668
+ }
669
+
670
+ async function openUrl(url) {
671
+ const value = String(url ?? '').trim();
672
+ if (!value || !window.vpnAutomation?.openUrl) {
673
+ return;
674
+ }
675
+ try {
676
+ await window.vpnAutomation.openUrl(value);
677
+ } catch (error) {
678
+ appendLog(formatMessage(getMessages(state.language).openFailed, { error: error.message }));
679
+ }
680
+ }
681
+
682
+ async function openArtifactDir() {
683
+ const artifactDir = state.artifactDir || resolveProfilePaths().artifacts_root;
684
+ if (!artifactDir || !window.vpnAutomation?.openPath) {
685
+ return;
686
+ }
687
+ const result = await window.vpnAutomation.openPath(artifactDir);
688
+ if (!result?.ok) {
689
+ appendLog(formatMessage(getMessages(state.language).openFailed, { error: result?.error ?? 'unknown' }));
690
+ return;
691
+ }
692
+ appendLog(formatMessage(getMessages(state.language).openedPathMessage, { value: artifactDir }));
693
+ }
694
+
695
+ async function openCurrentLogFile() {
696
+ const artifactDir = state.artifactDir || resolveProfilePaths().artifacts_root;
697
+ const logPath = artifactDir ? `${artifactDir}/human.log` : '';
698
+ if (!logPath || !window.vpnAutomation?.openPath) {
699
+ appendLog(formatMessage(getMessages(state.language).openFailed, { error: 'log_file_not_found' }));
700
+ return;
701
+ }
702
+
703
+ const result = await window.vpnAutomation.openPath(logPath);
704
+ if (!result?.ok) {
705
+ appendLog(formatMessage(getMessages(state.language).openFailed, { error: result?.error ?? 'unknown' }));
706
+ return;
707
+ }
708
+ appendLog(formatMessage(getMessages(state.language).openedPathMessage, { value: logPath }));
709
+ }
710
+
711
+ async function refreshQrCode() {
712
+ const subscriptionUrl = resolveActiveSubscriptionUrl();
713
+ if (!subscriptionUrl || !window.vpnAutomation?.generateQr) {
714
+ state.qrDataUrl = '';
715
+ return;
716
+ }
717
+
718
+ try {
719
+ const result = await window.vpnAutomation.generateQr(subscriptionUrl);
720
+ state.qrDataUrl = result?.dataUrl ?? '';
721
+ renderAll();
722
+ } catch {
723
+ state.qrDataUrl = '';
724
+ }
725
+ }
726
+
727
+ function resolveActiveSubscriptionUrl() {
728
+ const baseUrl = state.profile?.deploy?.subscription_url ?? '';
729
+ if (!baseUrl) {
730
+ return '';
731
+ }
732
+
733
+ const format = state.subscriptionFormat ?? 'Clash';
734
+ if (format === 'Clash') {
735
+ return baseUrl;
736
+ }
737
+ return `${baseUrl}?format=${encodeURIComponent(format.toLowerCase().replaceAll(' ', '-'))}`;
738
+ }
739
+
740
+ function openSettingsDrawer(section) {
741
+ if (!state.profile) {
742
+ return;
743
+ }
744
+
745
+ const draft = buildSettingsDraft(section);
746
+ if (!draft) {
747
+ return;
748
+ }
749
+
750
+ state.modalTransform = '';
751
+ state.settingsDrawer = { section, draft };
752
+ renderAll();
753
+ }
754
+
755
+ function buildSettingsDraft(section) {
756
+ if (!state.profile) {
757
+ return null;
758
+ }
759
+
760
+ if (section === 'sources') return buildSourceIterationDraft(state.profile.sources);
761
+ if (section === 'speed_test') return structuredClone(state.profile.speed_test);
762
+ if (section === 'availability_targets') return buildAvailabilityTargetDraft(state.profile.availability_targets);
763
+ if (section === 'deploy') return buildDeployDraft(state.profile.deploy);
764
+ if (section === 'paths') return structuredClone(state.profile.paths);
765
+ if (section === 'about') return { version: getMessages(state.language).sidebarVersion };
766
+ return null;
767
+ }
768
+
769
+ function resolveProfilePaths(profile = state.profile) {
770
+ return {
771
+ project_root: profile?.paths?.project_root ?? profile?.workspace?.project_root ?? '',
772
+ artifacts_root: profile?.paths?.artifacts_root ?? profile?.workspace?.artifacts_root ?? '',
773
+ state_root: profile?.paths?.state_root ?? profile?.workspace?.state_root ?? '',
774
+ profile_path: profile?.paths?.profile_path ?? profile?.workspace?.profile_path ?? ''
775
+ };
776
+ }
777
+
778
+ async function saveSettingsDrawer() {
779
+ if (!state.settingsDrawer || !state.profile) {
780
+ return;
781
+ }
782
+
783
+ const { section, draft } = state.settingsDrawer;
784
+ if (section !== 'about') {
785
+ state.profile[section] = resolveSettingsDraftPayload(section, draft);
786
+ }
787
+ state.settingsDrawer = null;
788
+ state.modalTransform = '';
789
+ touchUpdate();
790
+ if (section === 'deploy') {
791
+ await refreshQrCode();
792
+ }
793
+ if (section !== 'about') {
794
+ await saveProfile({ silent: true });
795
+ if (section === 'deploy') {
796
+ showToast({
797
+ tone: 'success',
798
+ message: `部署配置已保存:${state.profile.deploy.project_name} · ${state.profile.deploy.pages_project_url}`,
799
+ durationMs: 3200
800
+ });
801
+ appendLog(
802
+ `[settings] deploy saved project=${state.profile.deploy.project_name} url=${state.profile.deploy.pages_project_url}`
803
+ );
804
+ }
805
+ return;
806
+ }
807
+ renderAll();
808
+ }
809
+
810
+ async function saveProfile({ silent = false } = {}) {
811
+ if (!state.profile) {
812
+ return;
813
+ }
814
+
815
+ if (window.vpnAutomation) {
816
+ await window.vpnAutomation.saveProfile(state.profile);
817
+ }
818
+ state.savedProfile = structuredClone(state.profile);
819
+ touchUpdate();
820
+ renderAll();
821
+ if (!silent) {
822
+ appendLog(getMessages(state.language).profileSaved);
823
+ }
824
+ }
825
+
826
+ function resolveSettingsDraftPayload(section, draft) {
827
+ if (section === 'sources') {
828
+ return applySourceIterationDraft(draft.sources, draft);
829
+ }
830
+ if (section === 'availability_targets') {
831
+ return applyAvailabilityTargetDraft(draft);
832
+ }
833
+ if (section === 'deploy') {
834
+ return sanitizeDeployDraft(draft);
835
+ }
836
+ return structuredClone(draft);
837
+ }
838
+
839
+ function handleAvailabilityTargetAction(action) {
840
+ if (state.settingsDrawer?.section !== 'availability_targets') {
841
+ return;
842
+ }
843
+ if (action.dataset.availabilityAction === 'add') {
844
+ addAvailabilityTargetDraft(state.settingsDrawer.draft, 'custom');
845
+ renderAll();
846
+ return;
847
+ }
848
+ if (action.dataset.availabilityAction === 'remove') {
849
+ removeAvailabilityTargetDraft(state.settingsDrawer.draft, action.dataset.availabilityIndex);
850
+ renderAll();
851
+ }
852
+ }
853
+
854
+ function updateAvailabilityTargetDraft(target) {
855
+ if (state.settingsDrawer?.section !== 'availability_targets') {
856
+ return;
857
+ }
858
+ const index = Number(target.dataset.availabilityIndex);
859
+ const key = target.dataset.availabilityKey;
860
+ const row = state.settingsDrawer.draft?.targets?.[index];
861
+ if (!row || !key) {
862
+ return;
863
+ }
864
+ row[key] = target.type === 'checkbox' ? target.checked : target.value;
865
+ }
866
+
867
+ async function exportLogs() {
868
+ if (!state.logEntries.length) {
869
+ return;
870
+ }
871
+
872
+ const payload = resolveVisibleLogEntries().map((entry) => entry.line).join('\n');
873
+ if (window.vpnAutomation?.exportLogs) {
874
+ const result = await window.vpnAutomation.exportLogs(payload);
875
+ if (result?.path) {
876
+ appendLog(formatMessage(getMessages(state.language).exportedLogsMessage, { value: result.path }));
877
+ return;
878
+ }
879
+ }
880
+
881
+ await copyText(payload);
882
+ }
883
+
884
+ async function runPipeline() {
885
+ if (state.runState !== 'idle' || !state.profile) {
886
+ return;
887
+ }
888
+
889
+ const messages = getMessages(state.language);
890
+ const runOptions = collectRunOptions();
891
+ const preflightError = validateRunPreflight(runOptions);
892
+ if (preflightError) {
893
+ showToast({ tone: 'danger', message: preflightError, durationMs: 5200 });
894
+ appendLog(`[界面] ${preflightError}`, { level: 'error' });
895
+ return;
896
+ }
897
+
898
+ state.runState = 'running';
899
+ state.runResult = 'running';
900
+ state.stageStatus = {};
901
+ state.counts = {};
902
+ state.sourceCounts = {};
903
+ state.logEntries = [];
904
+ state.artifactDir = '';
905
+ state.outputFiles = [];
906
+ state.nodeRows = [];
907
+ state.selectedRetryStage = '';
908
+ state.runStartedAt = Date.now();
909
+ touchUpdate();
910
+ renderAll();
911
+ appendLog(messages.pipelineStarted);
912
+
913
+ if (runOptions.saveBeforeRun) {
914
+ await saveProfile({ silent: true });
915
+ }
916
+
917
+ if (!window.vpnAutomation) {
918
+ state.runState = 'idle';
919
+ state.runResult = 'demo';
920
+ touchUpdate();
921
+ renderAll();
922
+ appendLog(formatMessage(messages.pipelineFinished, { code: 'demo' }));
923
+ return;
924
+ }
925
+
926
+ try {
927
+ const result = await window.vpnAutomation.runPipeline(runOptions);
928
+ if (!result?.ok) {
929
+ finishRun(result);
930
+ }
931
+ } catch (error) {
932
+ state.runState = 'idle';
933
+ state.runResult = 'failed';
934
+ state.runStartedAt = null;
935
+ touchUpdate();
936
+ renderAll();
937
+ appendLog(formatMessage(messages.pipelineFailed, { error: error.message }));
938
+ }
939
+ }
940
+
941
+ async function retryStage() {
942
+ if (state.runState !== 'idle' || !state.profile || !state.selectedRetryArtifactDir || !state.selectedRetryStage) {
943
+ return;
944
+ }
945
+
946
+ const messages = getMessages(state.language);
947
+ state.runState = 'running';
948
+ state.runResult = 'running';
949
+ state.stageStatus = {};
950
+ state.counts = {};
951
+ state.sourceCounts = {};
952
+ state.logEntries = [];
953
+ state.artifactDir = '';
954
+ state.outputFiles = [];
955
+ state.nodeRows = [];
956
+ state.runStartedAt = Date.now();
957
+ touchUpdate();
958
+ renderAll();
959
+ appendLog(`[retry] artifact=${state.selectedRetryArtifactDir} stage=${state.selectedRetryStage}`);
960
+
961
+ const runOptions = collectRunOptions();
962
+ if (runOptions.saveBeforeRun) {
963
+ await saveProfile({ silent: true });
964
+ }
965
+
966
+ if (!window.vpnAutomation?.retryStage) {
967
+ state.runState = 'idle';
968
+ state.runResult = 'failed';
969
+ state.runStartedAt = null;
970
+ touchUpdate();
971
+ renderAll();
972
+ appendLog(formatMessage(messages.pipelineFailed, { error: 'retry bridge unavailable' }));
973
+ return;
974
+ }
975
+
976
+ try {
977
+ const result = await window.vpnAutomation.retryStage({
978
+ artifactDir: state.selectedRetryArtifactDir,
979
+ stage: state.selectedRetryStage,
980
+ saveBeforeRun: runOptions.saveBeforeRun
981
+ });
982
+ if (!result?.ok) {
983
+ finishRun(result);
984
+ }
985
+ } catch (error) {
986
+ state.runState = 'idle';
987
+ state.runResult = 'failed';
988
+ state.runStartedAt = null;
989
+ touchUpdate();
990
+ renderAll();
991
+ appendLog(formatMessage(messages.pipelineFailed, { error: error.message }));
992
+ }
993
+ }
994
+
995
+ function collectRunOptions() {
996
+ const optionInputs = document.querySelectorAll('[data-run-option]');
997
+ const options = {
998
+ skipDeploy: false,
999
+ skipVerify: false,
1000
+ saveBeforeRun: true
1001
+ };
1002
+ for (const input of optionInputs) {
1003
+ options[input.dataset.runOption] = Boolean(input.checked);
1004
+ }
1005
+ return options;
1006
+ }
1007
+
1008
+ function validateRunPreflight(runOptions) {
1009
+ if (runOptions.skipDeploy) {
1010
+ return '';
1011
+ }
1012
+
1013
+ const deploy = state.profile?.deploy ?? {};
1014
+ const authMode = String(deploy.cloudflare_auth_mode || 'api_token').trim() || 'api_token';
1015
+ if (authMode === 'global_key') {
1016
+ if (String(deploy.cloudflare_email || '').trim() && String(deploy.cloudflare_global_key || '').trim()) {
1017
+ return '';
1018
+ }
1019
+ return getMessages(state.language).deployCredentialsMissing;
1020
+ }
1021
+
1022
+ if (String(deploy.cloudflare_api_token || '').trim()) {
1023
+ return '';
1024
+ }
1025
+ return getMessages(state.language).deployCredentialsMissing;
1026
+ }
1027
+
1028
+ async function stopPipeline() {
1029
+ const messages = getMessages(state.language);
1030
+ if (!window.vpnAutomation || state.runState !== 'running') {
1031
+ appendLog(messages.stopUnavailable);
1032
+ return;
1033
+ }
1034
+
1035
+ state.runState = 'stopping';
1036
+ touchUpdate();
1037
+ renderAll();
1038
+ appendLog(messages.pipelineStopping);
1039
+
1040
+ const result = await window.vpnAutomation.stopPipeline();
1041
+ if (!result?.ok) {
1042
+ state.runState = 'running';
1043
+ touchUpdate();
1044
+ renderAll();
1045
+ appendLog(messages.stopUnavailable);
1046
+ }
1047
+ }
1048
+
1049
+ function finishRun(result = {}) {
1050
+ const messages = getMessages(state.language);
1051
+ state.runState = 'idle';
1052
+ state.runStartedAt = null;
1053
+
1054
+ if (result.stopped) {
1055
+ state.runResult = 'stopped';
1056
+ touchUpdate();
1057
+ renderAll();
1058
+ appendLog(messages.pipelineStopped);
1059
+ void hydrateRetryArtifacts();
1060
+ return;
1061
+ }
1062
+
1063
+ if (result.ok) {
1064
+ state.runResult = 'success';
1065
+ touchUpdate();
1066
+ renderAll();
1067
+ appendLog(formatMessage(messages.pipelineFinished, { code: result.code ?? 0 }));
1068
+ void hydrateRetryArtifacts();
1069
+ return;
1070
+ }
1071
+
1072
+ state.runResult = 'failed';
1073
+ touchUpdate();
1074
+ renderAll();
1075
+ if (result.error) {
1076
+ appendLog(formatMessage(messages.pipelineFailed, { error: result.error }));
1077
+ } else {
1078
+ appendLog(formatMessage(messages.pipelineFinished, { code: result.code ?? 'error' }));
1079
+ }
1080
+ void hydrateRetryArtifacts();
1081
+ }
1082
+
1083
+ function handlePipelineEvent(event) {
1084
+ if (event.type === 'log') {
1085
+ appendLog(event.message);
1086
+ return;
1087
+ }
1088
+
1089
+ if (event.type === 'stage') {
1090
+ state.stageStatus[event.stage] = event.status;
1091
+ appendLog(`[stage] ${event.stage} ${event.status}`, {
1092
+ kind: 'stage',
1093
+ stage: event.stage,
1094
+ level: event.status === 'failed' ? 'error' : event.status === 'running' ? 'warning' : 'info'
1095
+ });
1096
+ touchUpdate();
1097
+ renderRuntimeOnly({ chrome: false });
1098
+ return;
1099
+ }
1100
+
1101
+ if (event.type === 'summary') {
1102
+ state.stageStatus = event.stage_status ?? {};
1103
+ state.counts = normalizeCounts(event.counts ?? {});
1104
+ state.sourceCounts = normalizeSourceCounts(event.source_counts ?? state.sourceCounts);
1105
+ state.retryContext = event.retry_context ?? state.retryContext ?? {};
1106
+ state.deployment = event.deployment ?? state.deployment ?? {};
1107
+ if (state.profile?.deploy && event.deployment) {
1108
+ if (event.deployment.project_name) {
1109
+ state.profile.deploy.project_name = event.deployment.project_name;
1110
+ }
1111
+ if (event.deployment.pages_project_url) {
1112
+ state.profile.deploy.pages_project_url = event.deployment.pages_project_url;
1113
+ }
1114
+ if (event.deployment.share_project_name) {
1115
+ state.profile.deploy.share_project_name = event.deployment.share_project_name;
1116
+ }
1117
+ }
1118
+ state.artifactDir = event.artifact_dir ?? '';
1119
+ state.selectedRetryArtifactDir = state.artifactDir || state.selectedRetryArtifactDir;
1120
+ touchUpdate();
1121
+ renderAll();
1122
+ appendLog(`[summary] artifacts: ${event.artifact_dir}`);
1123
+ hydrateArtifactPreview();
1124
+ void hydrateRetryArtifacts();
1125
+ return;
1126
+ }
1127
+
1128
+ if (event.type === 'extract_iteration') {
1129
+ updateExtractMetrics(event);
1130
+ touchUpdate();
1131
+ renderRuntimeOnly({ chrome: false });
1132
+ return;
1133
+ }
1134
+
1135
+ if (event.type === 'speedtest_result') {
1136
+ if (event.passed_threshold) {
1137
+ state.counts.speedtest_links = Number(state.counts.speedtest_links ?? 0) + 1;
1138
+ }
1139
+ touchUpdate();
1140
+ renderRuntimeOnly({ chrome: false });
1141
+ return;
1142
+ }
1143
+
1144
+ if (event.type === 'availability_link_result') {
1145
+ if (event.all_passed) {
1146
+ state.counts.availability_links = Number(state.counts.availability_links ?? 0) + 1;
1147
+ }
1148
+ touchUpdate();
1149
+ renderRuntimeOnly({ chrome: false });
1150
+ return;
1151
+ }
1152
+
1153
+ if (event.type === 'finished') {
1154
+ finishRun(event);
1155
+ return;
1156
+ }
1157
+
1158
+ if (event.type === 'run_started') {
1159
+ state.artifactDir = event.artifact_dir ?? '';
1160
+ touchUpdate();
1161
+ renderAll();
1162
+ }
1163
+ }
1164
+
1165
+ function normalizeCounts(counts) {
1166
+ return {
1167
+ ...counts,
1168
+ raw_links: Number(counts.raw_links ?? 0),
1169
+ speedtest_links: Number(counts.speedtest_links ?? 0),
1170
+ availability_links: Number(counts.availability_links ?? 0),
1171
+ deduped_links: Number(counts.deduped_links ?? counts.postprocess_links ?? 0)
1172
+ };
1173
+ }
1174
+
1175
+ function normalizeSourceCounts(sourceCounts = {}) {
1176
+ return Object.fromEntries(
1177
+ Object.entries(sourceCounts).map(([sourceName, counts]) => [
1178
+ sourceName,
1179
+ {
1180
+ ...counts,
1181
+ raw_links: Number(counts?.raw_links ?? 0),
1182
+ deduped_links: Number(counts?.deduped_links ?? 0)
1183
+ }
1184
+ ])
1185
+ );
1186
+ }
1187
+
1188
+ function updateExtractMetrics(event) {
1189
+ const sourceName = event.source_name;
1190
+ if (!sourceName) {
1191
+ return;
1192
+ }
1193
+
1194
+ const previous = state.sourceCounts[sourceName] ?? {};
1195
+ const rawLinks = Number(event.total_links ?? previous.raw_links ?? 0);
1196
+ state.sourceCounts = {
1197
+ ...state.sourceCounts,
1198
+ [sourceName]: {
1199
+ ...previous,
1200
+ raw_links: rawLinks
1201
+ }
1202
+ };
1203
+ state.counts.raw_links = Object.values(state.sourceCounts)
1204
+ .reduce((total, item) => total + Number(item?.raw_links ?? 0), 0);
1205
+ }
1206
+
1207
+ async function hydrateArtifactPreview() {
1208
+ if (!state.artifactDir || !window.vpnAutomation?.previewArtifact) {
1209
+ state.outputFiles = [];
1210
+ state.nodeRows = [];
1211
+ return;
1212
+ }
1213
+
1214
+ const result = await window.vpnAutomation.previewArtifact(state.artifactDir);
1215
+ if (result?.ok) {
1216
+ state.outputFiles = result.outputFiles ?? [];
1217
+ state.nodeRows = result.nodeRows ?? [];
1218
+ state.retryContext = result.retry_context ?? state.retryContext ?? {};
1219
+ state.deployment = result.deployment ?? state.deployment ?? {};
1220
+ renderAll();
1221
+ }
1222
+ }
1223
+
1224
+ async function hydrateLatestArtifact() {
1225
+ if (!window.vpnAutomation?.latestArtifact) {
1226
+ return;
1227
+ }
1228
+
1229
+ try {
1230
+ const result = await window.vpnAutomation.latestArtifact();
1231
+ if (!result?.ok || !result.artifact_dir) {
1232
+ return;
1233
+ }
1234
+ state.artifactDir = result.artifact_dir;
1235
+ state.counts = normalizeCounts(result.counts ?? {});
1236
+ state.sourceCounts = normalizeSourceCounts(result.source_counts ?? {});
1237
+ state.outputFiles = result.outputFiles ?? [];
1238
+ state.nodeRows = result.nodeRows ?? [];
1239
+ state.retryContext = result.retry_context ?? {};
1240
+ state.deployment = result.deployment ?? {};
1241
+ state.runResult = result.run_status === 'success' ? 'success' : state.runResult;
1242
+ if (result.stage_status) {
1243
+ state.stageStatus = result.stage_status;
1244
+ }
1245
+ touchUpdate();
1246
+ } catch (error) {
1247
+ appendLog(formatMessage(getMessages(state.language).openFailed, { error: error.message }));
1248
+ }
1249
+ }
1250
+
1251
+ async function hydrateRetryArtifacts() {
1252
+ if (!window.vpnAutomation?.artifactList) {
1253
+ return;
1254
+ }
1255
+
1256
+ try {
1257
+ const result = await window.vpnAutomation.artifactList();
1258
+ if (!result?.ok) {
1259
+ return;
1260
+ }
1261
+ state.retryArtifacts = result.items ?? [];
1262
+ if (!state.selectedRetryArtifactDir) {
1263
+ state.selectedRetryArtifactDir = state.retryArtifacts[0]?.artifact_dir ?? '';
1264
+ }
1265
+ const selectedArtifact = state.retryArtifacts.find((item) => item.artifact_dir === state.selectedRetryArtifactDir) ?? state.retryArtifacts[0];
1266
+ const nextStage = selectedArtifact?.retryable_stages?.includes(state.selectedRetryStage)
1267
+ ? state.selectedRetryStage
1268
+ : resolveDefaultRetryStage(selectedArtifact);
1269
+ state.selectedRetryArtifactDir = selectedArtifact?.artifact_dir ?? '';
1270
+ state.selectedRetryStage = nextStage;
1271
+ touchUpdate();
1272
+ } catch (error) {
1273
+ appendLog(formatMessage(getMessages(state.language).openFailed, { error: error.message }));
1274
+ }
1275
+ }
1276
+
1277
+ function resolveDefaultRetryStage(artifact) {
1278
+ const retryableStages = Array.isArray(artifact?.retryable_stages) ? artifact.retryable_stages : [];
1279
+ if (!retryableStages.length) {
1280
+ return '';
1281
+ }
1282
+ const failedStage = retryableStages.find((stage) => artifact?.stage_status?.[stage] === 'failed');
1283
+ return failedStage || retryableStages.at(-1) || '';
1284
+ }
1285
+
1286
+ function appendLog(message, overrides = {}) {
1287
+ state.logEntries.push(classifyLogEntry(message, overrides));
1288
+ touchUpdate();
1289
+ renderRuntimeOnly({ chrome: false });
1290
+ }
1291
+
1292
+ function renderRuntimeOnly({ chrome = true } = {}) {
1293
+ const messages = getMessages(state.language);
1294
+ const viewModel = buildViewModel(state, messages, state.language);
1295
+ if (chrome) {
1296
+ renderChrome(messages, viewModel);
1297
+ }
1298
+ renderActiveRuntimeSections(viewModel);
1299
+ renderToast();
1300
+ }
1301
+
1302
+ function renderActiveRuntimeSections(viewModel) {
1303
+ const logCenter = document.querySelector('#logCenterTable');
1304
+ if (logCenter) {
1305
+ logCenter.innerHTML = buildLogCenterMarkup(viewModel);
1306
+ }
1307
+
1308
+ const stageProgress = document.querySelector('#runsStageProgress');
1309
+ if (stageProgress) {
1310
+ stageProgress.outerHTML = buildRunsStageProgressMarkup(viewModel);
1311
+ }
1312
+
1313
+ const currentStage = document.querySelector('#runsCurrentStage');
1314
+ if (currentStage) {
1315
+ currentStage.outerHTML = buildRunsCurrentStageMarkup(viewModel);
1316
+ }
1317
+
1318
+ const dashboardMetrics = document.querySelector('#dashboardMetricsPanel');
1319
+ if (dashboardMetrics) {
1320
+ dashboardMetrics.outerHTML = buildDashboardMetricsMarkup(viewModel);
1321
+ }
1322
+ }
1323
+
1324
+ function resolveVisibleLogEntries() {
1325
+ return filterLogEntries((state.logEntries ?? []).map((entry) => classifyLogEntry(entry)), state.logFilter);
1326
+ }
1327
+
1328
+ function touchUpdate() {
1329
+ state.lastUpdateAt = Date.now();
1330
+ }
1331
+
1332
+ bootstrap();