feat: initial public release (MAESTRO)

This commit is contained in:
oss-sync
2026-06-03 05:08:00 +00:00
commit f5c7666f6b
823 changed files with 184150 additions and 0 deletions
@@ -0,0 +1,329 @@
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { listBrowserSessionProfiles } from '../../api';
export interface ParamHint {
name: string;
valueToReplace: string;
type: 'string' | 'number' | 'boolean';
}
interface SaveAsScriptDialogProps {
/** The name of the recording (without .json extension). */
recordingName: string;
onClose: () => void;
/** Called with the new script filename (e.g. "my-script.js") after a successful compile. */
onSuccess: (scriptName: string) => void;
}
const SCRIPT_NAME_RE = /^[A-Za-z0-9_\-.]+$/;
async function apiCompileScript(body: {
recordingName: string;
scriptName: string;
description: string;
sessionProfileId?: number;
paramHints?: ParamHint[];
overwrite?: boolean;
}): Promise<{ ok: boolean; scriptName: string }> {
const { overwrite, ...rest } = body;
const qs = overwrite ? '?overwrite=true' : '';
const res = await fetch(`/api/users/me/browser-macros/compile${qs}`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(rest),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
const err = new Error((data as { error?: string }).error ?? `HTTP ${res.status}`);
(err as any).status = res.status;
throw err;
}
return res.json();
}
function emptyHint(): ParamHint {
return { name: '', valueToReplace: '', type: 'string' };
}
export function SaveAsScriptDialog({ recordingName, onClose, onSuccess }: SaveAsScriptDialogProps) {
const qc = useQueryClient();
const [scriptName, setScriptName] = useState(recordingName);
const [description, setDescription] = useState('');
const [sessionProfileId, setSessionProfileId] = useState('');
const [paramHints, setParamHints] = useState<ParamHint[]>([]);
const [overwrite, setOverwrite] = useState(false);
const [validationError, setValidationError] = useState<string | null>(null);
const [conflictError, setConflictError] = useState<string | null>(null);
const { data: sessionProfiles = [], isLoading: profilesLoading } = useQuery({
queryKey: ['browser-session-profiles'],
queryFn: listBrowserSessionProfiles,
staleTime: 60 * 1000,
});
const activeSessionProfiles = sessionProfiles.filter(p => p.status === 'active');
const compileMutation = useMutation({
mutationFn: apiCompileScript,
onSuccess: (data) => {
// Invalidate browser-macros list so the new file appears
qc.invalidateQueries({ queryKey: ['userfolder', 'list', 'browser-macros'] });
onSuccess(data.scriptName);
},
onError: (err: any) => {
if (err.status === 409) {
setConflictError('Script already exists; check the overwrite box and retry.');
}
// Other errors surface via compileMutation.error
},
});
function validate(): boolean {
if (!scriptName.trim()) {
setValidationError('Script name is required.');
return false;
}
const nameWithoutExt = scriptName.endsWith('.js') ? scriptName.slice(0, -3) : scriptName;
if (!SCRIPT_NAME_RE.test(nameWithoutExt)) {
setValidationError('Script name may only contain letters, numbers, dashes, underscores, and dots.');
return false;
}
if (!description.trim()) {
setValidationError('Description is required.');
return false;
}
for (let i = 0; i < paramHints.length; i++) {
const h = paramHints[i]!;
if (!h.name.trim() || !h.valueToReplace.trim()) {
setValidationError(`Param hint #${i + 1} must have a name and value to replace.`);
return false;
}
}
setValidationError(null);
setConflictError(null);
return true;
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!validate()) return;
const profileId = sessionProfileId.trim() !== '' ? parseInt(sessionProfileId, 10) : undefined;
compileMutation.mutate({
recordingName,
scriptName: scriptName.trim(),
description: description.trim(),
sessionProfileId: profileId !== undefined && !isNaN(profileId) ? profileId : undefined,
paramHints: paramHints.length > 0 ? paramHints : undefined,
overwrite,
});
}
function addHint() {
setParamHints(prev => [...prev, emptyHint()]);
}
function updateHint(idx: number, patch: Partial<ParamHint>) {
setParamHints(prev => prev.map((h, i) => i === idx ? { ...h, ...patch } : h));
}
function removeHint(idx: number) {
setParamHints(prev => prev.filter((_, i) => i !== idx));
}
const isSubmitting = compileMutation.isPending;
const submitError = compileMutation.isError && !conflictError
? ((compileMutation.error as any)?.message ?? 'Compile failed')
: null;
return (
/* Backdrop */
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40"
onClick={e => { if (e.target === e.currentTarget) onClose(); }}
>
<div className="bg-white rounded-xl shadow-xl w-full max-w-lg mx-4 overflow-hidden flex flex-col max-h-[90vh]">
{/* Header */}
<div className="flex items-center gap-3 px-5 py-4 border-b border-hairline">
<span className="text-sm font-semibold text-slate-800 flex-1">Save as Script</span>
<button
type="button"
onClick={onClose}
className="w-6 h-6 flex items-center justify-center rounded hover:bg-surface-2 text-slate-400 hover:text-slate-700 transition-colors"
aria-label="Close"
>
<svg viewBox="0 0 16 16" className="w-3.5 h-3.5" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<path d="M3 3l10 10M13 3L3 13" />
</svg>
</button>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="flex flex-col gap-4 px-5 py-4 overflow-y-auto flex-1">
{/* Recording name (read-only) */}
<div className="flex flex-col gap-1">
<label className="text-2xs font-semibold text-slate-500 uppercase tracking-wide">
Recording
</label>
<div className="px-3 py-2 rounded-md bg-surface-2 text-xs font-mono text-slate-700 border border-hairline">
{recordingName}.json
</div>
</div>
{/* Script name */}
<div className="flex flex-col gap-1">
<label htmlFor="sas-script-name" className="text-2xs font-semibold text-slate-500 uppercase tracking-wide">
Script name <span className="text-red-500">*</span>
</label>
<input
id="sas-script-name"
type="text"
value={scriptName}
onChange={e => setScriptName(e.target.value)}
placeholder="my-script"
className="px-3 py-2 rounded-md border border-hairline text-[13px] font-mono focus:outline-none focus:ring-2 focus:ring-accent/30 focus:border-accent"
/>
<span className="text-[10px] text-slate-400">Alphanumeric, dashes, underscores, dots. A .js extension will be added automatically.</span>
</div>
{/* Description */}
<div className="flex flex-col gap-1">
<label htmlFor="sas-description" className="text-2xs font-semibold text-slate-500 uppercase tracking-wide">
Description <span className="text-red-500">*</span>
</label>
<textarea
id="sas-description"
value={description}
onChange={e => setDescription(e.target.value)}
rows={3}
placeholder="What does this script do?"
className="px-3 py-2 rounded-md border border-hairline text-[13px] focus:outline-none focus:ring-2 focus:ring-accent/30 focus:border-accent resize-none"
/>
</div>
{/* Session profile */}
<div className="flex flex-col gap-1">
<label htmlFor="sas-session-profile" className="text-2xs font-semibold text-slate-500 uppercase tracking-wide">
Session profile <span className="text-slate-400 font-normal">(optional)</span>
</label>
{profilesLoading ? (
<div className="px-3 py-2 text-xs text-slate-500">Loading profiles</div>
) : activeSessionProfiles.length === 0 ? (
<div className="px-3 py-2 text-xs text-slate-500">
No active session profiles. Create one in the Browser tab to enable authenticated automation.
</div>
) : (
<select
id="sas-session-profile"
value={sessionProfileId}
onChange={e => setSessionProfileId(e.target.value)}
className="px-3 py-2 rounded-md border border-hairline text-[13px] focus:outline-none focus:ring-2 focus:ring-accent/30 focus:border-accent"
>
<option value="">None</option>
{activeSessionProfiles.map(p => (
<option key={p.id} value={String(p.id)}>{p.label} (#{p.id})</option>
))}
</select>
)}
</div>
{/* Param hints */}
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<span className="text-2xs font-semibold text-slate-500 uppercase tracking-wide flex-1">
Param hints <span className="text-slate-400 font-normal">(optional)</span>
</span>
<button
type="button"
onClick={addHint}
className="text-2xs px-2 py-1 rounded border border-hairline text-slate-600 hover:bg-surface-2 transition-colors"
>
+ Add hint
</button>
</div>
{paramHints.map((hint, idx) => (
<div key={idx} className="flex gap-2 items-start p-2 rounded-md border border-hairline bg-surface-2">
<div className="flex flex-col gap-1 flex-1 min-w-0">
<input
type="text"
value={hint.name}
onChange={e => updateHint(idx, { name: e.target.value })}
placeholder="param name"
className="px-2 py-1 rounded border border-hairline text-2xs font-mono bg-white focus:outline-none focus:ring-1 focus:ring-accent/30"
/>
<input
type="text"
value={hint.valueToReplace}
onChange={e => updateHint(idx, { valueToReplace: e.target.value })}
placeholder="value to replace (literal)"
className="px-2 py-1 rounded border border-hairline text-2xs font-mono bg-white focus:outline-none focus:ring-1 focus:ring-accent/30"
/>
<select
value={hint.type}
onChange={e => updateHint(idx, { type: e.target.value as ParamHint['type'] })}
className="px-2 py-1 rounded border border-hairline text-2xs bg-white focus:outline-none focus:ring-1 focus:ring-accent/30"
>
<option value="string">string</option>
<option value="number">number</option>
<option value="boolean">boolean</option>
</select>
</div>
<button
type="button"
onClick={() => removeHint(idx)}
aria-label="Remove hint"
className="mt-1 w-5 h-5 flex-shrink-0 flex items-center justify-center rounded hover:bg-red-100 hover:text-red-600 text-slate-400 transition-colors"
>
<svg viewBox="0 0 16 16" className="w-3 h-3" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<path d="M4 4l8 8M12 4l-8 8" />
</svg>
</button>
</div>
))}
</div>
{/* Overwrite checkbox */}
<label className="flex items-center gap-2 cursor-pointer select-none">
<input
type="checkbox"
checked={overwrite}
onChange={e => { setOverwrite(e.target.checked); setConflictError(null); }}
className="rounded border-hairline accent-accent"
/>
<span className="text-xs text-slate-700">Overwrite if script already exists</span>
</label>
{/* Errors */}
{(validationError || conflictError || submitError) && (
<div className="px-3 py-2 rounded-md bg-red-50 border border-red-200 text-xs text-red-700">
{validationError ?? conflictError ?? submitError}
</div>
)}
</form>
{/* Footer */}
<div className="flex items-center justify-end gap-2 px-5 py-3 border-t border-hairline bg-surface-2/50">
<button
type="button"
onClick={onClose}
disabled={isSubmitting}
className="px-4 py-1.5 rounded-md text-xs font-medium text-slate-700 hover:bg-surface-2 disabled:opacity-50 transition-colors border border-hairline"
>
Cancel
</button>
<button
type="submit"
form=""
onClick={handleSubmit}
disabled={isSubmitting}
className="px-4 py-1.5 rounded-md text-xs font-semibold bg-accent text-accent-fg hover:bg-accent-deep disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{isSubmitting ? 'Compiling…' : 'Save as Script'}
</button>
</div>
</div>
</div>
);
}