@doppelgangerdev/doppelganger 0.2.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/.dockerignore +9 -0
- package/.github/workflows/docker-publish.yml +59 -0
- package/CODE_OF_CONDUCT.md +28 -0
- package/CONTRIBUTING.md +42 -0
- package/Dockerfile +44 -0
- package/LICENSE +163 -0
- package/README.md +133 -0
- package/TERMS.md +16 -0
- package/THIRD_PARTY_LICENSES.md +3502 -0
- package/agent.js +1240 -0
- package/headful.js +171 -0
- package/index.html +21 -0
- package/n8n-nodes-doppelganger/LICENSE +201 -0
- package/n8n-nodes-doppelganger/README.md +42 -0
- package/n8n-nodes-doppelganger/package-lock.json +6128 -0
- package/n8n-nodes-doppelganger/package.json +36 -0
- package/n8n-nodes-doppelganger/src/credentials/DoppelgangerApi.credentials.ts +35 -0
- package/n8n-nodes-doppelganger/src/index.ts +4 -0
- package/n8n-nodes-doppelganger/src/nodes/Doppelganger/Doppelganger.node.ts +147 -0
- package/n8n-nodes-doppelganger/src/nodes/Doppelganger/icon.png +0 -0
- package/n8n-nodes-doppelganger/tsconfig.json +14 -0
- package/package.json +45 -0
- package/postcss.config.js +6 -0
- package/public/icon.png +0 -0
- package/public/novnc.html +151 -0
- package/public/styles.css +86 -0
- package/scrape.js +389 -0
- package/server.js +875 -0
- package/src/App.tsx +722 -0
- package/src/components/AuthScreen.tsx +95 -0
- package/src/components/CodeEditor.tsx +70 -0
- package/src/components/DashboardScreen.tsx +133 -0
- package/src/components/EditorScreen.tsx +1519 -0
- package/src/components/ExecutionDetailScreen.tsx +115 -0
- package/src/components/ExecutionsScreen.tsx +156 -0
- package/src/components/LoadingScreen.tsx +26 -0
- package/src/components/NotFoundScreen.tsx +34 -0
- package/src/components/RichInput.tsx +68 -0
- package/src/components/SettingsScreen.tsx +228 -0
- package/src/components/Sidebar.tsx +61 -0
- package/src/components/app/CenterAlert.tsx +44 -0
- package/src/components/app/CenterConfirm.tsx +33 -0
- package/src/components/app/EditorLoader.tsx +89 -0
- package/src/components/editor/ActionPalette.tsx +79 -0
- package/src/components/editor/JsonEditorPane.tsx +71 -0
- package/src/components/editor/ResultsPane.tsx +641 -0
- package/src/components/editor/actionCatalog.ts +23 -0
- package/src/components/settings/AgentAiPanel.tsx +105 -0
- package/src/components/settings/ApiKeyPanel.tsx +68 -0
- package/src/components/settings/CookiesPanel.tsx +154 -0
- package/src/components/settings/LayoutPanel.tsx +46 -0
- package/src/components/settings/ScreenshotsPanel.tsx +64 -0
- package/src/components/settings/SettingsHeader.tsx +28 -0
- package/src/components/settings/StoragePanel.tsx +35 -0
- package/src/index.css +287 -0
- package/src/main.tsx +13 -0
- package/src/types.ts +114 -0
- package/src/utils/syntaxHighlight.ts +140 -0
- package/start-vnc.sh +52 -0
- package/tailwind.config.js +22 -0
- package/tsconfig.json +39 -0
- package/tsconfig.node.json +12 -0
- package/vite.config.mts +27 -0
|
@@ -0,0 +1,641 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
import { Check, Copy, Terminal } from 'lucide-react';
|
|
3
|
+
import { ConfirmRequest, Results } from '../../types';
|
|
4
|
+
import CodeEditor from '../CodeEditor';
|
|
5
|
+
import { SyntaxLanguage } from '../../utils/syntaxHighlight';
|
|
6
|
+
|
|
7
|
+
interface ResultsPaneProps {
|
|
8
|
+
results: Results | null;
|
|
9
|
+
pinnedResults?: Results | null;
|
|
10
|
+
isExecuting: boolean;
|
|
11
|
+
isHeadful?: boolean;
|
|
12
|
+
onConfirm: (request: ConfirmRequest) => Promise<boolean>;
|
|
13
|
+
onNotify: (message: string, tone?: 'success' | 'error') => void;
|
|
14
|
+
onPin?: (results: Results) => void;
|
|
15
|
+
onUnpin?: () => void;
|
|
16
|
+
fullWidth?: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const MAX_PREVIEW_CHARS = 60000;
|
|
20
|
+
const MAX_PREVIEW_ITEMS = 200;
|
|
21
|
+
const MAX_PREVIEW_KEYS = 200;
|
|
22
|
+
const MAX_COPY_CHARS = 1000000;
|
|
23
|
+
const MAX_COPY_ITEMS = 2000;
|
|
24
|
+
const MAX_COPY_KEYS = 2000;
|
|
25
|
+
|
|
26
|
+
const formatSize = (chars: number) => `${(chars / (1024 * 1024)).toFixed(2)} MB`;
|
|
27
|
+
const normalizeBoolean = (value: any) => {
|
|
28
|
+
if (typeof value === 'boolean') return value;
|
|
29
|
+
if (typeof value === 'string') {
|
|
30
|
+
const trimmed = value.trim().toLowerCase();
|
|
31
|
+
if (trimmed === 'true') return true;
|
|
32
|
+
if (trimmed === 'false') return false;
|
|
33
|
+
}
|
|
34
|
+
return null;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const clampText = (text: string, limit: number) => {
|
|
38
|
+
if (text.length <= limit) return { text, truncated: false };
|
|
39
|
+
return { text: text.slice(0, limit), truncated: true };
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const getResultsCopyPayload = (payload: Results | null) => {
|
|
43
|
+
if (!payload || payload.data === undefined || payload.data === null) return { reason: 'No data to copy.' };
|
|
44
|
+
return { raw: payload.data };
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const clampWithReason = (text: string, limit: number, reasons: string[]) => {
|
|
48
|
+
if (text.length <= limit) return text;
|
|
49
|
+
reasons.push(`first ${limit.toLocaleString()} chars`);
|
|
50
|
+
return text.slice(0, limit);
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const getTruncatedCopyText = (raw: any) => {
|
|
54
|
+
const reasons: string[] = [];
|
|
55
|
+
if (typeof raw === 'string') {
|
|
56
|
+
const text = clampWithReason(raw, MAX_COPY_CHARS, reasons);
|
|
57
|
+
return { text, truncated: reasons.length > 0, reason: reasons.join(', ') };
|
|
58
|
+
}
|
|
59
|
+
if (Array.isArray(raw)) {
|
|
60
|
+
let snapshot = raw;
|
|
61
|
+
if (raw.length > MAX_COPY_ITEMS) {
|
|
62
|
+
snapshot = raw.slice(0, MAX_COPY_ITEMS);
|
|
63
|
+
reasons.push(`first ${MAX_COPY_ITEMS.toLocaleString()} items`);
|
|
64
|
+
}
|
|
65
|
+
let text = '';
|
|
66
|
+
try {
|
|
67
|
+
text = JSON.stringify(snapshot, null, 2);
|
|
68
|
+
} catch {
|
|
69
|
+
text = String(snapshot);
|
|
70
|
+
}
|
|
71
|
+
text = clampWithReason(text, MAX_COPY_CHARS, reasons);
|
|
72
|
+
return { text, truncated: reasons.length > 0, reason: reasons.join(', ') };
|
|
73
|
+
}
|
|
74
|
+
if (raw && typeof raw === 'object') {
|
|
75
|
+
let snapshot = raw;
|
|
76
|
+
const keys = Object.keys(raw);
|
|
77
|
+
if (keys.length > MAX_COPY_KEYS) {
|
|
78
|
+
snapshot = keys.slice(0, MAX_COPY_KEYS).reduce<Record<string, any>>((acc, key) => {
|
|
79
|
+
acc[key] = (raw as Record<string, any>)[key];
|
|
80
|
+
return acc;
|
|
81
|
+
}, {});
|
|
82
|
+
reasons.push(`first ${MAX_COPY_KEYS.toLocaleString()} keys`);
|
|
83
|
+
}
|
|
84
|
+
let text = '';
|
|
85
|
+
try {
|
|
86
|
+
text = JSON.stringify(snapshot, null, 2);
|
|
87
|
+
} catch {
|
|
88
|
+
text = String(snapshot);
|
|
89
|
+
}
|
|
90
|
+
text = clampWithReason(text, MAX_COPY_CHARS, reasons);
|
|
91
|
+
return { text, truncated: reasons.length > 0, reason: reasons.join(', ') };
|
|
92
|
+
}
|
|
93
|
+
const text = clampWithReason(String(raw), MAX_COPY_CHARS, reasons);
|
|
94
|
+
return { text, truncated: reasons.length > 0, reason: reasons.join(', ') };
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const getFullCopyText = (raw: any) => {
|
|
98
|
+
if (typeof raw === 'string') return raw;
|
|
99
|
+
try {
|
|
100
|
+
return JSON.stringify(raw, null, 2);
|
|
101
|
+
} catch {
|
|
102
|
+
return String(raw);
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const getResultsPreview = (payload: Results | null): { text: string; truncated: boolean; language: SyntaxLanguage } => {
|
|
107
|
+
if (!payload || payload.data === undefined || payload.data === null || payload.data === '') {
|
|
108
|
+
return { text: '', truncated: false, language: 'plain' as const };
|
|
109
|
+
}
|
|
110
|
+
const raw = payload.data;
|
|
111
|
+
if (typeof raw === 'string') {
|
|
112
|
+
const trimmed = raw.trim();
|
|
113
|
+
const language: SyntaxLanguage = trimmed.startsWith('<') && trimmed.includes('>')
|
|
114
|
+
? 'html'
|
|
115
|
+
: (trimmed.startsWith('{') || trimmed.startsWith('['))
|
|
116
|
+
? 'json'
|
|
117
|
+
: 'plain';
|
|
118
|
+
const clamped = clampText(raw, MAX_PREVIEW_CHARS);
|
|
119
|
+
return { text: clamped.text, truncated: clamped.truncated, language };
|
|
120
|
+
}
|
|
121
|
+
if (Array.isArray(raw)) {
|
|
122
|
+
const sliced = raw.length > MAX_PREVIEW_ITEMS ? raw.slice(0, MAX_PREVIEW_ITEMS) : raw;
|
|
123
|
+
const text = JSON.stringify(sliced, null, 2);
|
|
124
|
+
const clamped = clampText(text, MAX_PREVIEW_CHARS);
|
|
125
|
+
return { text: clamped.text, truncated: clamped.truncated || raw.length > MAX_PREVIEW_ITEMS, language: 'json' as const };
|
|
126
|
+
}
|
|
127
|
+
if (raw && typeof raw === 'object') {
|
|
128
|
+
const keys = Object.keys(raw);
|
|
129
|
+
let snapshot = raw;
|
|
130
|
+
let truncated = false;
|
|
131
|
+
if (keys.length > MAX_PREVIEW_KEYS) {
|
|
132
|
+
truncated = true;
|
|
133
|
+
snapshot = keys.slice(0, MAX_PREVIEW_KEYS).reduce<Record<string, any>>((acc, key) => {
|
|
134
|
+
acc[key] = (raw as Record<string, any>)[key];
|
|
135
|
+
return acc;
|
|
136
|
+
}, {});
|
|
137
|
+
}
|
|
138
|
+
const text = JSON.stringify(snapshot, null, 2);
|
|
139
|
+
const clamped = clampText(text, MAX_PREVIEW_CHARS);
|
|
140
|
+
return { text: clamped.text, truncated: clamped.truncated || truncated, language: 'json' as const };
|
|
141
|
+
}
|
|
142
|
+
const clamped = clampText(String(raw), MAX_PREVIEW_CHARS);
|
|
143
|
+
return { text: clamped.text, truncated: clamped.truncated, language: 'plain' as const };
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const parseCsvRows = (text: string) => {
|
|
147
|
+
const rows: string[][] = [];
|
|
148
|
+
let row: string[] = [];
|
|
149
|
+
let current = '';
|
|
150
|
+
let inQuotes = false;
|
|
151
|
+
|
|
152
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
153
|
+
const char = text[i];
|
|
154
|
+
if (inQuotes) {
|
|
155
|
+
if (char === '"') {
|
|
156
|
+
if (text[i + 1] === '"') {
|
|
157
|
+
current += '"';
|
|
158
|
+
i += 1;
|
|
159
|
+
} else {
|
|
160
|
+
inQuotes = false;
|
|
161
|
+
}
|
|
162
|
+
} else {
|
|
163
|
+
current += char;
|
|
164
|
+
}
|
|
165
|
+
} else {
|
|
166
|
+
if (char === '"') {
|
|
167
|
+
inQuotes = true;
|
|
168
|
+
} else if (char === ',') {
|
|
169
|
+
row.push(current);
|
|
170
|
+
current = '';
|
|
171
|
+
} else if (char === '\n') {
|
|
172
|
+
row.push(current);
|
|
173
|
+
rows.push(row);
|
|
174
|
+
row = [];
|
|
175
|
+
current = '';
|
|
176
|
+
} else if (char === '\r') {
|
|
177
|
+
// ignore CR
|
|
178
|
+
} else {
|
|
179
|
+
current += char;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
row.push(current);
|
|
184
|
+
if (row.length > 1 || row[0] !== '' || rows.length > 0) rows.push(row);
|
|
185
|
+
return rows;
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
const getTableData = (raw: any) => {
|
|
189
|
+
if (!raw) return null;
|
|
190
|
+
if (typeof raw === 'string') {
|
|
191
|
+
const text = raw.trim();
|
|
192
|
+
if (!text.includes(',') || !text.includes('\n')) return null;
|
|
193
|
+
const rows = parseCsvRows(text).filter((r) => r.some((cell) => String(cell || '').trim() !== ''));
|
|
194
|
+
if (rows.length < 2) return null;
|
|
195
|
+
const header = rows[0].map((cell, idx) => {
|
|
196
|
+
const trimmed = String(cell || '').trim();
|
|
197
|
+
return trimmed || `column_${idx + 1}`;
|
|
198
|
+
});
|
|
199
|
+
const body = rows.slice(1);
|
|
200
|
+
if (header.length < 2) return null;
|
|
201
|
+
return { headers: header, rows: body };
|
|
202
|
+
}
|
|
203
|
+
if (Array.isArray(raw)) {
|
|
204
|
+
if (raw.length === 0) return null;
|
|
205
|
+
if (raw.every((item) => item && typeof item === 'object' && !Array.isArray(item))) {
|
|
206
|
+
const headers: string[] = [];
|
|
207
|
+
raw.forEach((item) => {
|
|
208
|
+
Object.keys(item).forEach((key) => {
|
|
209
|
+
if (!headers.includes(key)) headers.push(key);
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
if (headers.length === 0) return null;
|
|
213
|
+
const rows = raw.map((item) => headers.map((key) => item[key] ?? ''));
|
|
214
|
+
return { headers, rows };
|
|
215
|
+
}
|
|
216
|
+
if (raw.every((item) => Array.isArray(item))) {
|
|
217
|
+
const maxCols = Math.max(...raw.map((item) => item.length));
|
|
218
|
+
const headers = Array.from({ length: maxCols }, (_, idx) => `column_${idx + 1}`);
|
|
219
|
+
return { headers, rows: raw };
|
|
220
|
+
}
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
if (raw && typeof raw === 'object') {
|
|
224
|
+
const headers = Object.keys(raw);
|
|
225
|
+
if (headers.length === 0) return null;
|
|
226
|
+
return { headers, rows: [headers.map((key) => raw[key] ?? '')] };
|
|
227
|
+
}
|
|
228
|
+
return null;
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
const getExportPayload = (raw: any, tableData: { headers: string[]; rows: any[][] } | null) => {
|
|
232
|
+
if (raw === undefined || raw === null) return null;
|
|
233
|
+
if (typeof raw === 'string') {
|
|
234
|
+
if (tableData) {
|
|
235
|
+
return { content: raw, mime: 'text/csv', ext: 'csv' };
|
|
236
|
+
}
|
|
237
|
+
return { content: raw, mime: 'application/json', ext: 'json' };
|
|
238
|
+
}
|
|
239
|
+
return { content: JSON.stringify(raw, null, 2), mime: 'application/json', ext: 'json' };
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
const downloadText = (filename: string, content: string, mime: string) => {
|
|
243
|
+
const blob = new Blob([content], { type: mime });
|
|
244
|
+
const url = URL.createObjectURL(blob);
|
|
245
|
+
const link = document.createElement('a');
|
|
246
|
+
link.href = url;
|
|
247
|
+
link.download = filename;
|
|
248
|
+
document.body.appendChild(link);
|
|
249
|
+
link.click();
|
|
250
|
+
link.remove();
|
|
251
|
+
URL.revokeObjectURL(url);
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
const ResultsPane: React.FC<ResultsPaneProps> = ({ results, pinnedResults, isExecuting, isHeadful, onConfirm, onNotify, onPin, onUnpin, fullWidth }) => {
|
|
255
|
+
const [copied, setCopied] = useState<string | null>(null);
|
|
256
|
+
const [dataView, setDataView] = useState<'raw' | 'table'>('raw');
|
|
257
|
+
const [resultView, setResultView] = useState<'latest' | 'pinned'>(() => (pinnedResults && !results ? 'pinned' : 'latest'));
|
|
258
|
+
const [headfulViewer, setHeadfulViewer] = useState<'checking' | 'native' | 'novnc'>('checking');
|
|
259
|
+
const activeResults = resultView === 'pinned' && pinnedResults ? pinnedResults : results;
|
|
260
|
+
const tableData = getTableData(activeResults?.data);
|
|
261
|
+
const preview = activeResults && activeResults.data !== undefined && activeResults.data !== null && activeResults.data !== ''
|
|
262
|
+
? getResultsPreview(activeResults)
|
|
263
|
+
: null;
|
|
264
|
+
const screenshotSrc = activeResults?.screenshotUrl
|
|
265
|
+
? `${activeResults.screenshotUrl}${resultView === 'latest' ? `?t=${Date.now()}` : ''}`
|
|
266
|
+
: null;
|
|
267
|
+
const renderCellValue = (value: any) => {
|
|
268
|
+
const boolValue = normalizeBoolean(value);
|
|
269
|
+
if (boolValue !== null) {
|
|
270
|
+
if (!boolValue) return '';
|
|
271
|
+
return <Check className="w-3 h-3 text-blue-400" />;
|
|
272
|
+
}
|
|
273
|
+
return value ?? '';
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
useEffect(() => {
|
|
277
|
+
if (tableData) {
|
|
278
|
+
setDataView('table');
|
|
279
|
+
} else {
|
|
280
|
+
setDataView('raw');
|
|
281
|
+
}
|
|
282
|
+
}, [activeResults]);
|
|
283
|
+
|
|
284
|
+
useEffect(() => {
|
|
285
|
+
if (results) {
|
|
286
|
+
setResultView('latest');
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
if (pinnedResults) setResultView('pinned');
|
|
290
|
+
}, [results, pinnedResults]);
|
|
291
|
+
|
|
292
|
+
useEffect(() => {
|
|
293
|
+
if (!pinnedResults && resultView === 'pinned') {
|
|
294
|
+
setResultView('latest');
|
|
295
|
+
}
|
|
296
|
+
}, [pinnedResults, resultView]);
|
|
297
|
+
|
|
298
|
+
useEffect(() => {
|
|
299
|
+
if (!isHeadful || resultView !== 'latest') return;
|
|
300
|
+
let cancelled = false;
|
|
301
|
+
const checkHeadful = async () => {
|
|
302
|
+
try {
|
|
303
|
+
const res = await fetch('/api/headful/status', { cache: 'no-store' });
|
|
304
|
+
const data = res.ok ? await res.json() : { useNovnc: false };
|
|
305
|
+
if (cancelled) return;
|
|
306
|
+
if (data && data.useNovnc) {
|
|
307
|
+
try {
|
|
308
|
+
const test = await fetch('/novnc/core/rfb.js', { method: 'HEAD', cache: 'no-store' });
|
|
309
|
+
if (!cancelled) setHeadfulViewer(test.ok ? 'novnc' : 'native');
|
|
310
|
+
} catch {
|
|
311
|
+
if (!cancelled) setHeadfulViewer('native');
|
|
312
|
+
}
|
|
313
|
+
} else {
|
|
314
|
+
if (!cancelled) setHeadfulViewer('native');
|
|
315
|
+
}
|
|
316
|
+
} catch {
|
|
317
|
+
if (!cancelled) setHeadfulViewer('native');
|
|
318
|
+
}
|
|
319
|
+
};
|
|
320
|
+
checkHeadful();
|
|
321
|
+
return () => {
|
|
322
|
+
cancelled = true;
|
|
323
|
+
};
|
|
324
|
+
}, [isHeadful, resultView]);
|
|
325
|
+
|
|
326
|
+
const handleCopy = async (text: string, id: string, options?: { skipSizeConfirm?: boolean; truncatedNotice?: boolean }) => {
|
|
327
|
+
if (!text) {
|
|
328
|
+
onNotify('Nothing to copy.', 'error');
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
let copyText = text;
|
|
332
|
+
if (!options?.skipSizeConfirm && text.length > MAX_COPY_CHARS) {
|
|
333
|
+
const confirmed = await onConfirm({
|
|
334
|
+
message: `Copying ${formatSize(text.length)} may freeze your browser.`,
|
|
335
|
+
confirmLabel: 'Copy full',
|
|
336
|
+
cancelLabel: 'Copy segment'
|
|
337
|
+
});
|
|
338
|
+
if (!confirmed) {
|
|
339
|
+
copyText = text.slice(0, MAX_COPY_CHARS);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
try {
|
|
344
|
+
if (navigator.clipboard && window.isSecureContext) {
|
|
345
|
+
await navigator.clipboard.writeText(copyText);
|
|
346
|
+
} else {
|
|
347
|
+
const textArea = document.createElement('textarea');
|
|
348
|
+
textArea.value = copyText;
|
|
349
|
+
textArea.style.position = 'fixed';
|
|
350
|
+
textArea.style.left = '-999999px';
|
|
351
|
+
textArea.style.top = '-999999px';
|
|
352
|
+
document.body.appendChild(textArea);
|
|
353
|
+
textArea.focus();
|
|
354
|
+
textArea.select();
|
|
355
|
+
document.execCommand('copy');
|
|
356
|
+
textArea.remove();
|
|
357
|
+
}
|
|
358
|
+
setCopied(id);
|
|
359
|
+
setTimeout(() => setCopied(null), 2000);
|
|
360
|
+
if (options?.truncatedNotice) {
|
|
361
|
+
onNotify('Copied truncated data.', 'success');
|
|
362
|
+
} else if (copyText.length !== text.length) {
|
|
363
|
+
onNotify('Copied a truncated preview.', 'success');
|
|
364
|
+
}
|
|
365
|
+
} catch (err) {
|
|
366
|
+
console.error('Copy failed:', err);
|
|
367
|
+
onNotify('Copy failed.', 'error');
|
|
368
|
+
}
|
|
369
|
+
};
|
|
370
|
+
|
|
371
|
+
if (isHeadful && resultView === 'latest') {
|
|
372
|
+
const { origin, hostname } = window.location;
|
|
373
|
+
const headfulUrl = `${origin}/novnc.html?host=${hostname}&port=54311&path=websockify`;
|
|
374
|
+
if (headfulViewer === 'native') {
|
|
375
|
+
return (
|
|
376
|
+
<div className="glass-card rounded-[32px] overflow-hidden h-[80vh] w-full relative flex items-center justify-center">
|
|
377
|
+
<div className="text-[10px] font-bold uppercase tracking-widest text-gray-500">
|
|
378
|
+
Headful session running in a native browser window.
|
|
379
|
+
</div>
|
|
380
|
+
</div>
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
if (headfulViewer === 'checking') {
|
|
384
|
+
return (
|
|
385
|
+
<div className="glass-card rounded-[32px] overflow-hidden h-[80vh] w-full relative flex items-center justify-center">
|
|
386
|
+
<div className="text-[10px] font-bold uppercase tracking-widest text-gray-500">
|
|
387
|
+
Checking headful viewer...
|
|
388
|
+
</div>
|
|
389
|
+
</div>
|
|
390
|
+
);
|
|
391
|
+
}
|
|
392
|
+
return (
|
|
393
|
+
<div className="glass-card rounded-[32px] overflow-hidden h-[80vh] w-full relative">
|
|
394
|
+
<iframe
|
|
395
|
+
src={headfulUrl}
|
|
396
|
+
className="absolute inset-0 w-full h-full"
|
|
397
|
+
title="Headful Browser"
|
|
398
|
+
/>
|
|
399
|
+
</div>
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
if (!activeResults && !(isExecuting && resultView === 'latest')) {
|
|
404
|
+
return (
|
|
405
|
+
<div className="h-full flex flex-col items-center justify-center text-center space-y-4 opacity-20">
|
|
406
|
+
<div className="w-16 h-16 border border-white/10 rounded-full flex items-center justify-center">
|
|
407
|
+
<Terminal className="w-6 h-6 text-white" />
|
|
408
|
+
</div>
|
|
409
|
+
<p className="text-[9px] font-bold uppercase tracking-[0.3em]">Ready</p>
|
|
410
|
+
</div>
|
|
411
|
+
);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
const containerClassName = fullWidth ? 'space-y-12 relative z-10 w-full' : 'space-y-12 relative z-10 max-w-5xl mx-auto';
|
|
415
|
+
|
|
416
|
+
return (
|
|
417
|
+
<div className={containerClassName}>
|
|
418
|
+
<div className="flex items-end justify-between border-b border-white/5 pb-10">
|
|
419
|
+
<div className="space-y-4">
|
|
420
|
+
<p className="text-[9px] font-bold text-gray-500 uppercase tracking-[0.3em]">Preview</p>
|
|
421
|
+
<h2 className="text-xl font-mono text-white truncate max-w-xl tracking-tight italic">
|
|
422
|
+
{activeResults?.finalUrl || activeResults?.url || ''}
|
|
423
|
+
</h2>
|
|
424
|
+
</div>
|
|
425
|
+
<div className={`px-4 py-2 rounded-xl text-[9px] font-bold uppercase tracking-[0.2em] ${
|
|
426
|
+
resultView === 'pinned'
|
|
427
|
+
? 'bg-amber-500/10 text-amber-300'
|
|
428
|
+
: isExecuting
|
|
429
|
+
? 'bg-blue-500/10 text-blue-400 animate-pulse'
|
|
430
|
+
: 'bg-green-500/10 text-green-400'
|
|
431
|
+
}`}>
|
|
432
|
+
{resultView === 'pinned' ? 'Pinned' : (isExecuting ? 'Running' : 'Finished')}
|
|
433
|
+
</div>
|
|
434
|
+
</div>
|
|
435
|
+
|
|
436
|
+
<div className="grid grid-cols-1 xl:grid-cols-2 gap-8">
|
|
437
|
+
<div className="glass-card rounded-[32px] overflow-hidden flex flex-col min-h-[400px]">
|
|
438
|
+
<div className="p-6 border-b border-white/5 flex items-center justify-between text-[8px] font-bold text-gray-500 uppercase tracking-widest">
|
|
439
|
+
<span>Screenshot</span>
|
|
440
|
+
<span className="text-white/20">{activeResults?.timestamp || '--:--:--'}</span>
|
|
441
|
+
</div>
|
|
442
|
+
<div className="relative bg-black flex-1 flex items-center justify-center overflow-hidden">
|
|
443
|
+
{screenshotSrc ? (
|
|
444
|
+
<img
|
|
445
|
+
src={screenshotSrc}
|
|
446
|
+
className="absolute inset-0 w-full h-full object-contain transition-opacity duration-1000"
|
|
447
|
+
/>
|
|
448
|
+
) : (
|
|
449
|
+
<div className="text-[8px] font-bold text-white/5 uppercase tracking-widest">Waiting for Frame...</div>
|
|
450
|
+
)}
|
|
451
|
+
</div>
|
|
452
|
+
</div>
|
|
453
|
+
<div className="glass-card rounded-[32px] p-8 flex flex-col h-[400px]">
|
|
454
|
+
<span className="text-[8px] font-bold text-gray-500 uppercase tracking-widest mb-6 border-b border-white/5 pb-4">Activity Log</span>
|
|
455
|
+
<div className="flex-1 font-mono text-[10px] text-gray-400 space-y-2 overflow-y-auto custom-scrollbar pr-2">
|
|
456
|
+
{activeResults?.logs?.map((log: string, i: number) => (
|
|
457
|
+
<div key={i} className="flex gap-2">
|
|
458
|
+
<span className="text-white/10 shrink-0">›</span> <span>{log}</span>
|
|
459
|
+
</div>
|
|
460
|
+
))}
|
|
461
|
+
{isExecuting && resultView === 'latest' && (!activeResults?.logs || activeResults?.logs.length === 0) && <div className="animate-pulse">Connecting to kernel...</div>}
|
|
462
|
+
</div>
|
|
463
|
+
</div>
|
|
464
|
+
</div>
|
|
465
|
+
|
|
466
|
+
<div className="glass-card rounded-[32px] p-8 flex flex-col relative">
|
|
467
|
+
<div className="flex items-center justify-between border-b border-white/5 pb-4 mb-6">
|
|
468
|
+
<span className="text-[8px] font-bold text-gray-500 uppercase tracking-widest">Data</span>
|
|
469
|
+
<div className="flex items-center gap-2">
|
|
470
|
+
{pinnedResults && (
|
|
471
|
+
<div className="flex bg-white/5 rounded-lg p-0.5 border border-white/10">
|
|
472
|
+
{(['latest', 'pinned'] as const).map((mode) => (
|
|
473
|
+
<button
|
|
474
|
+
key={mode}
|
|
475
|
+
onClick={() => setResultView(mode)}
|
|
476
|
+
className={`px-3 py-1 rounded text-[8px] font-bold uppercase tracking-widest transition-all ${resultView === mode ? 'bg-white text-black' : 'text-gray-500 hover:text-white'}`}
|
|
477
|
+
>
|
|
478
|
+
{mode}
|
|
479
|
+
</button>
|
|
480
|
+
))}
|
|
481
|
+
</div>
|
|
482
|
+
)}
|
|
483
|
+
{tableData && (
|
|
484
|
+
<div className="flex bg-white/5 rounded-lg p-0.5 border border-white/10">
|
|
485
|
+
{(['table', 'raw'] as const).map((mode) => (
|
|
486
|
+
<button
|
|
487
|
+
key={mode}
|
|
488
|
+
onClick={() => setDataView(mode)}
|
|
489
|
+
className={`px-3 py-1 rounded text-[8px] font-bold uppercase tracking-widest transition-all ${dataView === mode ? 'bg-white text-black' : 'text-gray-500 hover:text-white'}`}
|
|
490
|
+
>
|
|
491
|
+
{mode}
|
|
492
|
+
</button>
|
|
493
|
+
))}
|
|
494
|
+
</div>
|
|
495
|
+
)}
|
|
496
|
+
{resultView === 'pinned' ? (
|
|
497
|
+
<button
|
|
498
|
+
onClick={() => {
|
|
499
|
+
onUnpin?.();
|
|
500
|
+
setResultView('latest');
|
|
501
|
+
}}
|
|
502
|
+
className="px-3 py-2 border text-[9px] font-bold rounded-xl uppercase transition-all flex items-center gap-2 bg-white/5 border-white/10 text-amber-200 hover:bg-white/10"
|
|
503
|
+
title="Unpin data"
|
|
504
|
+
>
|
|
505
|
+
Unpin
|
|
506
|
+
</button>
|
|
507
|
+
) : (
|
|
508
|
+
<button
|
|
509
|
+
onClick={() => {
|
|
510
|
+
if (!activeResults) {
|
|
511
|
+
onNotify('No data to pin.', 'error');
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
onPin?.(activeResults);
|
|
515
|
+
setResultView('pinned');
|
|
516
|
+
}}
|
|
517
|
+
className="px-3 py-2 border text-[9px] font-bold rounded-xl uppercase transition-all flex items-center gap-2 bg-white/5 border-white/10 text-white hover:bg-white/10"
|
|
518
|
+
title="Pin data"
|
|
519
|
+
>
|
|
520
|
+
{pinnedResults ? 'Update Pin' : 'Pin'}
|
|
521
|
+
</button>
|
|
522
|
+
)}
|
|
523
|
+
<button
|
|
524
|
+
onClick={() => {
|
|
525
|
+
const payload = getExportPayload(activeResults?.data, tableData);
|
|
526
|
+
if (!payload) {
|
|
527
|
+
onNotify('No data to export.', 'error');
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
530
|
+
const name = `doppelganger-data-${new Date().toISOString().replace(/[:.]/g, '-')}.${payload.ext}`;
|
|
531
|
+
downloadText(name, payload.content, payload.mime);
|
|
532
|
+
onNotify(`Exported ${payload.ext.toUpperCase()}.`, 'success');
|
|
533
|
+
}}
|
|
534
|
+
className="px-3 py-2 border text-[9px] font-bold rounded-xl uppercase transition-all flex items-center gap-2 bg-white/5 border-white/10 text-white hover:bg-white/10"
|
|
535
|
+
title="Export extracted data"
|
|
536
|
+
>
|
|
537
|
+
Export
|
|
538
|
+
</button>
|
|
539
|
+
<button
|
|
540
|
+
onClick={async () => {
|
|
541
|
+
const payload = getResultsCopyPayload(activeResults);
|
|
542
|
+
if (payload.reason) {
|
|
543
|
+
onNotify(payload.reason || 'Data too large to copy safely.', 'error');
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
const preview = getResultsPreview(activeResults);
|
|
547
|
+
const fullText = getFullCopyText(payload.raw);
|
|
548
|
+
let copyText = fullText;
|
|
549
|
+
let usedTruncated = false;
|
|
550
|
+
|
|
551
|
+
if (preview.truncated) {
|
|
552
|
+
const confirmed = await onConfirm({
|
|
553
|
+
message: 'Preview is truncated for performance.',
|
|
554
|
+
confirmLabel: 'Copy full',
|
|
555
|
+
cancelLabel: 'Copy preview'
|
|
556
|
+
});
|
|
557
|
+
if (!confirmed) {
|
|
558
|
+
copyText = preview.text || '';
|
|
559
|
+
usedTruncated = true;
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
if (copyText.length > MAX_COPY_CHARS) {
|
|
564
|
+
const proceed = await onConfirm({
|
|
565
|
+
message: `Copying ${formatSize(copyText.length)} may freeze your browser.`,
|
|
566
|
+
confirmLabel: 'Copy full',
|
|
567
|
+
cancelLabel: usedTruncated ? 'Copy preview' : 'Copy truncated'
|
|
568
|
+
});
|
|
569
|
+
if (!proceed) {
|
|
570
|
+
const truncated = getTruncatedCopyText(payload.raw);
|
|
571
|
+
copyText = truncated.text;
|
|
572
|
+
usedTruncated = true;
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
void handleCopy(copyText, 'data', { skipSizeConfirm: true, truncatedNotice: usedTruncated });
|
|
577
|
+
}}
|
|
578
|
+
className={`px-3 py-2 border text-[9px] font-bold rounded-xl uppercase transition-all flex items-center gap-2 ${copied === 'data' ? 'bg-green-500/10 border-green-500/20 text-green-400' : 'bg-white/5 border-white/10 text-white hover:bg-white/10'}`}
|
|
579
|
+
title="Copy extracted data"
|
|
580
|
+
>
|
|
581
|
+
{copied === 'data' ? <Check className="w-3 h-3" /> : <Copy className="w-3 h-3" />}
|
|
582
|
+
{copied === 'data' ? 'Copied' : 'Copy'}
|
|
583
|
+
</button>
|
|
584
|
+
</div>
|
|
585
|
+
</div>
|
|
586
|
+
{preview?.truncated && (
|
|
587
|
+
<button
|
|
588
|
+
type="button"
|
|
589
|
+
onClick={() => onNotify('Preview truncated for performance.', 'error')}
|
|
590
|
+
className="absolute top-5 right-5 h-2.5 w-2.5 rounded-full bg-amber-400 shadow-[0_0_8px_rgba(251,191,36,0.6)]"
|
|
591
|
+
title="Preview truncated"
|
|
592
|
+
/>
|
|
593
|
+
)}
|
|
594
|
+
<div className="max-h-[70vh] overflow-y-auto custom-scrollbar pr-2 relative">
|
|
595
|
+
{(() => {
|
|
596
|
+
if (isExecuting && resultView === 'latest' && (!activeResults || activeResults.data === undefined)) {
|
|
597
|
+
return <pre className="font-mono text-[10px] text-blue-300/60 whitespace-pre-wrap leading-relaxed">Buffering data stream...</pre>;
|
|
598
|
+
}
|
|
599
|
+
if (!activeResults || activeResults.data === undefined || activeResults.data === null || activeResults.data === '') {
|
|
600
|
+
return <pre className="font-mono text-[10px] text-blue-300/60 whitespace-pre-wrap leading-relaxed">No data available.</pre>;
|
|
601
|
+
}
|
|
602
|
+
return (
|
|
603
|
+
<div>
|
|
604
|
+
{tableData && dataView === 'table' ? (
|
|
605
|
+
<div className="overflow-auto custom-scrollbar rounded-2xl border border-white/10">
|
|
606
|
+
<table className="min-w-full table-auto text-[10px] text-left text-white/80 font-mono">
|
|
607
|
+
<thead className="bg-white/5 text-[9px] uppercase tracking-widest text-white/50">
|
|
608
|
+
<tr>
|
|
609
|
+
{tableData.headers.map((header) => (
|
|
610
|
+
<th key={header} className="px-3 py-2 border-b border-white/10 whitespace-nowrap">
|
|
611
|
+
{header}
|
|
612
|
+
</th>
|
|
613
|
+
))}
|
|
614
|
+
</tr>
|
|
615
|
+
</thead>
|
|
616
|
+
<tbody>
|
|
617
|
+
{tableData.rows.map((row, rowIndex) => (
|
|
618
|
+
<tr key={rowIndex} className="odd:bg-white/[0.02]">
|
|
619
|
+
{tableData.headers.map((_, colIndex) => (
|
|
620
|
+
<td key={`${rowIndex}-${colIndex}`} className="px-3 py-2 border-b border-white/5 align-top whitespace-normal break-words">
|
|
621
|
+
{renderCellValue(row[colIndex])}
|
|
622
|
+
</td>
|
|
623
|
+
))}
|
|
624
|
+
</tr>
|
|
625
|
+
))}
|
|
626
|
+
</tbody>
|
|
627
|
+
</table>
|
|
628
|
+
</div>
|
|
629
|
+
) : (
|
|
630
|
+
<CodeEditor readOnly value={preview?.text || ''} language={preview?.language || 'plain'} />
|
|
631
|
+
)}
|
|
632
|
+
</div>
|
|
633
|
+
);
|
|
634
|
+
})()}
|
|
635
|
+
</div>
|
|
636
|
+
</div>
|
|
637
|
+
</div>
|
|
638
|
+
);
|
|
639
|
+
};
|
|
640
|
+
|
|
641
|
+
export default ResultsPane;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { Action } from '../../types';
|
|
2
|
+
|
|
3
|
+
export const ACTION_CATALOG: { type: Action['type']; label: string; description: string }[] = [
|
|
4
|
+
{ type: 'click', label: 'Click', description: 'Click an element' },
|
|
5
|
+
{ type: 'type', label: 'Type', description: 'Type text into a field' },
|
|
6
|
+
{ type: 'hover', label: 'Hover', description: 'Hover an element' },
|
|
7
|
+
{ type: 'press', label: 'Press', description: 'Press a key' },
|
|
8
|
+
{ type: 'wait', label: 'Wait', description: 'Pause for seconds' },
|
|
9
|
+
{ type: 'scroll', label: 'Scroll', description: 'Scroll the page or container' },
|
|
10
|
+
{ type: 'javascript', label: 'JavaScript', description: 'Run custom JS' },
|
|
11
|
+
{ type: 'csv', label: 'CSV', description: 'Parse CSV into rows' },
|
|
12
|
+
{ type: 'merge', label: 'Merge', description: 'Merge inputs into a single output' },
|
|
13
|
+
{ type: 'if', label: 'If', description: 'Conditional block start' },
|
|
14
|
+
{ type: 'else', label: 'Else', description: 'Conditional alternate path' },
|
|
15
|
+
{ type: 'end', label: 'End Block', description: 'Close a block' },
|
|
16
|
+
{ type: 'while', label: 'While', description: 'Loop while condition true' },
|
|
17
|
+
{ type: 'repeat', label: 'Repeat N', description: 'Repeat block N times' },
|
|
18
|
+
{ type: 'foreach', label: 'For Each', description: 'Loop through items' },
|
|
19
|
+
{ type: 'set', label: 'Set Variable', description: 'Update variable value' },
|
|
20
|
+
{ type: 'stop', label: 'Stop Task', description: 'Stop task with status' },
|
|
21
|
+
{ type: 'on_error', label: 'On Error', description: 'Run on failure' },
|
|
22
|
+
{ type: 'start', label: 'Start Task', description: 'Run another task' }
|
|
23
|
+
];
|