site-agent-pro 1.0.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.
- package/README.md +689 -0
- package/dist/auth/credentialStore.js +62 -0
- package/dist/auth/inbox.js +193 -0
- package/dist/auth/profile.js +379 -0
- package/dist/auth/runner.js +1124 -0
- package/dist/backend/dashboardData.js +194 -0
- package/dist/backend/runArtifacts.js +48 -0
- package/dist/backend/runRepository.js +93 -0
- package/dist/bin.js +2 -0
- package/dist/cli/backfillSiteChecks.js +143 -0
- package/dist/cli/run.js +309 -0
- package/dist/cli/trade.js +69 -0
- package/dist/config.js +199 -0
- package/dist/core/agentProfiles.js +55 -0
- package/dist/core/aggregateReport.js +382 -0
- package/dist/core/audit.js +30 -0
- package/dist/core/customTaskSuite.js +148 -0
- package/dist/core/evaluator.js +217 -0
- package/dist/core/executor.js +788 -0
- package/dist/core/fallbackReport.js +335 -0
- package/dist/core/formHeuristics.js +411 -0
- package/dist/core/gameplaySummary.js +164 -0
- package/dist/core/interaction.js +202 -0
- package/dist/core/pageState.js +201 -0
- package/dist/core/planner.js +1669 -0
- package/dist/core/processSubmissionBatch.js +204 -0
- package/dist/core/runAuditJob.js +170 -0
- package/dist/core/runner.js +2352 -0
- package/dist/core/siteBrief.js +107 -0
- package/dist/core/siteChecks.js +1526 -0
- package/dist/core/taskDirectives.js +279 -0
- package/dist/core/taskHeuristics.js +263 -0
- package/dist/dashboard/client.js +1256 -0
- package/dist/dashboard/contracts.js +95 -0
- package/dist/dashboard/narrative.js +277 -0
- package/dist/dashboard/server.js +458 -0
- package/dist/dashboard/theme.js +888 -0
- package/dist/index.js +84 -0
- package/dist/llm/client.js +188 -0
- package/dist/paystack/account.js +123 -0
- package/dist/paystack/client.js +100 -0
- package/dist/paystack/index.js +13 -0
- package/dist/paystack/test-paystack.js +83 -0
- package/dist/paystack/transfer.js +138 -0
- package/dist/paystack/types.js +74 -0
- package/dist/paystack/webhook.js +121 -0
- package/dist/prompts/browserAgent.js +124 -0
- package/dist/prompts/reviewer.js +71 -0
- package/dist/reporting/clickReplay.js +290 -0
- package/dist/reporting/html.js +930 -0
- package/dist/reporting/markdown.js +238 -0
- package/dist/reporting/template.js +1141 -0
- package/dist/schemas/types.js +361 -0
- package/dist/submissions/customTasks.js +196 -0
- package/dist/submissions/html.js +770 -0
- package/dist/submissions/model.js +56 -0
- package/dist/submissions/publicUrl.js +76 -0
- package/dist/submissions/service.js +74 -0
- package/dist/submissions/store.js +37 -0
- package/dist/submissions/types.js +65 -0
- package/dist/trade/engine.js +241 -0
- package/dist/trade/evm/erc20.js +44 -0
- package/dist/trade/extractor.js +148 -0
- package/dist/trade/policy.js +35 -0
- package/dist/trade/session.js +31 -0
- package/dist/trade/types.js +107 -0
- package/dist/trade/validator.js +148 -0
- package/dist/utils/files.js +59 -0
- package/dist/utils/log.js +24 -0
- package/dist/utils/playwrightCompat.js +14 -0
- package/dist/utils/time.js +3 -0
- package/dist/wallet/provider.js +345 -0
- package/dist/wallet/relay.js +129 -0
- package/dist/wallet/wallet.js +178 -0
- package/docs/01-installation.md +134 -0
- package/docs/02-running-your-first-audit.md +136 -0
- package/docs/03-configuration.md +233 -0
- package/docs/04-how-the-agent-thinks.md +41 -0
- package/docs/05-extending-personas-and-tasks.md +42 -0
- package/docs/06-hardening-for-production.md +92 -0
- package/package.json +60 -0
|
@@ -0,0 +1,1256 @@
|
|
|
1
|
+
import { buildVisitRecap, buildVisitSummary, filterVisitorFacingItems } from "./narrative.js";
|
|
2
|
+
const appRoot = document.getElementById("app");
|
|
3
|
+
if (!appRoot) {
|
|
4
|
+
throw new Error("Dashboard root element was not found.");
|
|
5
|
+
}
|
|
6
|
+
const dashboardRoot = appRoot;
|
|
7
|
+
const SIDEBAR_COLLAPSE_STORAGE_KEY = "site-agent-pro:dashboard-sidebar-collapsed";
|
|
8
|
+
const SUMMARY_RAIL_COLLAPSE_STORAGE_KEY = "site-agent-pro:dashboard-summary-collapsed";
|
|
9
|
+
function readStoredBooleanPreference(key) {
|
|
10
|
+
try {
|
|
11
|
+
return window.localStorage.getItem(key) === "true";
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
function writeStoredBooleanPreference(key, value) {
|
|
18
|
+
try {
|
|
19
|
+
window.localStorage.setItem(key, String(value));
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
// Ignore storage failures and keep the in-memory state.
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
function readSidebarCollapsedPreference() {
|
|
26
|
+
return readStoredBooleanPreference(SIDEBAR_COLLAPSE_STORAGE_KEY);
|
|
27
|
+
}
|
|
28
|
+
function writeSidebarCollapsedPreference(collapsed) {
|
|
29
|
+
writeStoredBooleanPreference(SIDEBAR_COLLAPSE_STORAGE_KEY, collapsed);
|
|
30
|
+
}
|
|
31
|
+
function readSummaryRailCollapsedPreference() {
|
|
32
|
+
return readStoredBooleanPreference(SUMMARY_RAIL_COLLAPSE_STORAGE_KEY);
|
|
33
|
+
}
|
|
34
|
+
function writeSummaryRailCollapsedPreference(collapsed) {
|
|
35
|
+
writeStoredBooleanPreference(SUMMARY_RAIL_COLLAPSE_STORAGE_KEY, collapsed);
|
|
36
|
+
}
|
|
37
|
+
const state = {
|
|
38
|
+
detail: null,
|
|
39
|
+
error: null,
|
|
40
|
+
loadingDetail: false,
|
|
41
|
+
loadingRuns: true,
|
|
42
|
+
runs: [],
|
|
43
|
+
sidebarCollapsed: readSidebarCollapsedPreference(),
|
|
44
|
+
summaryRailCollapsed: readSummaryRailCollapsedPreference(),
|
|
45
|
+
selectedRunId: null
|
|
46
|
+
};
|
|
47
|
+
function escapeHtml(value) {
|
|
48
|
+
return value
|
|
49
|
+
.replaceAll("&", "&")
|
|
50
|
+
.replaceAll("<", "<")
|
|
51
|
+
.replaceAll(">", ">")
|
|
52
|
+
.replaceAll('"', """)
|
|
53
|
+
.replaceAll("'", "'");
|
|
54
|
+
}
|
|
55
|
+
function formatDate(value, timeZone) {
|
|
56
|
+
if (!value) {
|
|
57
|
+
return "Unknown time";
|
|
58
|
+
}
|
|
59
|
+
const date = new Date(value);
|
|
60
|
+
if (Number.isNaN(date.getTime())) {
|
|
61
|
+
return value;
|
|
62
|
+
}
|
|
63
|
+
return new Intl.DateTimeFormat(undefined, {
|
|
64
|
+
dateStyle: "medium",
|
|
65
|
+
timeStyle: "short",
|
|
66
|
+
...(timeZone ? { timeZone } : {})
|
|
67
|
+
}).format(date);
|
|
68
|
+
}
|
|
69
|
+
function formatDashboardDate(value) {
|
|
70
|
+
const date = value ? new Date(value) : new Date();
|
|
71
|
+
if (Number.isNaN(date.getTime())) {
|
|
72
|
+
return value ?? "Today";
|
|
73
|
+
}
|
|
74
|
+
return new Intl.DateTimeFormat(undefined, {
|
|
75
|
+
weekday: "short",
|
|
76
|
+
day: "2-digit",
|
|
77
|
+
month: "short",
|
|
78
|
+
year: "numeric"
|
|
79
|
+
}).format(date);
|
|
80
|
+
}
|
|
81
|
+
function formatClock(value, timeZone) {
|
|
82
|
+
if (!value) {
|
|
83
|
+
return "--:--";
|
|
84
|
+
}
|
|
85
|
+
const date = new Date(value);
|
|
86
|
+
if (Number.isNaN(date.getTime())) {
|
|
87
|
+
return value;
|
|
88
|
+
}
|
|
89
|
+
return new Intl.DateTimeFormat(undefined, {
|
|
90
|
+
hour: "numeric",
|
|
91
|
+
minute: "2-digit",
|
|
92
|
+
...(timeZone ? { timeZone } : {})
|
|
93
|
+
}).format(date);
|
|
94
|
+
}
|
|
95
|
+
function humanize(value) {
|
|
96
|
+
return value.replaceAll("_", " ");
|
|
97
|
+
}
|
|
98
|
+
function scoreTone(score) {
|
|
99
|
+
if ((score ?? 0) >= 8) {
|
|
100
|
+
return "high";
|
|
101
|
+
}
|
|
102
|
+
if ((score ?? 0) >= 5) {
|
|
103
|
+
return "mid";
|
|
104
|
+
}
|
|
105
|
+
return "low";
|
|
106
|
+
}
|
|
107
|
+
function formatScore(score) {
|
|
108
|
+
if (score === null || score === undefined) {
|
|
109
|
+
return "n/a";
|
|
110
|
+
}
|
|
111
|
+
return Number.isInteger(score) ? `${score}` : score.toFixed(1);
|
|
112
|
+
}
|
|
113
|
+
function computeAverageScore(runs) {
|
|
114
|
+
const scores = runs
|
|
115
|
+
.map((run) => run.overallScore)
|
|
116
|
+
.filter((score) => score !== null);
|
|
117
|
+
if (scores.length === 0) {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
const average = scores.reduce((sum, score) => sum + score, 0) / scores.length;
|
|
121
|
+
return Math.round(average * 10) / 10;
|
|
122
|
+
}
|
|
123
|
+
function formatAgentCount(agentCount) {
|
|
124
|
+
return `${agentCount} agent${agentCount === 1 ? "" : "s"}`;
|
|
125
|
+
}
|
|
126
|
+
function describeBatchRole(batchRole, agentCount) {
|
|
127
|
+
if (batchRole === "aggregate") {
|
|
128
|
+
return `${formatAgentCount(agentCount)} task panel`;
|
|
129
|
+
}
|
|
130
|
+
if (batchRole === "child") {
|
|
131
|
+
return "Individual agent run";
|
|
132
|
+
}
|
|
133
|
+
return "Single-agent run";
|
|
134
|
+
}
|
|
135
|
+
function getActionDescription(action, target) {
|
|
136
|
+
const trimmedTarget = target.trim();
|
|
137
|
+
switch (action) {
|
|
138
|
+
case "click":
|
|
139
|
+
return trimmedTarget ? `I clicked "${trimmedTarget}"` : "I clicked an element";
|
|
140
|
+
case "type":
|
|
141
|
+
return trimmedTarget ? `I typed into "${trimmedTarget}"` : "I typed into a field";
|
|
142
|
+
case "scroll":
|
|
143
|
+
return "I scrolled the page";
|
|
144
|
+
case "wait":
|
|
145
|
+
return "I waited for the page to respond";
|
|
146
|
+
case "back":
|
|
147
|
+
return "I went back";
|
|
148
|
+
case "extract":
|
|
149
|
+
return "I captured the page state";
|
|
150
|
+
case "trade":
|
|
151
|
+
return "I ran the wallet trade handoff";
|
|
152
|
+
case "stop":
|
|
153
|
+
return "I stopped";
|
|
154
|
+
default:
|
|
155
|
+
return `I performed "${action}"`;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
function safeHref(url) {
|
|
159
|
+
return /^https?:\/\//i.test(url) ? url : null;
|
|
160
|
+
}
|
|
161
|
+
function buildArtifactHref(runId, fileName) {
|
|
162
|
+
const normalized = fileName?.trim();
|
|
163
|
+
if (!normalized) {
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
return `/api/runs/${encodeURIComponent(runId)}/artifacts/${encodeURIComponent(normalized)}`;
|
|
167
|
+
}
|
|
168
|
+
function describeClickReplay(inputs) {
|
|
169
|
+
if (!inputs?.clickReplayArtifact) {
|
|
170
|
+
return "";
|
|
171
|
+
}
|
|
172
|
+
const frameLabel = inputs.clickReplayFrameCount && inputs.clickReplayFrameCount > 0
|
|
173
|
+
? `${inputs.clickReplayFrameCount} frame${inputs.clickReplayFrameCount === 1 ? "" : "s"}`
|
|
174
|
+
: "saved click frames";
|
|
175
|
+
const durationLabel = inputs.clickReplayDurationMs && inputs.clickReplayDurationMs > 0
|
|
176
|
+
? ` over ${(Math.round((inputs.clickReplayDurationMs / 1000) * 10) / 10).toFixed(1)}s`
|
|
177
|
+
: "";
|
|
178
|
+
return `Compact animated WebP with highlighted click targets, built from ${frameLabel}${durationLabel}.`;
|
|
179
|
+
}
|
|
180
|
+
function renderVideoRecording(detail) {
|
|
181
|
+
const inputs = detail.inputs;
|
|
182
|
+
const videoArtifact = inputs?.videoArtifact ?? null;
|
|
183
|
+
if (!videoArtifact)
|
|
184
|
+
return "";
|
|
185
|
+
const videoHref = buildArtifactHref(detail.id, videoArtifact);
|
|
186
|
+
if (!videoHref)
|
|
187
|
+
return "";
|
|
188
|
+
const isMp4 = videoArtifact.endsWith(".mp4");
|
|
189
|
+
const mimeType = isMp4 ? "video/mp4" : "video/webm";
|
|
190
|
+
return `
|
|
191
|
+
<div class="warning-note" style="margin-top: 12px;"><strong>Session recording.</strong> Full browser session captured by Playwright during this run.</div>
|
|
192
|
+
<div class="step-proof" style="margin-top: 12px;">
|
|
193
|
+
<figure class="proof-shot">
|
|
194
|
+
<video controls style="width:100%;border-radius:6px;background:#000;" preload="metadata">
|
|
195
|
+
<source src="${escapeHtml(videoHref)}" type="${mimeType}" />
|
|
196
|
+
Your browser does not support inline video playback. <a href="${escapeHtml(videoHref)}" target="_blank" rel="noreferrer">Download recording</a>.
|
|
197
|
+
</video>
|
|
198
|
+
<figcaption>Session recording (${isMp4 ? "MP4" : "WebM"})</figcaption>
|
|
199
|
+
</figure>
|
|
200
|
+
</div>
|
|
201
|
+
`;
|
|
202
|
+
}
|
|
203
|
+
function renderClickReplayStatus(detail, hasClickFrames) {
|
|
204
|
+
const inputs = detail.inputs;
|
|
205
|
+
const clickReplayHref = buildArtifactHref(detail.id, inputs?.clickReplayArtifact);
|
|
206
|
+
const videoSection = renderVideoRecording(detail);
|
|
207
|
+
if (clickReplayHref) {
|
|
208
|
+
return `
|
|
209
|
+
<div class="warning-note" style="margin-top: 12px;"><strong>Click replay.</strong> ${escapeHtml(describeClickReplay(inputs))}</div>
|
|
210
|
+
<div class="step-proof" style="margin-top: 12px;">
|
|
211
|
+
<figure class="proof-shot">
|
|
212
|
+
<a href="${escapeHtml(clickReplayHref)}" target="_blank" rel="noreferrer">
|
|
213
|
+
<img src="${escapeHtml(clickReplayHref)}" alt="Animated click replay for this run" loading="lazy" />
|
|
214
|
+
</a>
|
|
215
|
+
<figcaption>Animated WebP replay</figcaption>
|
|
216
|
+
</figure>
|
|
217
|
+
</div>
|
|
218
|
+
${videoSection}
|
|
219
|
+
`;
|
|
220
|
+
}
|
|
221
|
+
if (inputs?.batchRole === "aggregate" && (inputs.agentRuns?.length ?? 0) > 0) {
|
|
222
|
+
const agentReplayLinks = inputs.agentRuns
|
|
223
|
+
.filter((agentRun) => Boolean(agentRun.runId))
|
|
224
|
+
.sort((left, right) => left.index - right.index)
|
|
225
|
+
.map((agentRun) => {
|
|
226
|
+
const childRunId = agentRun.runId;
|
|
227
|
+
const runHref = `/dashboard?run=${encodeURIComponent(childRunId)}`;
|
|
228
|
+
const replayHref = agentRun.clickReplayAvailable || agentRun.clickReplayArtifact
|
|
229
|
+
? buildArtifactHref(childRunId, agentRun.clickReplayArtifact ?? "click-replay.webp")
|
|
230
|
+
: null;
|
|
231
|
+
const videoHref = agentRun.videoArtifact ? buildArtifactHref(childRunId, agentRun.videoArtifact) : null;
|
|
232
|
+
return `
|
|
233
|
+
<div class="link-row" style="margin-top: 8px;">
|
|
234
|
+
<strong>${escapeHtml(displayAgentPersona(agentRun.profileLabel, agentRun.label))}</strong>
|
|
235
|
+
<a class="inline-link" href="${escapeHtml(runHref)}">Open run</a>
|
|
236
|
+
${replayHref ? `<a class="inline-link" href="${escapeHtml(replayHref)}" target="_blank" rel="noreferrer">Open replay</a>` : `<span class="muted">Replay unavailable</span>`}
|
|
237
|
+
${videoHref ? `<a class="inline-link" href="${escapeHtml(videoHref)}" target="_blank" rel="noreferrer">Open recording</a>` : ""}
|
|
238
|
+
</div>
|
|
239
|
+
`;
|
|
240
|
+
})
|
|
241
|
+
.join("");
|
|
242
|
+
return `
|
|
243
|
+
<div class="warning-note" style="margin-top: 12px;"><strong>Click replay.</strong> This is the aggregate summary run. Use the links below to open each agent run directly, or jump straight into an available replay.</div>
|
|
244
|
+
${agentReplayLinks}
|
|
245
|
+
${videoSection}
|
|
246
|
+
`;
|
|
247
|
+
}
|
|
248
|
+
if (hasClickFrames) {
|
|
249
|
+
return `<div class="warning-note" style="margin-top: 12px;"><strong>Click replay.</strong> Click frames were saved for this run, but the combined animated WebP was not available.</div>${videoSection}`;
|
|
250
|
+
}
|
|
251
|
+
return `<div class="warning-note" style="margin-top: 12px;"><strong>Click replay.</strong> No click actions were recorded in this run, so there was nothing to turn into a replay.</div>${videoSection}`;
|
|
252
|
+
}
|
|
253
|
+
function clamp(value, min, max) {
|
|
254
|
+
return Math.min(max, Math.max(min, value));
|
|
255
|
+
}
|
|
256
|
+
function currentRunIdFromUrl() {
|
|
257
|
+
const requestedRunId = new URLSearchParams(window.location.search).get("run");
|
|
258
|
+
return requestedRunId && requestedRunId.trim().length > 0 ? requestedRunId : null;
|
|
259
|
+
}
|
|
260
|
+
function updateUrl(runId, pushHistory) {
|
|
261
|
+
const nextUrl = new URL(window.location.href);
|
|
262
|
+
nextUrl.searchParams.set("run", runId);
|
|
263
|
+
if (pushHistory) {
|
|
264
|
+
window.history.pushState({ runId }, "", nextUrl);
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
window.history.replaceState({ runId }, "", nextUrl);
|
|
268
|
+
}
|
|
269
|
+
async function fetchJson(url) {
|
|
270
|
+
const response = await fetch(url);
|
|
271
|
+
if (!response.ok) {
|
|
272
|
+
const message = await response.text();
|
|
273
|
+
throw new Error(message || `${response.status} ${response.statusText}`);
|
|
274
|
+
}
|
|
275
|
+
return (await response.json());
|
|
276
|
+
}
|
|
277
|
+
async function loadRuns() {
|
|
278
|
+
state.loadingRuns = true;
|
|
279
|
+
state.error = null;
|
|
280
|
+
render();
|
|
281
|
+
try {
|
|
282
|
+
state.runs = await fetchJson("/api/runs");
|
|
283
|
+
const requestedRunId = currentRunIdFromUrl();
|
|
284
|
+
const fallbackRun = state.runs.find((run) => run.id === requestedRunId) ?? state.runs[0] ?? null;
|
|
285
|
+
state.loadingRuns = false;
|
|
286
|
+
render();
|
|
287
|
+
if (requestedRunId) {
|
|
288
|
+
const loadedRequested = await loadRunDetail(requestedRunId, false);
|
|
289
|
+
if (!loadedRequested && fallbackRun && fallbackRun.id !== requestedRunId) {
|
|
290
|
+
state.error = `Run '${requestedRunId}' was not found. Showing the latest visible run instead.`;
|
|
291
|
+
await loadRunDetail(fallbackRun.id, false);
|
|
292
|
+
render();
|
|
293
|
+
}
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
if (fallbackRun) {
|
|
297
|
+
await loadRunDetail(fallbackRun.id, false);
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
state.selectedRunId = null;
|
|
301
|
+
}
|
|
302
|
+
catch (error) {
|
|
303
|
+
state.loadingRuns = false;
|
|
304
|
+
state.error = error instanceof Error ? error.message : "Failed to load dashboard runs.";
|
|
305
|
+
render();
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
async function loadRunDetail(runId, pushHistory) {
|
|
309
|
+
state.selectedRunId = runId;
|
|
310
|
+
state.loadingDetail = true;
|
|
311
|
+
state.error = null;
|
|
312
|
+
render();
|
|
313
|
+
try {
|
|
314
|
+
state.detail = await fetchJson(`/api/runs/${encodeURIComponent(runId)}`);
|
|
315
|
+
state.loadingDetail = false;
|
|
316
|
+
updateUrl(runId, pushHistory);
|
|
317
|
+
render();
|
|
318
|
+
return true;
|
|
319
|
+
}
|
|
320
|
+
catch (error) {
|
|
321
|
+
state.detail = null;
|
|
322
|
+
state.loadingDetail = false;
|
|
323
|
+
state.error = error instanceof Error ? error.message : "Failed to load the selected run.";
|
|
324
|
+
render();
|
|
325
|
+
return false;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
function classifyFinding(text, positive = false) {
|
|
329
|
+
if (positive) {
|
|
330
|
+
return { tagClass: "tag-pos", tagLabel: "GOOD" };
|
|
331
|
+
}
|
|
332
|
+
if (/(slow|lag|latency|performance|preload|asset|websocket|load)/i.test(text)) {
|
|
333
|
+
return { tagClass: "tag-perf", tagLabel: "PERF" };
|
|
334
|
+
}
|
|
335
|
+
if (/(bug|error|broken|fail|failed|missing|hidden|did not|didn't|could not|can't|cannot|not respond|unclear)/i.test(text)) {
|
|
336
|
+
return { tagClass: "tag-bug", tagLabel: "BUG" };
|
|
337
|
+
}
|
|
338
|
+
return { tagClass: "tag-ux", tagLabel: "UX" };
|
|
339
|
+
}
|
|
340
|
+
function classifyIssue(text) {
|
|
341
|
+
if (/(slow|lag|latency|performance|preload|asset|websocket|load)/i.test(text)) {
|
|
342
|
+
return { iconClass: "i-info", iconLabel: "i" };
|
|
343
|
+
}
|
|
344
|
+
if (/(bug|error|broken|fail|failed|missing|hidden|did not|didn't|could not|can't|cannot|not respond)/i.test(text)) {
|
|
345
|
+
return { iconClass: "i-bug", iconLabel: "!" };
|
|
346
|
+
}
|
|
347
|
+
return { iconClass: "i-ux", iconLabel: "~" };
|
|
348
|
+
}
|
|
349
|
+
function formatAgentId(index) {
|
|
350
|
+
return `AGT-${String(index).padStart(2, "0")}`;
|
|
351
|
+
}
|
|
352
|
+
function displayAgentPersona(profileLabel, fallback) {
|
|
353
|
+
const trimmed = profileLabel?.trim();
|
|
354
|
+
return trimmed ? trimmed : fallback;
|
|
355
|
+
}
|
|
356
|
+
function buildAgentCards(detail) {
|
|
357
|
+
const inputs = detail.inputs;
|
|
358
|
+
if (inputs?.batchRole === "aggregate" && inputs.agentRuns.length > 0) {
|
|
359
|
+
return inputs.agentRuns
|
|
360
|
+
.slice()
|
|
361
|
+
.sort((left, right) => left.index - right.index)
|
|
362
|
+
.map((agentRun) => {
|
|
363
|
+
const status = agentRun.status;
|
|
364
|
+
const summary = agentRun.reportSummary ??
|
|
365
|
+
agentRun.error ??
|
|
366
|
+
(status === "completed"
|
|
367
|
+
? "Finished the accepted tasks and submitted final outcomes."
|
|
368
|
+
: status === "failed"
|
|
369
|
+
? "The run ended with an error before the accepted tasks could finish."
|
|
370
|
+
: status === "running"
|
|
371
|
+
? "Still working through the accepted tasks."
|
|
372
|
+
: "Queued and waiting to start.");
|
|
373
|
+
return {
|
|
374
|
+
idLabel: formatAgentId(agentRun.index),
|
|
375
|
+
persona: displayAgentPersona(agentRun.profileLabel, agentRun.label),
|
|
376
|
+
summary,
|
|
377
|
+
score: agentRun.overallScore,
|
|
378
|
+
stateClass: status === "completed" ? "st-done" : status === "running" ? "st-active" : status === "failed" ? "st-error" : "st-idle",
|
|
379
|
+
pipClass: status === "completed" ? "pip-green" : status === "running" ? "pip-blue" : status === "failed" ? "pip-amber" : "pip-gray",
|
|
380
|
+
progressClass: status === "completed" ? "prog-green" : status === "running" ? "prog-blue" : "prog-amber",
|
|
381
|
+
progressWidth: status === "completed" ? 100 : status === "running" ? 62 : status === "failed" ? 100 : 18,
|
|
382
|
+
runId: agentRun.runId,
|
|
383
|
+
selected: agentRun.runId === state.selectedRunId
|
|
384
|
+
};
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
const batchRole = inputs?.batchRole ?? "single";
|
|
388
|
+
const persona = batchRole === "child"
|
|
389
|
+
? inputs?.agentProfileLabel ?? inputs?.agentLabel ?? "Focused task runner"
|
|
390
|
+
: inputs?.persona ?? "Human visitor";
|
|
391
|
+
const summary = buildVisitSummary(detail);
|
|
392
|
+
return [
|
|
393
|
+
{
|
|
394
|
+
idLabel: batchRole === "child" ? formatAgentId(inputs?.agentIndex ?? 1) : "AGT-01",
|
|
395
|
+
persona,
|
|
396
|
+
summary,
|
|
397
|
+
score: detail.report?.overall_score ?? null,
|
|
398
|
+
stateClass: detail.report ? "st-done" : "st-idle",
|
|
399
|
+
pipClass: detail.report ? "pip-green" : "pip-gray",
|
|
400
|
+
progressClass: detail.report ? "prog-green" : "prog-amber",
|
|
401
|
+
progressWidth: detail.report ? 100 : 24,
|
|
402
|
+
runId: detail.id,
|
|
403
|
+
selected: true
|
|
404
|
+
}
|
|
405
|
+
];
|
|
406
|
+
}
|
|
407
|
+
function buildFeedbackItems(detail) {
|
|
408
|
+
const report = detail.report;
|
|
409
|
+
const inputs = detail.inputs;
|
|
410
|
+
const items = [];
|
|
411
|
+
if (inputs?.batchRole === "aggregate") {
|
|
412
|
+
for (const agentRun of inputs.agentRuns.slice().sort((left, right) => left.index - right.index)) {
|
|
413
|
+
const summary = agentRun.reportSummary ?? agentRun.error;
|
|
414
|
+
if (!summary) {
|
|
415
|
+
continue;
|
|
416
|
+
}
|
|
417
|
+
const classification = agentRun.status === "completed" && (agentRun.overallScore ?? 0) >= 8
|
|
418
|
+
? { tagClass: "tag-pos", tagLabel: "GOOD" }
|
|
419
|
+
: agentRun.status === "failed"
|
|
420
|
+
? { tagClass: "tag-bug", tagLabel: "BUG" }
|
|
421
|
+
: classifyFinding(summary);
|
|
422
|
+
items.push({
|
|
423
|
+
source: formatAgentId(agentRun.index),
|
|
424
|
+
text: summary,
|
|
425
|
+
tagClass: classification.tagClass,
|
|
426
|
+
tagLabel: classification.tagLabel
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
for (const strength of filterVisitorFacingItems(report?.strengths ?? []).slice(0, 2)) {
|
|
431
|
+
const classification = classifyFinding(strength, true);
|
|
432
|
+
items.push({
|
|
433
|
+
source: "VISIT",
|
|
434
|
+
text: strength,
|
|
435
|
+
tagClass: classification.tagClass,
|
|
436
|
+
tagLabel: classification.tagLabel
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
for (const weakness of filterVisitorFacingItems(report?.weaknesses ?? []).slice(0, 4)) {
|
|
440
|
+
const classification = classifyFinding(weakness);
|
|
441
|
+
items.push({
|
|
442
|
+
source: "VISIT",
|
|
443
|
+
text: weakness,
|
|
444
|
+
tagClass: classification.tagClass,
|
|
445
|
+
tagLabel: classification.tagLabel
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
for (const fix of filterVisitorFacingItems(report?.top_fixes ?? []).slice(0, 2)) {
|
|
449
|
+
items.push({
|
|
450
|
+
source: "FIX",
|
|
451
|
+
text: fix,
|
|
452
|
+
tagClass: "tag-ux",
|
|
453
|
+
tagLabel: "FIX"
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
for (const violation of detail.accessibility?.violations.slice(0, 2) ?? []) {
|
|
457
|
+
items.push({
|
|
458
|
+
source: "AXE",
|
|
459
|
+
text: `${violation.help} (${violation.nodes} affected ${violation.nodes === 1 ? "node" : "nodes"})`,
|
|
460
|
+
tagClass: "tag-bug",
|
|
461
|
+
tagLabel: "A11Y"
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
return items.slice(0, 8);
|
|
465
|
+
}
|
|
466
|
+
function buildIssueItems(detail) {
|
|
467
|
+
const issues = [];
|
|
468
|
+
for (const weakness of filterVisitorFacingItems(detail.report?.weaknesses ?? []).slice(0, 3)) {
|
|
469
|
+
const classification = classifyIssue(weakness);
|
|
470
|
+
issues.push({
|
|
471
|
+
iconClass: classification.iconClass,
|
|
472
|
+
iconLabel: classification.iconLabel,
|
|
473
|
+
text: weakness,
|
|
474
|
+
countLabel: "Observed"
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
for (const violation of detail.accessibility?.violations.slice(0, 1) ?? []) {
|
|
478
|
+
issues.push({
|
|
479
|
+
iconClass: "i-bug",
|
|
480
|
+
iconLabel: "!",
|
|
481
|
+
text: violation.help,
|
|
482
|
+
countLabel: `${violation.nodes} ${violation.nodes === 1 ? "node" : "nodes"}`
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
for (const fix of filterVisitorFacingItems(detail.report?.top_fixes ?? []).slice(0, 1)) {
|
|
486
|
+
issues.push({
|
|
487
|
+
iconClass: "i-info",
|
|
488
|
+
iconLabel: "i",
|
|
489
|
+
text: fix,
|
|
490
|
+
countLabel: "Fix first"
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
if (issues.length === 0) {
|
|
494
|
+
issues.push({
|
|
495
|
+
iconClass: "i-info",
|
|
496
|
+
iconLabel: "i",
|
|
497
|
+
text: "No major issues were recorded for this visit.",
|
|
498
|
+
countLabel: "Clear"
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
return issues.slice(0, 4);
|
|
502
|
+
}
|
|
503
|
+
function buildPersonaChips(detail) {
|
|
504
|
+
const inputs = detail.inputs;
|
|
505
|
+
if (inputs?.batchRole === "aggregate" && inputs.agentRuns.length > 0) {
|
|
506
|
+
const profileLabels = Array.from(new Set(inputs.agentRuns
|
|
507
|
+
.map((agentRun) => agentRun.profileLabel.trim())
|
|
508
|
+
.filter(Boolean)));
|
|
509
|
+
if (profileLabels.length > 0) {
|
|
510
|
+
return profileLabels;
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
if (inputs?.agentProfileLabel) {
|
|
514
|
+
return [inputs.agentProfileLabel];
|
|
515
|
+
}
|
|
516
|
+
if (inputs?.persona) {
|
|
517
|
+
return [inputs.persona];
|
|
518
|
+
}
|
|
519
|
+
return ["Human visitor"];
|
|
520
|
+
}
|
|
521
|
+
function buildActivityItems(detail) {
|
|
522
|
+
const inputs = detail.inputs;
|
|
523
|
+
const timezone = inputs?.synchronizedTimezone ?? null;
|
|
524
|
+
if (inputs?.batchRole === "aggregate" && inputs.agentRuns.length > 0) {
|
|
525
|
+
return inputs.agentRuns
|
|
526
|
+
.slice()
|
|
527
|
+
.sort((left, right) => {
|
|
528
|
+
const rightTime = new Date(right.completedAt ?? right.startedAt ?? 0).getTime();
|
|
529
|
+
const leftTime = new Date(left.completedAt ?? left.startedAt ?? 0).getTime();
|
|
530
|
+
return rightTime - leftTime;
|
|
531
|
+
})
|
|
532
|
+
.map((agentRun) => ({
|
|
533
|
+
time: formatClock(agentRun.completedAt ?? agentRun.startedAt, timezone),
|
|
534
|
+
idLabel: formatAgentId(agentRun.index),
|
|
535
|
+
text: agentRun.status === "completed"
|
|
536
|
+
? `completed the visit · ${formatScore(agentRun.overallScore)}/10`
|
|
537
|
+
: agentRun.status === "failed"
|
|
538
|
+
? `stopped with an error${agentRun.error ? ` · ${agentRun.error}` : ""}`
|
|
539
|
+
: agentRun.status === "running"
|
|
540
|
+
? "is still moving through the site"
|
|
541
|
+
: "is queued for launch"
|
|
542
|
+
}))
|
|
543
|
+
.slice(0, 5);
|
|
544
|
+
}
|
|
545
|
+
const history = detail.tasks
|
|
546
|
+
.flatMap((task) => task.history)
|
|
547
|
+
.slice()
|
|
548
|
+
.sort((left, right) => new Date(right.time).getTime() - new Date(left.time).getTime());
|
|
549
|
+
return history.slice(0, 5).map((entry) => ({
|
|
550
|
+
time: formatClock(entry.time, timezone),
|
|
551
|
+
idLabel: null,
|
|
552
|
+
text: `${getActionDescription(entry.decision.action, entry.decision.target)} · ${entry.result.note}`
|
|
553
|
+
}));
|
|
554
|
+
}
|
|
555
|
+
function renderRunList() {
|
|
556
|
+
if (state.loadingRuns && state.runs.length === 0) {
|
|
557
|
+
return `<div class="empty-stack"><div><h3>Loading runs</h3><p class="muted">Scanning saved artifacts.</p></div></div>`;
|
|
558
|
+
}
|
|
559
|
+
if (state.runs.length === 0) {
|
|
560
|
+
return `<div class="empty-stack"><div><h3>No runs yet</h3><p class="muted">Your first finished task run will appear here automatically.</p></div></div>`;
|
|
561
|
+
}
|
|
562
|
+
return state.runs
|
|
563
|
+
.map((run) => {
|
|
564
|
+
const isActive = run.id === state.selectedRunId;
|
|
565
|
+
const summary = run.summary ?? "This run does not have a saved summary yet.";
|
|
566
|
+
const scoreLabel = formatScore(run.overallScore);
|
|
567
|
+
const modes = [
|
|
568
|
+
describeBatchRole(run.batchRole, run.agentCount),
|
|
569
|
+
run.mobile ? "Mobile" : "Desktop",
|
|
570
|
+
run.headed ? "Headed" : "Headless",
|
|
571
|
+
run.batchRole === "aggregate"
|
|
572
|
+
? `${run.completedAgentCount}/${run.agentCount} complete${run.failedAgentCount > 0 ? ` · ${run.failedAgentCount} failed` : ""}`
|
|
573
|
+
: null
|
|
574
|
+
];
|
|
575
|
+
return `
|
|
576
|
+
<button type="button" class="run-button ${isActive ? "run-button--active" : ""}" data-run-id="${escapeHtml(run.id)}">
|
|
577
|
+
<div class="run-topline">
|
|
578
|
+
<div>
|
|
579
|
+
<div class="run-host">${escapeHtml(run.host)}</div>
|
|
580
|
+
<div class="muted">${escapeHtml(formatDate(run.startedAt))}</div>
|
|
581
|
+
</div>
|
|
582
|
+
<span class="pill pill--score-${scoreTone(run.overallScore)}">${escapeHtml(scoreLabel)}</span>
|
|
583
|
+
</div>
|
|
584
|
+
<p class="run-summary">${escapeHtml(summary)}</p>
|
|
585
|
+
<div class="mini-meta">
|
|
586
|
+
${modes.filter((mode) => Boolean(mode)).map((mode) => `<span>${escapeHtml(mode)}</span>`).join("")}
|
|
587
|
+
</div>
|
|
588
|
+
</button>
|
|
589
|
+
`;
|
|
590
|
+
})
|
|
591
|
+
.join("");
|
|
592
|
+
}
|
|
593
|
+
function renderMetricsGrid(detail) {
|
|
594
|
+
const totalRuns = state.runs.length;
|
|
595
|
+
const totalAgents = state.runs.reduce((sum, run) => sum + run.agentCount, 0);
|
|
596
|
+
const selectedInputs = detail?.inputs;
|
|
597
|
+
const findingsCount = detail
|
|
598
|
+
? filterVisitorFacingItems(detail.report?.weaknesses ?? []).length +
|
|
599
|
+
filterVisitorFacingItems(detail.report?.top_fixes ?? []).length +
|
|
600
|
+
(detail.accessibility?.violations.length ?? 0)
|
|
601
|
+
: 0;
|
|
602
|
+
const latestRun = state.runs[0] ?? null;
|
|
603
|
+
const averageScore = computeAverageScore(state.runs);
|
|
604
|
+
return `
|
|
605
|
+
<section class="metrics-grid">
|
|
606
|
+
<article class="metric-card">
|
|
607
|
+
<div class="metric-label">Total runs</div>
|
|
608
|
+
<div class="metric-val">${totalRuns}</div>
|
|
609
|
+
<div class="metric-delta ${totalRuns > 0 ? "delta-up" : ""}">${escapeHtml(latestRun ? `Latest ${formatDate(latestRun.startedAt)}` : "Waiting for the first task run")}</div>
|
|
610
|
+
</article>
|
|
611
|
+
<article class="metric-card">
|
|
612
|
+
<div class="metric-label">Agents deployed</div>
|
|
613
|
+
<div class="metric-val">${totalAgents}</div>
|
|
614
|
+
<div class="metric-delta">${escapeHtml(detail ? `${selectedInputs?.agentCount ?? 1} in this run` : "Across saved runs")}</div>
|
|
615
|
+
</article>
|
|
616
|
+
<article class="metric-card">
|
|
617
|
+
<div class="metric-label">Issues found</div>
|
|
618
|
+
<div class="metric-val" style="color: var(--red);">${detail ? findingsCount : 0}</div>
|
|
619
|
+
<div class="metric-delta ${findingsCount > 0 ? "delta-down" : ""}">${escapeHtml(detail ? "From the selected run" : "Open a run to inspect findings")}</div>
|
|
620
|
+
</article>
|
|
621
|
+
<article class="metric-card">
|
|
622
|
+
<div class="metric-label">Avg UX score</div>
|
|
623
|
+
<div class="metric-val" style="color: var(--accent);">${escapeHtml(formatScore(averageScore))}</div>
|
|
624
|
+
<div class="metric-delta ${averageScore !== null && averageScore >= 7 ? "delta-up" : averageScore !== null ? "delta-down" : ""}">${escapeHtml(detail?.report ? `Selected run ${formatScore(detail.report.overall_score)}/10` : "Across scored runs")}</div>
|
|
625
|
+
</article>
|
|
626
|
+
</section>
|
|
627
|
+
`;
|
|
628
|
+
}
|
|
629
|
+
function renderNewTestCard(detail) {
|
|
630
|
+
const urlValue = detail?.inputs?.baseUrl ?? "";
|
|
631
|
+
const selectedAgents = String(detail?.inputs?.agentCount ?? 1);
|
|
632
|
+
const submittedInstructions = detail?.inputs?.instructionText ?? detail?.inputs?.customTasks?.join("\n") ?? "";
|
|
633
|
+
return `
|
|
634
|
+
<form class="new-test-card" id="new-test" method="POST" action="/submit" enctype="multipart/form-data">
|
|
635
|
+
<div class="card-title">New test</div>
|
|
636
|
+
<div class="url-row">
|
|
637
|
+
<input class="url-input" name="url" type="url" value="${escapeHtml(urlValue)}" placeholder="https://app.yourproduct.com" required />
|
|
638
|
+
<button class="btn btn-primary" type="submit">▶ Start task run</button>
|
|
639
|
+
</div>
|
|
640
|
+
<p class="task-intro">Paste the instructions in one box or upload a text or JSON file. The agents will first understand what the site appears to be for, then perform only the instructions you supplied.</p>
|
|
641
|
+
<div class="instruction-panel">
|
|
642
|
+
<label>
|
|
643
|
+
<span class="instruction-label">Instructions</span>
|
|
644
|
+
<textarea class="instruction-input" name="instructions" placeholder="- Check what a new user is meant to do first - Try the pricing path and explain whether it is clear - Stop before entering private details">${escapeHtml(submittedInstructions)}</textarea>
|
|
645
|
+
</label>
|
|
646
|
+
</div>
|
|
647
|
+
<div class="file-input-row">
|
|
648
|
+
<label>
|
|
649
|
+
<span class="instruction-label">Instruction file (optional)</span>
|
|
650
|
+
<input class="file-input" type="file" name="instructions_file" accept=".txt,.md,.json,.csv,text/plain,application/json" />
|
|
651
|
+
</label>
|
|
652
|
+
</div>
|
|
653
|
+
<div class="config-row">
|
|
654
|
+
<select class="config-select" name="agents">
|
|
655
|
+
${[1, 2, 3, 4, 5]
|
|
656
|
+
.map((count) => `<option value="${count}" ${selectedAgents === `${count}` ? "selected" : ""}>${count} ${count === 1 ? "agent" : "agents"}</option>`)
|
|
657
|
+
.join("")}
|
|
658
|
+
</select>
|
|
659
|
+
<span class="tag on">task-driven</span>
|
|
660
|
+
<span class="tag on">dashboard input only</span>
|
|
661
|
+
<span class="tag on">no fallback personas</span>
|
|
662
|
+
</div>
|
|
663
|
+
</form>
|
|
664
|
+
`;
|
|
665
|
+
}
|
|
666
|
+
function renderAgentGrid(detail) {
|
|
667
|
+
const inputs = detail.inputs;
|
|
668
|
+
const report = detail.report;
|
|
669
|
+
const batchRole = inputs?.batchRole ?? "single";
|
|
670
|
+
const badgeClass = batchRole === "aggregate" && (inputs?.completedAgentCount ?? 0) < (inputs?.agentCount ?? 1)
|
|
671
|
+
? "live-badge"
|
|
672
|
+
: detail.warnings.length > 0
|
|
673
|
+
? "live-badge warning"
|
|
674
|
+
: "live-badge";
|
|
675
|
+
const badgeLabel = batchRole === "aggregate" && (inputs?.completedAgentCount ?? 0) < (inputs?.agentCount ?? 1)
|
|
676
|
+
? "LIVE"
|
|
677
|
+
: detail.warnings.length > 0
|
|
678
|
+
? "CHECK"
|
|
679
|
+
: "SAVED";
|
|
680
|
+
const badgeDotClass = detail.warnings.length > 0 ? "live-dot warning" : "live-dot";
|
|
681
|
+
const cards = buildAgentCards(detail);
|
|
682
|
+
const feedbackItems = buildFeedbackItems(detail);
|
|
683
|
+
const subtitle = batchRole === "aggregate"
|
|
684
|
+
? `${inputs?.completedAgentCount ?? 0} of ${inputs?.agentCount ?? 1} complete${(inputs?.failedAgentCount ?? 0) > 0 ? ` · ${inputs?.failedAgentCount ?? 0} failed` : ""}`
|
|
685
|
+
: `${detail.tasks.length} accepted ${detail.tasks.length === 1 ? "task" : "tasks"} · ${report ? `${formatScore(report.overall_score)}/10 overall` : "output pending"}`;
|
|
686
|
+
const warningList = detail.warnings.length > 0
|
|
687
|
+
? `
|
|
688
|
+
<div class="warning-note" style="margin-bottom: 18px;">
|
|
689
|
+
${detail.warnings.map((warning) => `<div>${escapeHtml(warning)}</div>`).join("")}
|
|
690
|
+
</div>
|
|
691
|
+
`
|
|
692
|
+
: "";
|
|
693
|
+
return `
|
|
694
|
+
<section class="panel" id="live-run">
|
|
695
|
+
<div class="panel-head">
|
|
696
|
+
<div class="${badgeClass}"><div class="${badgeDotClass}"></div>${escapeHtml(badgeLabel)}</div>
|
|
697
|
+
<div>
|
|
698
|
+
<div class="panel-title">${escapeHtml(`${detail.host} · ${describeBatchRole(batchRole, inputs?.agentCount ?? 1)}`)}</div>
|
|
699
|
+
<div class="panel-sub">${escapeHtml(subtitle)}</div>
|
|
700
|
+
</div>
|
|
701
|
+
<div class="panel-actions">
|
|
702
|
+
<a class="icon-btn" href="/outputs/${encodeURIComponent(detail.id)}" target="_blank" rel="noreferrer">↗ Full output</a>
|
|
703
|
+
<a class="icon-btn" href="/api/runs/${encodeURIComponent(detail.id)}/artifacts/report.json">↓ JSON output</a>
|
|
704
|
+
</div>
|
|
705
|
+
</div>
|
|
706
|
+
${warningList}
|
|
707
|
+
<div class="agents-grid">
|
|
708
|
+
${cards
|
|
709
|
+
.map((card) => `
|
|
710
|
+
<article class="agent-card ${card.stateClass} ${card.selected ? "selected" : ""}"${card.runId ? ` data-run-id="${escapeHtml(card.runId)}"` : ""}>
|
|
711
|
+
<div class="agent-num">
|
|
712
|
+
<span>${escapeHtml(card.idLabel)}</span>
|
|
713
|
+
<span class="status-pip ${card.pipClass}"></span>
|
|
714
|
+
</div>
|
|
715
|
+
<div class="agent-persona">${escapeHtml(card.persona)}</div>
|
|
716
|
+
<div class="agent-doing">${escapeHtml(card.summary)}</div>
|
|
717
|
+
<div class="agent-score ${card.score === null ? "score-muted" : card.score >= 8 ? "score-green" : card.score >= 5 ? "score-amber" : "score-red"}">${escapeHtml(card.score === null ? "..." : `${formatScore(card.score)}/10`)}</div>
|
|
718
|
+
<div class="prog-track"><div class="prog-fill ${card.progressClass}" style="width:${card.progressWidth}%"></div></div>
|
|
719
|
+
</article>
|
|
720
|
+
`)
|
|
721
|
+
.join("")}
|
|
722
|
+
</div>
|
|
723
|
+
<div style="padding: 0 16px 6px;">
|
|
724
|
+
<div class="card-title">Live feedback stream</div>
|
|
725
|
+
</div>
|
|
726
|
+
<div class="feedback-list">
|
|
727
|
+
${feedbackItems.length > 0
|
|
728
|
+
? feedbackItems
|
|
729
|
+
.map((item) => `
|
|
730
|
+
<article class="fb-item">
|
|
731
|
+
<div class="fb-top">
|
|
732
|
+
<span class="fb-agent">${escapeHtml(item.source)}</span>
|
|
733
|
+
<span class="fb-tag ${item.tagClass}">${escapeHtml(item.tagLabel)}</span>
|
|
734
|
+
</div>
|
|
735
|
+
<div class="fb-text">${escapeHtml(item.text)}</div>
|
|
736
|
+
</article>
|
|
737
|
+
`)
|
|
738
|
+
.join("")
|
|
739
|
+
: `<div class="warning-note">Feedback will appear here once the run records strengths, issues, or accessibility findings.</div>`}
|
|
740
|
+
</div>
|
|
741
|
+
</section>
|
|
742
|
+
`;
|
|
743
|
+
}
|
|
744
|
+
function renderListPanel(title, items, emptyCopy) {
|
|
745
|
+
return `
|
|
746
|
+
<section class="panel">
|
|
747
|
+
<div class="panel-head">
|
|
748
|
+
<div>
|
|
749
|
+
<div class="panel-title">${escapeHtml(title)}</div>
|
|
750
|
+
</div>
|
|
751
|
+
</div>
|
|
752
|
+
<div class="panel-body">
|
|
753
|
+
${items.length > 0
|
|
754
|
+
? `<ul class="prose-list">${items.map((item) => `<li>${escapeHtml(item)}</li>`).join("")}</ul>`
|
|
755
|
+
: `<div class="warning-note">${escapeHtml(emptyCopy)}</div>`}
|
|
756
|
+
</div>
|
|
757
|
+
</section>
|
|
758
|
+
`;
|
|
759
|
+
}
|
|
760
|
+
function renderTaskCard(runId, task, timeZone) {
|
|
761
|
+
const finalHref = safeHref(task.finalUrl);
|
|
762
|
+
const historyHtml = task.history.length > 0
|
|
763
|
+
? task.history
|
|
764
|
+
.map((entry) => {
|
|
765
|
+
const stepHref = safeHref(entry.url);
|
|
766
|
+
const actionText = getActionDescription(entry.decision.action, entry.decision.target);
|
|
767
|
+
const beforeScreenshotHref = buildArtifactHref(runId, entry.result.beforeScreenshotPath);
|
|
768
|
+
const afterScreenshotHref = buildArtifactHref(runId, entry.result.afterScreenshotPath);
|
|
769
|
+
return `
|
|
770
|
+
<article class="history-card">
|
|
771
|
+
<div class="history-head">
|
|
772
|
+
<strong>Step ${entry.step}</strong>
|
|
773
|
+
<span class="pill pill--friction-${escapeHtml(entry.decision.friction)}">${escapeHtml(humanize(entry.decision.friction))} friction</span>
|
|
774
|
+
</div>
|
|
775
|
+
${entry.decision.instructionQuote
|
|
776
|
+
? `<p><strong>Page Step${entry.decision.stepNumber ? ` ${escapeHtml(String(entry.decision.stepNumber))}` : ""}:</strong> ${escapeHtml(entry.decision.instructionQuote)}</p>`
|
|
777
|
+
: ""}
|
|
778
|
+
<p><strong>${escapeHtml(actionText)}</strong></p>
|
|
779
|
+
<p>${escapeHtml(entry.decision.expectation)}</p>
|
|
780
|
+
<p>${escapeHtml(entry.result.note)}</p>
|
|
781
|
+
<div class="history-meta">
|
|
782
|
+
<span>${escapeHtml(entry.title || "Untitled page")}</span>
|
|
783
|
+
<span>${escapeHtml(formatDate(entry.time, timeZone))}</span>
|
|
784
|
+
</div>
|
|
785
|
+
${beforeScreenshotHref || afterScreenshotHref
|
|
786
|
+
? `
|
|
787
|
+
<div class="step-proof">
|
|
788
|
+
${beforeScreenshotHref
|
|
789
|
+
? `
|
|
790
|
+
<figure class="proof-shot">
|
|
791
|
+
<a href="${escapeHtml(beforeScreenshotHref)}" target="_blank" rel="noreferrer">
|
|
792
|
+
<img src="${escapeHtml(beforeScreenshotHref)}" alt="${escapeHtml(`Before click screenshot for step ${entry.step}`)}" loading="lazy" />
|
|
793
|
+
</a>
|
|
794
|
+
<figcaption>Before click</figcaption>
|
|
795
|
+
</figure>
|
|
796
|
+
`
|
|
797
|
+
: ""}
|
|
798
|
+
${afterScreenshotHref
|
|
799
|
+
? `
|
|
800
|
+
<figure class="proof-shot">
|
|
801
|
+
<a href="${escapeHtml(afterScreenshotHref)}" target="_blank" rel="noreferrer">
|
|
802
|
+
<img src="${escapeHtml(afterScreenshotHref)}" alt="${escapeHtml(`After click screenshot for step ${entry.step}`)}" loading="lazy" />
|
|
803
|
+
</a>
|
|
804
|
+
<figcaption>After click</figcaption>
|
|
805
|
+
</figure>
|
|
806
|
+
`
|
|
807
|
+
: ""}
|
|
808
|
+
</div>
|
|
809
|
+
`
|
|
810
|
+
: ""}
|
|
811
|
+
<div class="link-row">
|
|
812
|
+
${stepHref
|
|
813
|
+
? `<a class="inline-link" href="${escapeHtml(stepHref)}" target="_blank" rel="noreferrer">Open page</a>`
|
|
814
|
+
: `<span class="muted">No page URL was recorded.</span>`}
|
|
815
|
+
${beforeScreenshotHref ? `<a class="inline-link" href="${escapeHtml(beforeScreenshotHref)}" target="_blank" rel="noreferrer">Open before-click frame</a>` : ""}
|
|
816
|
+
${afterScreenshotHref ? `<a class="inline-link" href="${escapeHtml(afterScreenshotHref)}" target="_blank" rel="noreferrer">Open after-click frame</a>` : ""}
|
|
817
|
+
</div>
|
|
818
|
+
</article>
|
|
819
|
+
`;
|
|
820
|
+
})
|
|
821
|
+
.join("")
|
|
822
|
+
: `<div class="warning-note">No step-by-step history was recorded for this part of the visit.</div>`;
|
|
823
|
+
return `
|
|
824
|
+
<article class="task-card">
|
|
825
|
+
<div class="task-card__header">
|
|
826
|
+
<div>
|
|
827
|
+
<h3>${escapeHtml(task.name)}</h3>
|
|
828
|
+
<p class="task-card__reason">${escapeHtml(task.reason)}</p>
|
|
829
|
+
</div>
|
|
830
|
+
<span class="pill pill--status-${escapeHtml(task.status)}">${escapeHtml(humanize(task.status))}</span>
|
|
831
|
+
</div>
|
|
832
|
+
<div class="task-meta">
|
|
833
|
+
<span>${escapeHtml(`${task.history.length} steps recorded`)}</span>
|
|
834
|
+
<span>${escapeHtml(task.finalTitle || "No final page title recorded")}</span>
|
|
835
|
+
</div>
|
|
836
|
+
<div class="link-row">
|
|
837
|
+
${finalHref
|
|
838
|
+
? `<a class="inline-link" href="${escapeHtml(finalHref)}" target="_blank" rel="noreferrer">Open final URL</a>`
|
|
839
|
+
: `<span class="muted">No final URL was recorded.</span>`}
|
|
840
|
+
</div>
|
|
841
|
+
${task.evidence.length > 0
|
|
842
|
+
? `<ul class="evidence-list">${task.evidence.map((item) => `<li>${escapeHtml(item)}</li>`).join("")}</ul>`
|
|
843
|
+
: `<div class="warning-note" style="margin-top: 12px;">No extra evidence bullets were saved for this section.</div>`}
|
|
844
|
+
<details class="task-details">
|
|
845
|
+
<summary>Interaction timeline</summary>
|
|
846
|
+
<div class="history-grid">
|
|
847
|
+
${historyHtml}
|
|
848
|
+
</div>
|
|
849
|
+
</details>
|
|
850
|
+
</article>
|
|
851
|
+
`;
|
|
852
|
+
}
|
|
853
|
+
function renderAccessibility(detail) {
|
|
854
|
+
const accessibility = detail.accessibility;
|
|
855
|
+
if (!accessibility) {
|
|
856
|
+
return `<div class="warning-note">No accessibility artifact was found for this run.</div>`;
|
|
857
|
+
}
|
|
858
|
+
if (accessibility.error) {
|
|
859
|
+
return `<div class="warning-note">${escapeHtml(accessibility.error)}</div>`;
|
|
860
|
+
}
|
|
861
|
+
if (accessibility.violations.length === 0) {
|
|
862
|
+
return `<div class="warning-note">No accessibility violations were recorded for this run.</div>`;
|
|
863
|
+
}
|
|
864
|
+
return `
|
|
865
|
+
<div class="accessibility-grid">
|
|
866
|
+
${accessibility.violations
|
|
867
|
+
.map((violation) => `
|
|
868
|
+
<article class="violation-card">
|
|
869
|
+
<div class="section-heading">
|
|
870
|
+
<h3>${escapeHtml(violation.id)}</h3>
|
|
871
|
+
<span class="pill pill--score-${scoreTone(violation.impact ? 3 : 6)}">${escapeHtml(violation.impact ?? "unknown impact")}</span>
|
|
872
|
+
</div>
|
|
873
|
+
<p>${escapeHtml(violation.description)}</p>
|
|
874
|
+
<p><strong>Help:</strong> ${escapeHtml(violation.help)}</p>
|
|
875
|
+
<div class="helper-row">
|
|
876
|
+
<span>${escapeHtml(`${violation.nodes} affected ${violation.nodes === 1 ? "node" : "nodes"}`)}</span>
|
|
877
|
+
</div>
|
|
878
|
+
</article>
|
|
879
|
+
`)
|
|
880
|
+
.join("")}
|
|
881
|
+
</div>
|
|
882
|
+
`;
|
|
883
|
+
}
|
|
884
|
+
function renderSummaryRail(detail) {
|
|
885
|
+
const report = detail.report;
|
|
886
|
+
const issues = buildIssueItems(detail);
|
|
887
|
+
const personas = buildPersonaChips(detail);
|
|
888
|
+
const activityItems = buildActivityItems(detail);
|
|
889
|
+
const overallScore = report?.overall_score ?? null;
|
|
890
|
+
const summaryToggleLabel = state.summaryRailCollapsed ? "Open" : "Hide";
|
|
891
|
+
const summaryToggleAriaLabel = state.summaryRailCollapsed ? "Expand run summary" : "Collapse run summary";
|
|
892
|
+
const summaryToggleIcon = state.summaryRailCollapsed ? "▸" : "◂";
|
|
893
|
+
const summaryTitle = state.summaryRailCollapsed ? "Summary" : "Run summary";
|
|
894
|
+
return `
|
|
895
|
+
<aside class="panel summary-rail ${state.summaryRailCollapsed ? "summary-rail--collapsed" : ""}" data-summary-rail>
|
|
896
|
+
<div class="panel-head">
|
|
897
|
+
<div class="panel-title">${escapeHtml(summaryTitle)}</div>
|
|
898
|
+
<div class="panel-actions">
|
|
899
|
+
<a class="icon-btn" data-summary-share href="/outputs/${encodeURIComponent(detail.id)}" target="_blank" rel="noreferrer">⎘ Share output</a>
|
|
900
|
+
<button class="icon-btn summary-toggle" type="button" data-summary-toggle aria-expanded="${state.summaryRailCollapsed ? "false" : "true"}" aria-label="${escapeHtml(summaryToggleAriaLabel)}">
|
|
901
|
+
<span aria-hidden="true">${summaryToggleIcon}</span>
|
|
902
|
+
<span class="summary-toggle-label">${escapeHtml(summaryToggleLabel)}</span>
|
|
903
|
+
</button>
|
|
904
|
+
</div>
|
|
905
|
+
</div>
|
|
906
|
+
|
|
907
|
+
<div class="summary-rail-body">
|
|
908
|
+
<div class="summary-section">
|
|
909
|
+
<div class="ss-label">Overall score</div>
|
|
910
|
+
<div class="big-score">${escapeHtml(formatScore(overallScore))}<span class="score-dim">/10</span></div>
|
|
911
|
+
${report
|
|
912
|
+
? `
|
|
913
|
+
<div class="score-bars">
|
|
914
|
+
${Object.entries(report.scores)
|
|
915
|
+
.map(([label, value]) => `
|
|
916
|
+
<div class="sb-row">
|
|
917
|
+
<span class="sb-name">${escapeHtml(humanize(label))}</span>
|
|
918
|
+
<div class="sb-track"><div class="sb-fill ${value >= 8 ? "prog-green" : value >= 5 ? "prog-blue" : "prog-amber"}" style="width:${clamp(value * 10, 10, 100)}%"></div></div>
|
|
919
|
+
<span class="sb-val" style="color:${value >= 8 ? "var(--accent)" : value >= 5 ? "var(--blue)" : "var(--amber)"}">${value}</span>
|
|
920
|
+
</div>
|
|
921
|
+
`)
|
|
922
|
+
.join("")}
|
|
923
|
+
</div>
|
|
924
|
+
`
|
|
925
|
+
: `<div class="warning-note" style="margin-top: 12px;">This run does not have a saved score breakdown yet.</div>`}
|
|
926
|
+
</div>
|
|
927
|
+
|
|
928
|
+
<div class="summary-section">
|
|
929
|
+
<div class="ss-label">Top issues</div>
|
|
930
|
+
<div class="issue-list">
|
|
931
|
+
${issues
|
|
932
|
+
.map((issue) => `
|
|
933
|
+
<div class="issue-row">
|
|
934
|
+
<div class="issue-icon ${issue.iconClass}">${escapeHtml(issue.iconLabel)}</div>
|
|
935
|
+
<span class="issue-text">${escapeHtml(issue.text)}</span>
|
|
936
|
+
<span class="issue-cnt">${escapeHtml(issue.countLabel)}</span>
|
|
937
|
+
</div>
|
|
938
|
+
`)
|
|
939
|
+
.join("")}
|
|
940
|
+
</div>
|
|
941
|
+
</div>
|
|
942
|
+
|
|
943
|
+
<div class="summary-section">
|
|
944
|
+
<div class="ss-label">Personas</div>
|
|
945
|
+
<div class="persona-chips">
|
|
946
|
+
${personas.map((persona) => `<span class="p-chip">${escapeHtml(persona)}</span>`).join("")}
|
|
947
|
+
</div>
|
|
948
|
+
</div>
|
|
949
|
+
|
|
950
|
+
<div class="summary-section">
|
|
951
|
+
<div class="ss-label">Activity</div>
|
|
952
|
+
<div class="activity-log">
|
|
953
|
+
${activityItems
|
|
954
|
+
.map((item) => `
|
|
955
|
+
<div class="al-row">
|
|
956
|
+
<span class="al-time">${escapeHtml(item.time)}</span>
|
|
957
|
+
<span class="al-text">${item.idLabel ? `<span class="al-id">${escapeHtml(item.idLabel)}</span> ` : ""}${escapeHtml(item.text)}</span>
|
|
958
|
+
</div>
|
|
959
|
+
`)
|
|
960
|
+
.join("")}
|
|
961
|
+
</div>
|
|
962
|
+
</div>
|
|
963
|
+
</div>
|
|
964
|
+
</aside>
|
|
965
|
+
`;
|
|
966
|
+
}
|
|
967
|
+
function renderRunMetaPanel(detail) {
|
|
968
|
+
const inputs = detail.inputs;
|
|
969
|
+
const hasClickFrames = detail.tasks.some((task) => task.history.some((entry) => entry.result.beforeScreenshotPath || entry.result.afterScreenshotPath));
|
|
970
|
+
const clickReplayHref = buildArtifactHref(detail.id, inputs?.clickReplayArtifact);
|
|
971
|
+
const metaItems = [
|
|
972
|
+
describeBatchRole(inputs?.batchRole ?? "single", inputs?.agentCount ?? 1),
|
|
973
|
+
inputs?.mobile ? "Mobile-sized run" : "Desktop-sized run",
|
|
974
|
+
inputs?.headed ? "Headed browser" : "Headless browser",
|
|
975
|
+
inputs?.llmProvider && inputs?.model
|
|
976
|
+
? `${inputs.llmProvider === "ollama" ? "Ollama" : "OpenAI"} ${inputs.model}`
|
|
977
|
+
: inputs?.model
|
|
978
|
+
? `Model ${inputs.model}`
|
|
979
|
+
: null,
|
|
980
|
+
inputs?.synchronizedTimezone ? `Timezone ${inputs.synchronizedTimezone}` : null,
|
|
981
|
+
`${detail.rawEventCount} raw events`
|
|
982
|
+
].filter((item) => Boolean(item));
|
|
983
|
+
return `
|
|
984
|
+
<section class="panel">
|
|
985
|
+
<div class="panel-head">
|
|
986
|
+
<div>
|
|
987
|
+
<div class="panel-title">Run details</div>
|
|
988
|
+
<div class="panel-sub">The context behind this saved visit</div>
|
|
989
|
+
</div>
|
|
990
|
+
</div>
|
|
991
|
+
<div class="panel-body">
|
|
992
|
+
<div class="helper-row" style="margin-top: 0;">
|
|
993
|
+
${metaItems.map((item) => `<span>${escapeHtml(item)}</span>`).join("")}
|
|
994
|
+
</div>
|
|
995
|
+
${inputs?.siteBrief?.summary
|
|
996
|
+
? `<div class="warning-note" style="margin-top: 12px;"><strong>Site brief.</strong> ${escapeHtml(inputs.siteBrief.summary)}</div>`
|
|
997
|
+
: ""}
|
|
998
|
+
${inputs?.instructionText
|
|
999
|
+
? `<div class="warning-note" style="margin-top: 12px; white-space: pre-wrap;"><strong>Instruction source.</strong>\n${escapeHtml(inputs.instructionText)}</div>`
|
|
1000
|
+
: ""}
|
|
1001
|
+
${hasClickFrames
|
|
1002
|
+
? `<div class="warning-note" style="margin-top: 12px;"><strong>Playwright click frames.</strong> The before/after images in the interaction timeline were captured from the isolated Playwright browser session for this run.</div>`
|
|
1003
|
+
: ""}
|
|
1004
|
+
${renderClickReplayStatus(detail, hasClickFrames)}
|
|
1005
|
+
<div class="link-row">
|
|
1006
|
+
<a class="inline-link" href="/outputs/${encodeURIComponent(detail.id)}" target="_blank" rel="noreferrer">Open standalone HTML output</a>
|
|
1007
|
+
<a class="inline-link" href="/api/runs/${encodeURIComponent(detail.id)}/artifacts/report.html">Download HTML output</a>
|
|
1008
|
+
<a class="inline-link" href="/api/runs/${encodeURIComponent(detail.id)}/artifacts/report.json">Download JSON output</a>
|
|
1009
|
+
${clickReplayHref ? `<a class="inline-link" href="${escapeHtml(clickReplayHref)}" target="_blank" rel="noreferrer">Open click replay</a>` : ""}
|
|
1010
|
+
</div>
|
|
1011
|
+
</div>
|
|
1012
|
+
</section>
|
|
1013
|
+
`;
|
|
1014
|
+
}
|
|
1015
|
+
function renderDetailContent(detail) {
|
|
1016
|
+
const recapParagraphs = buildVisitRecap(detail);
|
|
1017
|
+
const strengths = filterVisitorFacingItems(detail.report?.strengths ?? []);
|
|
1018
|
+
const weaknesses = filterVisitorFacingItems(detail.report?.weaknesses ?? []);
|
|
1019
|
+
const topFixes = filterVisitorFacingItems(detail.report?.top_fixes ?? []);
|
|
1020
|
+
const timezone = detail.inputs?.synchronizedTimezone ?? null;
|
|
1021
|
+
return `
|
|
1022
|
+
<div class="two-col ${state.summaryRailCollapsed ? "summary-rail-collapsed" : ""}" data-summary-layout>
|
|
1023
|
+
<div class="stack">
|
|
1024
|
+
${renderAgentGrid(detail)}
|
|
1025
|
+
|
|
1026
|
+
<section class="panel">
|
|
1027
|
+
<div class="panel-head">
|
|
1028
|
+
<div>
|
|
1029
|
+
<div class="panel-title">How I’d Describe This Visit To A Friend</div>
|
|
1030
|
+
<div class="panel-sub">Built from recorded clicks, pages, and accepted-task outcomes</div>
|
|
1031
|
+
</div>
|
|
1032
|
+
</div>
|
|
1033
|
+
<div class="panel-body">
|
|
1034
|
+
<div class="visit-recap">
|
|
1035
|
+
${recapParagraphs.map((paragraph) => `<p class="visit-recap__line">${escapeHtml(paragraph)}</p>`).join("")}
|
|
1036
|
+
</div>
|
|
1037
|
+
</div>
|
|
1038
|
+
</section>
|
|
1039
|
+
|
|
1040
|
+
<div class="list-grid">
|
|
1041
|
+
${renderListPanel("What felt solid", strengths, "I did not record any standout positives in this run.")}
|
|
1042
|
+
${renderListPanel("Where the visit broke down", weaknesses, "This visit was smoother than expected.")}
|
|
1043
|
+
${renderListPanel("What I would fix first", topFixes, "No top-priority fixes were recorded for this run.")}
|
|
1044
|
+
</div>
|
|
1045
|
+
|
|
1046
|
+
${renderRunMetaPanel(detail)}
|
|
1047
|
+
|
|
1048
|
+
<section class="panel" id="output-details">
|
|
1049
|
+
<div class="panel-head">
|
|
1050
|
+
<div>
|
|
1051
|
+
<div class="panel-title">Interaction breakdown</div>
|
|
1052
|
+
<div class="panel-sub">Each part of the visit, with the recorded step history underneath</div>
|
|
1053
|
+
</div>
|
|
1054
|
+
</div>
|
|
1055
|
+
<div class="panel-body">
|
|
1056
|
+
<div class="task-stack">
|
|
1057
|
+
${detail.tasks.length > 0
|
|
1058
|
+
? detail.tasks.map((task) => renderTaskCard(detail.id, task, timezone)).join("")
|
|
1059
|
+
: `<div class="warning-note">No task breakdown was recorded for this run.</div>`}
|
|
1060
|
+
</div>
|
|
1061
|
+
</div>
|
|
1062
|
+
</section>
|
|
1063
|
+
|
|
1064
|
+
<section class="panel">
|
|
1065
|
+
<div class="panel-head">
|
|
1066
|
+
<div>
|
|
1067
|
+
<div class="panel-title">Accessibility findings</div>
|
|
1068
|
+
<div class="panel-sub">Saved axe results from the same run</div>
|
|
1069
|
+
</div>
|
|
1070
|
+
</div>
|
|
1071
|
+
<div class="panel-body">
|
|
1072
|
+
${renderAccessibility(detail)}
|
|
1073
|
+
</div>
|
|
1074
|
+
</section>
|
|
1075
|
+
</div>
|
|
1076
|
+
|
|
1077
|
+
${renderSummaryRail(detail)}
|
|
1078
|
+
</div>
|
|
1079
|
+
`;
|
|
1080
|
+
}
|
|
1081
|
+
function renderEmptyState() {
|
|
1082
|
+
if (state.loadingRuns && state.runs.length === 0) {
|
|
1083
|
+
return `
|
|
1084
|
+
<div class="two-col">
|
|
1085
|
+
<div class="stack">
|
|
1086
|
+
<section class="panel empty-stack">
|
|
1087
|
+
<div>
|
|
1088
|
+
<h2>Loading dashboard</h2>
|
|
1089
|
+
<p class="muted">Reading saved run artifacts and building the dashboard view.</p>
|
|
1090
|
+
</div>
|
|
1091
|
+
</section>
|
|
1092
|
+
</div>
|
|
1093
|
+
<aside class="panel">
|
|
1094
|
+
<div class="summary-section">
|
|
1095
|
+
<div class="ss-label">Run summary</div>
|
|
1096
|
+
<div class="warning-note">A saved run needs to finish loading before the summary rail can populate.</div>
|
|
1097
|
+
</div>
|
|
1098
|
+
</aside>
|
|
1099
|
+
</div>
|
|
1100
|
+
`;
|
|
1101
|
+
}
|
|
1102
|
+
return `
|
|
1103
|
+
<div class="two-col">
|
|
1104
|
+
<div class="stack">
|
|
1105
|
+
<section class="panel empty-stack">
|
|
1106
|
+
<div>
|
|
1107
|
+
<h2>No outputs yet</h2>
|
|
1108
|
+
<p class="muted">Start a new task run from the home page and the finished run will appear here automatically.</p>
|
|
1109
|
+
<p class="muted"><code>npm run dev -- --url https://example.com</code></p>
|
|
1110
|
+
</div>
|
|
1111
|
+
</section>
|
|
1112
|
+
</div>
|
|
1113
|
+
<aside class="panel">
|
|
1114
|
+
<div class="summary-section">
|
|
1115
|
+
<div class="ss-label">Run summary</div>
|
|
1116
|
+
<div class="warning-note">Pick or create a run to see the score, issues, personas, and activity rail.</div>
|
|
1117
|
+
</div>
|
|
1118
|
+
</aside>
|
|
1119
|
+
</div>
|
|
1120
|
+
`;
|
|
1121
|
+
}
|
|
1122
|
+
function renderMain() {
|
|
1123
|
+
const detail = state.detail;
|
|
1124
|
+
const topbarDate = detail?.inputs?.startedAt ?? state.runs[0]?.startedAt ?? null;
|
|
1125
|
+
const exportHref = detail ? `/api/runs/${encodeURIComponent(detail.id)}/artifacts/report.html` : null;
|
|
1126
|
+
const sidebarToggleLabel = state.sidebarCollapsed ? "Show nav" : "Hide nav";
|
|
1127
|
+
const sidebarToggleIcon = state.sidebarCollapsed ? "▸" : "◂";
|
|
1128
|
+
return `
|
|
1129
|
+
<div class="topbar">
|
|
1130
|
+
<div class="topbar-left">
|
|
1131
|
+
<button class="btn btn-ghost sidebar-toggle" type="button" data-sidebar-toggle aria-expanded="${state.sidebarCollapsed ? "false" : "true"}" aria-label="${escapeHtml(sidebarToggleLabel)}">
|
|
1132
|
+
<span aria-hidden="true">${sidebarToggleIcon}</span>
|
|
1133
|
+
<span class="sidebar-toggle-label">${escapeHtml(sidebarToggleLabel)}</span>
|
|
1134
|
+
</button>
|
|
1135
|
+
<div>
|
|
1136
|
+
<span class="page-title">Dashboard</span>
|
|
1137
|
+
<span class="page-sub">— ${escapeHtml(formatDashboardDate(topbarDate))}</span>
|
|
1138
|
+
</div>
|
|
1139
|
+
</div>
|
|
1140
|
+
<div class="topbar-right">
|
|
1141
|
+
${exportHref
|
|
1142
|
+
? `<a class="btn btn-ghost" href="${escapeHtml(exportHref)}">↓ Export</a>`
|
|
1143
|
+
: `<span class="btn btn-ghost">↓ Export</span>`}
|
|
1144
|
+
<a class="btn btn-primary" href="/">▶ New test run</a>
|
|
1145
|
+
</div>
|
|
1146
|
+
</div>
|
|
1147
|
+
|
|
1148
|
+
<div class="content">
|
|
1149
|
+
${renderMetricsGrid(detail)}
|
|
1150
|
+
${state.error ? `<div class="warning-note">${escapeHtml(state.error)}</div>` : ""}
|
|
1151
|
+
${detail ? renderDetailContent(detail) : renderEmptyState()}
|
|
1152
|
+
</div>
|
|
1153
|
+
`;
|
|
1154
|
+
}
|
|
1155
|
+
function render() {
|
|
1156
|
+
dashboardRoot.innerHTML = `
|
|
1157
|
+
<div class="app ${state.sidebarCollapsed ? "sidebar-collapsed" : ""}">
|
|
1158
|
+
<nav class="sidebar">
|
|
1159
|
+
<div class="logo">
|
|
1160
|
+
<div class="logo-mark"></div>
|
|
1161
|
+
<div>
|
|
1162
|
+
<div class="logo-name">site-agent-pro</div>
|
|
1163
|
+
</div>
|
|
1164
|
+
<span class="logo-beta">β</span>
|
|
1165
|
+
</div>
|
|
1166
|
+
|
|
1167
|
+
<div class="nav-section">
|
|
1168
|
+
<div class="nav-label">Main</div>
|
|
1169
|
+
<a class="nav-item active" href="/dashboard">
|
|
1170
|
+
<span class="nav-icon">◈</span> Dashboard
|
|
1171
|
+
</a>
|
|
1172
|
+
<a class="nav-item" href="/">
|
|
1173
|
+
<span class="nav-icon">⊡</span> New test
|
|
1174
|
+
<span class="nav-badge">${escapeHtml(`${state.runs.length}`)}</span>
|
|
1175
|
+
</a>
|
|
1176
|
+
<a class="nav-item" href="#output-details">
|
|
1177
|
+
<span class="nav-icon">◫</span> Outputs
|
|
1178
|
+
</a>
|
|
1179
|
+
</div>
|
|
1180
|
+
|
|
1181
|
+
<div class="nav-section">
|
|
1182
|
+
<div class="nav-label">Config</div>
|
|
1183
|
+
<a class="nav-item" href="#live-run">
|
|
1184
|
+
<span class="nav-icon">◉</span> Live run
|
|
1185
|
+
</a>
|
|
1186
|
+
<a class="nav-item" href="#saved-runs">
|
|
1187
|
+
<span class="nav-icon">◎</span> Saved runs
|
|
1188
|
+
</a>
|
|
1189
|
+
</div>
|
|
1190
|
+
|
|
1191
|
+
<div class="nav-section run-list-shell" id="saved-runs">
|
|
1192
|
+
<div class="nav-label">Saved runs</div>
|
|
1193
|
+
<div class="run-list">
|
|
1194
|
+
${renderRunList()}
|
|
1195
|
+
</div>
|
|
1196
|
+
</div>
|
|
1197
|
+
|
|
1198
|
+
<div class="sidebar-footer">
|
|
1199
|
+
<div class="workspace">
|
|
1200
|
+
<div class="ws-avatar">AP</div>
|
|
1201
|
+
<div>
|
|
1202
|
+
<div class="ws-name">Site Agent Pro Workspace</div>
|
|
1203
|
+
<div class="ws-plan">${escapeHtml(`${state.runs.length} saved ${state.runs.length === 1 ? "run" : "runs"}`)}</div>
|
|
1204
|
+
</div>
|
|
1205
|
+
</div>
|
|
1206
|
+
</div>
|
|
1207
|
+
</nav>
|
|
1208
|
+
|
|
1209
|
+
<main class="main">
|
|
1210
|
+
${renderMain()}
|
|
1211
|
+
</main>
|
|
1212
|
+
</div>
|
|
1213
|
+
`;
|
|
1214
|
+
}
|
|
1215
|
+
document.addEventListener("click", (event) => {
|
|
1216
|
+
const target = event.target;
|
|
1217
|
+
if (!(target instanceof Element)) {
|
|
1218
|
+
return;
|
|
1219
|
+
}
|
|
1220
|
+
const sidebarToggle = target.closest("[data-sidebar-toggle]");
|
|
1221
|
+
if (sidebarToggle) {
|
|
1222
|
+
state.sidebarCollapsed = !state.sidebarCollapsed;
|
|
1223
|
+
writeSidebarCollapsedPreference(state.sidebarCollapsed);
|
|
1224
|
+
render();
|
|
1225
|
+
return;
|
|
1226
|
+
}
|
|
1227
|
+
const summaryToggle = target.closest("[data-summary-toggle]");
|
|
1228
|
+
if (summaryToggle) {
|
|
1229
|
+
state.summaryRailCollapsed = !state.summaryRailCollapsed;
|
|
1230
|
+
writeSummaryRailCollapsedPreference(state.summaryRailCollapsed);
|
|
1231
|
+
render();
|
|
1232
|
+
return;
|
|
1233
|
+
}
|
|
1234
|
+
const runButton = target.closest("[data-run-id]");
|
|
1235
|
+
if (runButton) {
|
|
1236
|
+
const runId = runButton.dataset.runId;
|
|
1237
|
+
if (runId && runId !== state.selectedRunId) {
|
|
1238
|
+
void loadRunDetail(runId, true);
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
});
|
|
1242
|
+
window.addEventListener("popstate", () => {
|
|
1243
|
+
const requestedRunId = currentRunIdFromUrl();
|
|
1244
|
+
if (!requestedRunId) {
|
|
1245
|
+
const newestRun = state.runs[0];
|
|
1246
|
+
if (newestRun) {
|
|
1247
|
+
void loadRunDetail(newestRun.id, false);
|
|
1248
|
+
}
|
|
1249
|
+
return;
|
|
1250
|
+
}
|
|
1251
|
+
if (requestedRunId !== state.selectedRunId) {
|
|
1252
|
+
void loadRunDetail(requestedRunId, false);
|
|
1253
|
+
}
|
|
1254
|
+
});
|
|
1255
|
+
render();
|
|
1256
|
+
void loadRuns();
|