Add automation framework, typing, and runner for the autobuyer
Turns the autobuyer from a page scraper into something that acts. A dashboard button queues a run; a desktop process executes it against the real browser. lib/automations.ts — automations are declarative step lists nested inside the firm whose site they drive. Steps are click / type / wait / navigate, and they inherit the firm's tab pattern and URL, so one firm's automation can't act on another's tab. Adding a button means adding an entry here; the page renders buttons from the API and the runner receives steps from the server, so neither needs editing. Runs key on firm:automation — every firm will plausibly have its own "buy-accounts", and a bare id would resolve to the wrong one. clicker/runner.py — the daemon behind the buttons. Claims a queued run, works through the steps, reports each one back for the page's live log. Only one run executes at a time: two processes driving one physical mouse would interleave clicks. Heartbeats on its own thread, because a step can block for tens of seconds and folding the beat into the main loop would show the runner as offline in the middle of the run it was executing. clicker/actions.py — one implementation of the safety checks, shared by the CLI and the runner. Refuses to act when the element is covered by an overlay, when coordinates fall off-screen, when the browser can't be confirmed frontmost, or (for type) when the target isn't an editable field. Typing: uneven human cadence, and the field is read back afterwards and compared against what was typed — a field that never took focus fails silently and looks identical to success otherwise. Non-ASCII is rejected because pyautogui skips those characters without complaint, and newlines because Enter may submit the form. Typos are deliberately not simulated: a mistyped digit in a trading form is a real loss, and the correction is the part that can go wrong. Extension: opens the firm's page when no tab matches, navigates to a specific page for a navigate step (skipped when already there, so page state survives), and retries the locate while a freshly loaded React app mounts — `complete` only means the document loaded. Staleness reporting, after it cost three debugging rounds: Chrome doesn't reload an unpacked extension and Python doesn't reload a running process, so both now report their version. A stale runner gets a red banner naming both versions and the automation buttons are disabled, rather than failing mid-run on a step type it predates. Scale detection is now conservative: a raw OS/browser width ratio is only trusted when it lands on a real scaling factor. On this multi-monitor desktop the previous logic would have silently halved every coordinate. Verified end to end against the live browser: navigate, locate, and a real click (run #12, all three steps). API round-trips, claim-once semantics, run cancellation, the heartbeat online/offline lifecycle, motion geometry and timing, focus activation, and typing verification all pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
54221bbc0c
commit
b748f95372
@@ -0,0 +1,53 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
import { FIRMS, findAutomation, automationKey, describeStep } from '@/lib/automations';
|
||||
import { createRun, getSetting } from '@/lib/db';
|
||||
import { corsJson, corsPreflight } from '../cors';
|
||||
|
||||
/** The dashboard renders a tab per firm, and a button per automation inside it. */
|
||||
export async function GET() {
|
||||
return corsJson({
|
||||
firms: FIRMS.map((firm) => ({
|
||||
id: firm.id,
|
||||
label: firm.label,
|
||||
urlPattern: firm.urlPattern,
|
||||
automations: firm.automations.map((a) => ({
|
||||
key: automationKey(firm.id, a.id),
|
||||
id: a.id,
|
||||
label: a.label,
|
||||
description: a.description,
|
||||
confirm: a.confirm ?? null,
|
||||
steps: a.steps.map(describeStep),
|
||||
})),
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
/** Queue a run. The Python runner picks it up. */
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json() as { automationId?: unknown };
|
||||
if (typeof body.automationId !== 'string') {
|
||||
return corsJson({ error: '`automationId` is required' }, { status: 400 });
|
||||
}
|
||||
const found = findAutomation(body.automationId);
|
||||
if (!found) {
|
||||
return corsJson({ error: `No automation "${body.automationId}"` }, { status: 404 });
|
||||
}
|
||||
if (found.automation.steps.length === 0) {
|
||||
return corsJson({ error: `"${found.automation.label}" has no steps yet` }, { status: 400 });
|
||||
}
|
||||
// The dashboard switch is the master arm for anything that drives the mouse.
|
||||
if (getSetting('autobuyer_enabled') !== '1') {
|
||||
return corsJson({ error: 'AutoBuyer is switched off' }, { status: 409 });
|
||||
}
|
||||
|
||||
const run = createRun(body.automationId, found.automation.steps.length);
|
||||
return corsJson({ runId: run.id, totalSteps: run.total_steps });
|
||||
} catch (err: any) {
|
||||
return corsJson({ error: err?.message ?? 'Failed to queue run' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function OPTIONS() {
|
||||
return corsPreflight();
|
||||
}
|
||||
@@ -12,6 +12,8 @@ export async function POST() {
|
||||
selector: row.selector,
|
||||
index: row.match_index,
|
||||
urlPattern: row.url_pattern,
|
||||
openUrl: row.open_url,
|
||||
navigateUrl: row.navigate_url,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5,14 +5,16 @@ import { corsJson, corsPreflight } from '../cors';
|
||||
/** Python enqueues "find this selector and tell me where it is on screen". */
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json() as { selector?: unknown; index?: unknown; urlPattern?: unknown };
|
||||
const body = await req.json() as { selector?: unknown; index?: unknown; urlPattern?: unknown; openUrl?: unknown; navigateUrl?: unknown };
|
||||
if (typeof body.selector !== 'string' || !body.selector.trim()) {
|
||||
return corsJson({ error: '`selector` is required' }, { status: 400 });
|
||||
}
|
||||
const index = Number.isInteger(body.index) ? Number(body.index) : 0;
|
||||
const urlPattern = typeof body.urlPattern === 'string' ? body.urlPattern : '';
|
||||
const openUrl = typeof body.openUrl === 'string' ? body.openUrl : '';
|
||||
const navigateUrl = typeof body.navigateUrl === 'string' ? body.navigateUrl : '';
|
||||
|
||||
const row = createLocateRequest(body.selector.trim(), index, urlPattern);
|
||||
const row = createLocateRequest(body.selector.trim(), index, urlPattern, openUrl, navigateUrl);
|
||||
return corsJson({ id: row.id, status: row.status });
|
||||
} catch (err: any) {
|
||||
return corsJson({ error: err?.message ?? 'Failed to queue request' }, { status: 500 });
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
import { setRunnerHeartbeat, getRunnerHeartbeat, RUNNER_TIMEOUT_MS, RUNNER_EXPECTED_VERSION } from '@/lib/db';
|
||||
import { corsJson, corsPreflight } from '../cors';
|
||||
|
||||
/** The runner checks in. Timestamped server-side so a runner with a skewed clock
|
||||
* doesn't read as permanently offline (or permanently online). */
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json().catch(() => ({})) as Record<string, unknown>;
|
||||
setRunnerHeartbeat({
|
||||
host: typeof body.host === 'string' ? body.host : undefined,
|
||||
pid: typeof body.pid === 'number' ? body.pid : undefined,
|
||||
dryRun: body.dryRun === true,
|
||||
version: typeof body.version === 'string' ? body.version : undefined,
|
||||
busy: body.busy === true,
|
||||
});
|
||||
return corsJson({ ok: true });
|
||||
} catch (err: any) {
|
||||
return corsJson({ error: err?.message ?? 'Failed to record heartbeat' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const hb = getRunnerHeartbeat();
|
||||
if (!hb) return corsJson({ online: false, lastSeen: null, ageMs: null });
|
||||
|
||||
const ageMs = Date.now() - hb.at;
|
||||
return corsJson({
|
||||
online: ageMs < RUNNER_TIMEOUT_MS,
|
||||
lastSeen: hb.at,
|
||||
ageMs,
|
||||
host: hb.host ?? null,
|
||||
pid: hb.pid ?? null,
|
||||
dryRun: !!hb.dryRun,
|
||||
busy: !!hb.busy,
|
||||
version: hb.version ?? null,
|
||||
expectedVersion: RUNNER_EXPECTED_VERSION,
|
||||
stale: (hb.version ?? '') !== RUNNER_EXPECTED_VERSION,
|
||||
});
|
||||
}
|
||||
|
||||
export async function OPTIONS() {
|
||||
return corsPreflight();
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { claimRun, getSetting } from '@/lib/db';
|
||||
import { findAutomation, resolveSteps } from '@/lib/automations';
|
||||
import { corsJson, corsPreflight } from '../../cors';
|
||||
|
||||
/** The Python runner polls this. Returns the run plus the steps to execute, so
|
||||
* the runner never needs its own copy of the automation definitions. */
|
||||
export async function POST() {
|
||||
if (getSetting('autobuyer_enabled') !== '1') {
|
||||
return corsJson({ run: null, reason: 'AutoBuyer is switched off' });
|
||||
}
|
||||
|
||||
const row = claimRun();
|
||||
if (!row) return corsJson({ run: null });
|
||||
|
||||
const found = findAutomation(row.automation_id);
|
||||
if (!found) {
|
||||
return corsJson({ run: null, reason: `Unknown automation ${row.automation_id}` });
|
||||
}
|
||||
|
||||
return corsJson({
|
||||
run: {
|
||||
id: row.id,
|
||||
automationId: row.automation_id,
|
||||
firm: found.firm.label,
|
||||
label: found.automation.label,
|
||||
steps: resolveSteps(found.firm, found.automation),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function OPTIONS() {
|
||||
return corsPreflight();
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
import { appendRunLog, finishRun, getRun } from '@/lib/db';
|
||||
import { corsJson, corsPreflight } from '../../cors';
|
||||
|
||||
interface ProgressBody {
|
||||
id?: unknown;
|
||||
stepIndex?: unknown;
|
||||
step?: unknown;
|
||||
ok?: unknown;
|
||||
detail?: unknown;
|
||||
/** Present only on the final call. */
|
||||
finish?: unknown;
|
||||
error?: unknown;
|
||||
}
|
||||
|
||||
/** The runner reports each step, then a final finish. */
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json() as ProgressBody;
|
||||
const id = Number(body.id);
|
||||
const row = getRun(id);
|
||||
if (!row) return corsJson({ error: 'No such run' }, { status: 404 });
|
||||
|
||||
if (typeof body.step === 'string') {
|
||||
appendRunLog(id, {
|
||||
at: Date.now(),
|
||||
step: body.step,
|
||||
ok: body.ok !== false,
|
||||
detail: typeof body.detail === 'string' ? body.detail : undefined,
|
||||
}, Number(body.stepIndex) || row.step_index);
|
||||
}
|
||||
|
||||
if (body.finish === true) {
|
||||
const error = typeof body.error === 'string' && body.error ? body.error : null;
|
||||
finishRun(id, error ? 'error' : 'done', error);
|
||||
}
|
||||
|
||||
// The runner reads this to notice the Stop button between steps.
|
||||
const now = getRun(id);
|
||||
return corsJson({ ok: true, status: now?.status ?? 'error' });
|
||||
} catch (err: any) {
|
||||
return corsJson({ error: err?.message ?? 'Failed to record progress' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function OPTIONS() {
|
||||
return corsPreflight();
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
import { getRun, getRecentRuns, cancelRun } from '@/lib/db';
|
||||
import { findAutomation } from '@/lib/automations';
|
||||
import { corsJson, corsPreflight } from '../cors';
|
||||
|
||||
function shape(row: NonNullable<ReturnType<typeof getRun>>) {
|
||||
return {
|
||||
id: row.id,
|
||||
automationId: row.automation_id,
|
||||
automationLabel: findAutomation(row.automation_id)?.automation.label ?? row.automation_id,
|
||||
status: row.status,
|
||||
stepIndex: row.step_index,
|
||||
totalSteps: row.total_steps,
|
||||
log: (() => { try { return JSON.parse(row.log); } catch { return []; } })(),
|
||||
error: row.error,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
/** `?id=` for one run, otherwise the recent history the dashboard shows. */
|
||||
export async function GET(req: NextRequest) {
|
||||
const idParam = req.nextUrl.searchParams.get('id');
|
||||
if (idParam) {
|
||||
const row = getRun(Number(idParam));
|
||||
if (!row) return corsJson({ error: 'No such run' }, { status: 404 });
|
||||
return corsJson({ run: shape(row) });
|
||||
}
|
||||
return corsJson({ runs: getRecentRuns(5).map(shape) });
|
||||
}
|
||||
|
||||
/** Stop button. A queued run never starts; a running one halts at the next step. */
|
||||
export async function DELETE(req: NextRequest) {
|
||||
const id = Number(req.nextUrl.searchParams.get('id'));
|
||||
if (!Number.isInteger(id) || id <= 0) {
|
||||
return corsJson({ error: '`id` is required' }, { status: 400 });
|
||||
}
|
||||
return corsJson({ cancelled: cancelRun(id) });
|
||||
}
|
||||
|
||||
export async function OPTIONS() {
|
||||
return corsPreflight();
|
||||
}
|
||||
@@ -34,6 +34,64 @@ interface Capture {
|
||||
capturedAt: number;
|
||||
}
|
||||
|
||||
interface AutomationSummary {
|
||||
/** firm:automation — what runs are recorded against. */
|
||||
key: string;
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
confirm: string | null;
|
||||
steps: string[];
|
||||
}
|
||||
|
||||
interface FirmSummary {
|
||||
id: string;
|
||||
label: string;
|
||||
urlPattern: string;
|
||||
automations: AutomationSummary[];
|
||||
}
|
||||
|
||||
interface RunLogEntry {
|
||||
at: number;
|
||||
step: string;
|
||||
ok: boolean;
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
interface Run {
|
||||
id: number;
|
||||
automationId: string;
|
||||
automationLabel: string;
|
||||
status: 'queued' | 'running' | 'done' | 'error' | 'cancelled';
|
||||
stepIndex: number;
|
||||
totalSteps: number;
|
||||
log: RunLogEntry[];
|
||||
error: string | null;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
interface RunnerStatus {
|
||||
online: boolean;
|
||||
lastSeen: number | null;
|
||||
ageMs: number | null;
|
||||
host?: string | null;
|
||||
pid?: number | null;
|
||||
dryRun?: boolean;
|
||||
busy?: boolean;
|
||||
version?: string | null;
|
||||
expectedVersion?: string;
|
||||
stale?: boolean;
|
||||
}
|
||||
|
||||
const RUN_BADGE: Record<Run['status'], string> = {
|
||||
queued: 'bg-slate-100 text-slate-600',
|
||||
running: 'bg-blue-100 text-blue-700',
|
||||
done: 'bg-green-100 text-green-700',
|
||||
error: 'bg-red-100 text-red-700',
|
||||
cancelled: 'bg-amber-100 text-amber-700',
|
||||
};
|
||||
|
||||
const POLL_MS = 2000;
|
||||
|
||||
/** Point relative URLs (stylesheets, images, fonts) at the origin the snapshot came
|
||||
@@ -57,6 +115,11 @@ export default function AutoBuyer() {
|
||||
// reload the frame that often. So the rendered view shows a snapshot pinned when you
|
||||
// opened it — clicking Rendered again re-pins to the latest capture.
|
||||
const [pinned, setPinned] = useState<Capture | null>(null);
|
||||
const [firms, setFirms] = useState<FirmSummary[]>([]);
|
||||
const [activeFirm, setActiveFirm] = useState<string | null>(null);
|
||||
const [runs, setRuns] = useState<Run[]>([]);
|
||||
const [busyRun, setBusyRun] = useState(false);
|
||||
const [runner, setRunner] = useState<RunnerStatus | null>(null);
|
||||
|
||||
// Tracks the newest capture we already hold, so the poll can skip re-downloading it.
|
||||
const lastAtRef = useRef(0);
|
||||
@@ -87,6 +150,67 @@ export default function AutoBuyer() {
|
||||
return () => clearInterval(id);
|
||||
}, [poll]);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/autobuyer/automations')
|
||||
.then((r) => r.json())
|
||||
.then((d) => {
|
||||
const list: FirmSummary[] = d.firms ?? [];
|
||||
setFirms(list);
|
||||
setActiveFirm((current) => current ?? list[0]?.id ?? null);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
// Runs tick faster than the capture poll — a run is something you watch.
|
||||
const pollRuns = useCallback(async () => {
|
||||
try {
|
||||
const [d, r] = await Promise.all([
|
||||
fetch('/api/autobuyer/runs', { cache: 'no-store' }).then((x) => x.json()),
|
||||
fetch('/api/autobuyer/runner', { cache: 'no-store' }).then((x) => x.json()),
|
||||
]);
|
||||
setRuns(d.runs ?? []);
|
||||
setRunner(r);
|
||||
} catch { /* transient; the next tick retries */ }
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
pollRuns();
|
||||
const id = setInterval(pollRuns, 1000);
|
||||
return () => clearInterval(id);
|
||||
}, [pollRuns]);
|
||||
|
||||
const activeRun = runs.find((r) => r.status === 'queued' || r.status === 'running') ?? null;
|
||||
const firmAutomations = firms.find((f) => f.id === activeFirm)?.automations ?? [];
|
||||
|
||||
async function startRun(automation: AutomationSummary) {
|
||||
// Anything that drives the real mouse against a broker gets a confirmation.
|
||||
if (automation.confirm && !window.confirm(
|
||||
`${automation.confirm}\n\nSteps:\n${automation.steps.map((s, i) => `${i + 1}. ${s}`).join('\n')}`
|
||||
)) return;
|
||||
|
||||
setBusyRun(true);
|
||||
try {
|
||||
const res = await fetch('/api/autobuyer/automations', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ automationId: automation.key }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) setError(data.error ?? 'Failed to start');
|
||||
else setError(null);
|
||||
await pollRuns();
|
||||
} catch (err: any) {
|
||||
setError(err?.message ?? 'Failed to start');
|
||||
} finally {
|
||||
setBusyRun(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelRun(id: number) {
|
||||
await fetch(`/api/autobuyer/runs?id=${id}`, { method: 'DELETE' });
|
||||
await pollRuns();
|
||||
}
|
||||
|
||||
async function toggle() {
|
||||
setBusy(true);
|
||||
try {
|
||||
@@ -153,6 +277,158 @@ export default function AutoBuyer() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── One tab per firm ── */}
|
||||
<div className="flex items-end gap-5 mb-3 border-b border-slate-200">
|
||||
{firms.map((firm) => (
|
||||
<button
|
||||
key={firm.id}
|
||||
onClick={() => setActiveFirm(firm.id)}
|
||||
className={`text-sm font-semibold uppercase tracking-wider pb-2 -mb-px border-b-2 transition-colors ${
|
||||
firm.id === activeFirm
|
||||
? 'text-slate-900 border-slate-900'
|
||||
: 'text-slate-400 border-transparent hover:text-slate-600'
|
||||
}`}
|
||||
>
|
||||
{firm.label}
|
||||
</button>
|
||||
))}
|
||||
<span className="ml-auto flex items-center gap-1.5 text-xs pb-2">
|
||||
<span className={`w-2 h-2 rounded-full ${
|
||||
runner?.online ? (runner.busy ? 'bg-blue-500 animate-pulse' : 'bg-green-500') : 'bg-slate-300'
|
||||
}`} />
|
||||
<span className={runner?.online ? 'text-slate-500' : 'text-slate-400'}>
|
||||
{!runner?.online
|
||||
? 'Runner offline'
|
||||
: runner.busy ? 'Runner busy' : 'Runner online'}
|
||||
</span>
|
||||
{runner?.online && runner.stale && (
|
||||
<div className="mb-3 rounded-lg border border-red-200 bg-red-50 px-4 py-2.5 text-xs text-red-800">
|
||||
The runner is out of date (reporting v{runner.version ?? 'unknown'}, this
|
||||
dashboard needs v{runner.expectedVersion}). It will fail on step types it
|
||||
predates. Restart it: stop with Ctrl-C and run{' '}
|
||||
<code className="font-mono">python clicker/runner.py</code> again.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{runner?.online && runner.dryRun && (
|
||||
<span className="ml-1 px-1.5 py-0.5 rounded bg-amber-100 text-amber-700 font-medium">
|
||||
dry run
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{!runner?.online && (
|
||||
<div className="mb-3 rounded-lg border border-slate-200 bg-slate-50 px-4 py-2.5 text-xs text-slate-600">
|
||||
Nothing will run until the runner is started — it's the process that
|
||||
moves the mouse. In the project directory:{' '}
|
||||
<code className="font-mono text-slate-800">python clicker/runner.py</code>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{runner?.online && runner.stale && (
|
||||
<div className="mb-3 rounded-lg border border-red-200 bg-red-50 px-4 py-2.5 text-xs text-red-800">
|
||||
The runner is out of date (reporting v{runner.version ?? 'unknown'}, this
|
||||
dashboard needs v{runner.expectedVersion}). It will fail on step types it
|
||||
predates. Restart it: stop with Ctrl-C and run{' '}
|
||||
<code className="font-mono">python clicker/runner.py</code> again.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{runner?.online && runner.dryRun && (
|
||||
<div className="mb-3 rounded-lg border border-amber-200 bg-amber-50 px-4 py-2.5 text-xs text-amber-800">
|
||||
The runner is in dry-run mode: it will walk through the steps and move the
|
||||
cursor, but never press or type. Restart it without <code className="font-mono">--dry-run</code> to act for real.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-white border border-slate-200 rounded-xl shadow-sm mb-8">
|
||||
{firmAutomations.length === 0 ? (
|
||||
<div className="px-4 py-6 text-center text-sm text-slate-400">
|
||||
{firms.length === 0 ? 'No firms configured' : 'No automations for this firm yet'}
|
||||
</div>
|
||||
) : firmAutomations.map((a, i) => (
|
||||
<div
|
||||
key={a.key}
|
||||
className={`flex items-center justify-between px-4 py-3 ${
|
||||
i < firmAutomations.length - 1 ? 'border-b border-slate-100' : ''
|
||||
}`}
|
||||
>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-800">{a.label}</p>
|
||||
<p className="text-xs text-slate-400 mt-0.5">
|
||||
{a.description} · {a.steps.length} step{a.steps.length === 1 ? '' : 's'}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => startRun(a)}
|
||||
disabled={!enabled || busyRun || activeRun !== null || !runner?.online || !!runner?.stale}
|
||||
title={
|
||||
!enabled ? 'AutoBuyer is switched off'
|
||||
: !runner?.online ? 'The runner is not running'
|
||||
: runner?.stale ? 'The runner is out of date — restart it'
|
||||
: activeRun !== null ? 'A run is already in progress'
|
||||
: undefined
|
||||
}
|
||||
className="text-sm px-4 py-1.5 rounded-lg font-medium bg-blue-500 hover:bg-blue-600 text-white transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
{a.label}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ── Run status ── */}
|
||||
{runs.length > 0 && (
|
||||
<>
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wider text-slate-500 mb-3">
|
||||
Runs
|
||||
</h2>
|
||||
<div className="bg-white border border-slate-200 rounded-xl shadow-sm mb-8 divide-y divide-slate-100">
|
||||
{runs.map((run) => (
|
||||
<div key={run.id} className="px-4 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`text-xs font-medium px-2 py-0.5 rounded ${RUN_BADGE[run.status]}`}>
|
||||
{run.status}
|
||||
</span>
|
||||
<span className="text-sm text-slate-800">{run.automationLabel}</span>
|
||||
<span className="text-xs font-mono text-slate-400">
|
||||
step {Math.min(run.stepIndex + (run.status === 'done' ? 0 : 1), run.totalSteps)}/{run.totalSteps}
|
||||
</span>
|
||||
<span className="ml-auto text-xs text-slate-400">
|
||||
{new Date(run.createdAt).toLocaleTimeString()}
|
||||
</span>
|
||||
{(run.status === 'queued' || run.status === 'running') && (
|
||||
<button
|
||||
onClick={() => cancelRun(run.id)}
|
||||
className="text-xs text-slate-400 hover:text-red-500 transition-colors"
|
||||
>
|
||||
Stop
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{run.log.length > 0 && (
|
||||
<ol className="mt-2 space-y-0.5">
|
||||
{run.log.map((entry, i) => (
|
||||
<li key={i} className="text-xs font-mono text-slate-500">
|
||||
<span className={entry.ok ? 'text-green-600' : 'text-red-500'}>
|
||||
{entry.ok ? '✓' : '✗'}
|
||||
</span>{' '}
|
||||
{entry.step}
|
||||
{entry.detail && <span className="text-slate-400"> — {entry.detail}</span>}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
{run.error && (
|
||||
<p className="mt-1 text-xs text-red-600">{run.error}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Latest capture ── */}
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wider text-slate-500 mb-3">
|
||||
Latest capture
|
||||
|
||||
Reference in New Issue
Block a user