refactor(workspace): restore yaml exports and align docker builds

- return scenario export payloads as yaml to match existing workflows

- harden step reordering flow for drag-and-drop and one-based ui labels

- switch server container to workspace-lockfile installs and root ignore rules
This commit is contained in:
2026-04-10 18:19:38 +03:00
parent 259dac806e
commit de0cbeef7c
18 changed files with 361 additions and 11385 deletions
@@ -1,7 +1,7 @@
import { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Play, Pencil, Plus, History, Trash2, Upload } from 'lucide-react';
import { Play, Pencil, Plus, History, Trash2, Upload, GripVertical } from 'lucide-react';
import { stringify as yamlStringify } from 'yaml';
import { scenarios, steps, scenarioCredentials, credentials as credentialsApi } from '../../api';
import type { Scenario, ScenarioStep, ScenarioCredential, Credential } from '../../api';
@@ -37,10 +37,13 @@ export function ScenarioDetailPage() {
const [addCredError, setAddCredError] = useState('');
const [addAliasError, setAddAliasError] = useState('');
const [addCredSaving, setAddCredSaving] = useState(false);
const [draggedStepId, setDraggedStepId] = useState<string | null>(null);
const [dragOverStepId, setDragOverStepId] = useState<string | null>(null);
const [reorderingSteps, setReorderingSteps] = useState(false);
useEffect(() => {
if (!id) return;
scenarios
void scenarios
.get(id)
.then((s) => {
setScenario(s);
@@ -54,6 +57,13 @@ export function ScenarioDetailPage() {
.catch(() => {});
}, [id]);
const reloadScenario = async () => {
if (!id) return;
const s = await scenarios.get(id);
setScenario(s);
setScenarioCreds(s.scenarioCredentials ?? []);
};
const handleRun = async () => {
if (!scenario) return;
const run = await scenarios.run(scenario.id);
@@ -69,7 +79,8 @@ export function ScenarioDetailPage() {
const handleExport = async () => {
if (!scenario) return;
const data = await scenarios.exportScenario(scenario.id);
const blob = new Blob([yamlStringify(data)], { type: 'application/yaml' });
const yamlContent = typeof data === 'string' ? data : yamlStringify(data);
const blob = new Blob([yamlContent], { type: 'application/yaml' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
@@ -80,9 +91,21 @@ export function ScenarioDetailPage() {
const handleDeleteStep = async (stepId: string) => {
await steps.remove(id!, stepId);
setScenario((prev) =>
prev ? { ...prev, steps: prev.steps.filter((s) => s.id !== stepId) } : prev,
);
await reloadScenario();
};
const handleMoveStep = async (fromStepId: string, toStepId: string) => {
if (!scenario || reorderingSteps) return;
setReorderingSteps(true);
try {
const orderedSteps = [...scenario.steps].sort((a, b) => a.order - b.order);
const toIndex = orderedSteps.findIndex((step) => step.id === toStepId);
if (toIndex < 0) return;
await steps.update(id!, fromStepId, { order: toIndex });
await reloadScenario();
} finally {
setReorderingSteps(false);
}
};
const handleAddCredential = async (e: React.FormEvent) => {
@@ -120,7 +143,45 @@ export function ScenarioDetailPage() {
};
const stepColumns: TableColumn<ScenarioStep>[] = [
{ key: 'order', header: t('scenarios.step_order'), render: (s) => s.order, width: 60 },
{
key: 'drag',
header: '',
width: 44,
align: 'center',
render: (s) => (
<span
role="button"
tabIndex={0}
aria-label="Drag to reorder step"
className={[styles.dragHandle, reorderingSteps ? styles.dragHandleDisabled : '']
.filter(Boolean)
.join(' ')}
draggable={!reorderingSteps}
onClick={(e) => e.stopPropagation()}
onDragStart={(e) => {
if (reorderingSteps) {
e.preventDefault();
return;
}
setDraggedStepId(s.id);
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', s.id);
}}
onDragEnd={() => {
setDraggedStepId(null);
setDragOverStepId(null);
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
}
}}
>
<GripVertical size={14} />
</span>
),
},
{ key: 'order', header: t('scenarios.step_order'), render: (s) => s.order + 1, width: 60 },
{
key: 'title',
header: t('scenarios.step_title'),
@@ -256,7 +317,6 @@ export function ScenarioDetailPage() {
items={[
{ term: t('scenarios.field_id'), detail: <UuidBadge id={scenario.id} /> },
{ term: t('scenarios.field_name'), detail: scenario.name },
{ term: t('scenarios.field_steps'), detail: scenario.steps.length },
{
term: t('scenarios.field_created'),
detail: <Timestamp value={scenario.createdAt} />,
@@ -284,6 +344,30 @@ export function ScenarioDetailPage() {
rowKey={(s) => s.id}
loading={false}
emptyMessage=""
getRowProps={(s) => ({
className: dragOverStepId === s.id ? styles.dragOverRow : undefined,
onDragOver: (e) => {
if (reorderingSteps) return;
e.preventDefault();
if (draggedStepId && draggedStepId !== s.id) {
setDragOverStepId(s.id);
}
},
onDragLeave: () => {
if (dragOverStepId === s.id) {
setDragOverStepId(null);
}
},
onDrop: async (e) => {
if (reorderingSteps) return;
e.preventDefault();
const fromId = draggedStepId ?? e.dataTransfer.getData('text/plain');
setDragOverStepId(null);
setDraggedStepId(null);
if (!fromId || fromId === s.id) return;
await handleMoveStep(fromId, s.id);
},
})}
/>
</div>
)}