@decido/plugin-chameleon 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/.turbo/turbo-build.log +13 -0
- package/package.json +47 -0
- package/src/App.tsx +116 -0
- package/src/components/DatawayDashboard.tsx +152 -0
- package/src/components/DatawayFilterModal.tsx +132 -0
- package/src/components/DatawayMathModal.tsx +120 -0
- package/src/components/DatawayOrchestratorCanvas.tsx +345 -0
- package/src/components/DatawayPipelineManager.tsx +227 -0
- package/src/components/DatawaySchemaMapper.tsx +291 -0
- package/src/components/FormulaBar.tsx +242 -0
- package/src/hooks/useSocketSync.ts +104 -0
- package/src/index.css +18 -0
- package/src/index.ts +121 -0
- package/src/logic/rules.ts +39 -0
- package/src/logic/workflowGuard.ts +45 -0
- package/src/main.tsx +10 -0
- package/src/services/gemini.ts +110 -0
- package/src/store/datawayStore.ts +26 -0
- package/src/stores/authStore.ts +26 -0
- package/src/stores/orderStore.ts +263 -0
- package/src/stores/uiStore.ts +19 -0
- package/src/types.ts +39 -0
- package/src/useAgent.ts +89 -0
- package/src/utils/sounds.ts +52 -0
- package/src/views/DatabaseAdminView.tsx +707 -0
- package/src/views/DatawayStudioView.tsx +959 -0
- package/src/views/DiscoveryAdminView.tsx +59 -0
- package/src/views/KuspideDashboardView.tsx +144 -0
- package/src/views/LoginView.tsx +122 -0
- package/src/views/MadefrontChatView.tsx +174 -0
- package/src/views/MadefrontExcelView.tsx +95 -0
- package/src/views/MadefrontKanbanView.tsx +292 -0
- package/src/views/roles/CajaView.tsx +76 -0
- package/src/views/roles/DespachoView.tsx +100 -0
- package/src/views/roles/PlantaView.tsx +83 -0
- package/src/views/roles/VentasView.tsx +101 -0
- package/src/views/roles/WorkflowAdminView.tsx +62 -0
- package/tsconfig.json +34 -0
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
import React, { useState, useEffect, useCallback } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
ReactFlow,
|
|
4
|
+
Background,
|
|
5
|
+
Controls,
|
|
6
|
+
MiniMap,
|
|
7
|
+
useNodesState,
|
|
8
|
+
useEdgesState,
|
|
9
|
+
Handle,
|
|
10
|
+
Position,
|
|
11
|
+
MarkerType,
|
|
12
|
+
Node,
|
|
13
|
+
Edge
|
|
14
|
+
} from '@xyflow/react';
|
|
15
|
+
import '@xyflow/react/dist/style.css';
|
|
16
|
+
import { Clock, Database, FastForward, CheckCircle2, Play, Pause, Trash2, Power } from 'lucide-react';
|
|
17
|
+
|
|
18
|
+
interface CronJob {
|
|
19
|
+
id: number;
|
|
20
|
+
name: string;
|
|
21
|
+
cron_expression: string;
|
|
22
|
+
target_connector: string;
|
|
23
|
+
target_resource: string;
|
|
24
|
+
pipeline_id: number;
|
|
25
|
+
pipeline_name: string;
|
|
26
|
+
last_status: string;
|
|
27
|
+
is_active: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Custom Nodes
|
|
31
|
+
const TriggerNode = ({ data }: { data: any }) => (
|
|
32
|
+
<div className="bg-slate-900 border border-amber-500/50 rounded-xl shadow-2xl p-4 w-64 text-slate-200">
|
|
33
|
+
<div className="flex items-center gap-2 mb-3 text-amber-400">
|
|
34
|
+
<Clock size={18} />
|
|
35
|
+
<span className="font-bold text-sm tracking-wide">CRON TRIGGER</span>
|
|
36
|
+
</div>
|
|
37
|
+
<div className="bg-black/50 p-2 rounded-lg font-mono text-center text-lg text-emerald-400 border border-slate-700">
|
|
38
|
+
{data.cron}
|
|
39
|
+
</div>
|
|
40
|
+
<div className="text-xs text-slate-500 mt-2 text-center uppercase">Schedule Expression</div>
|
|
41
|
+
<Handle type="source" position={Position.Right} className="w-3 h-3 bg-amber-500" />
|
|
42
|
+
</div>
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
const PipelineNode = ({ data }: { data: any }) => (
|
|
46
|
+
<div className="bg-indigo-950 border border-indigo-500/50 rounded-xl shadow-2xl p-4 w-72 text-slate-200">
|
|
47
|
+
<Handle type="target" position={Position.Left} className="w-3 h-3 bg-indigo-500" />
|
|
48
|
+
<div className="flex items-center gap-2 mb-3 text-indigo-400">
|
|
49
|
+
<FastForward size={18} />
|
|
50
|
+
<span className="font-bold text-sm tracking-wide">ETL PIPELINE</span>
|
|
51
|
+
</div>
|
|
52
|
+
<div className="font-semibold text-white bg-indigo-900/50 p-2 rounded-lg border border-indigo-500/30 truncate">
|
|
53
|
+
{data.name}
|
|
54
|
+
</div>
|
|
55
|
+
<Handle type="source" position={Position.Right} className="w-3 h-3 bg-indigo-500" />
|
|
56
|
+
</div>
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
const DestinationNode = ({ data }: { data: any }) => (
|
|
60
|
+
<div className="bg-emerald-950 border border-emerald-500/50 rounded-xl shadow-2xl p-4 w-64 text-slate-200 relative overflow-hidden">
|
|
61
|
+
{data.isActive ? (
|
|
62
|
+
<div className="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-emerald-400 to-teal-400 animate-pulse"></div>
|
|
63
|
+
) : (
|
|
64
|
+
<div className="absolute top-0 left-0 w-full h-1 bg-slate-700"></div>
|
|
65
|
+
)}
|
|
66
|
+
<Handle type="target" position={Position.Left} className="w-3 h-3 bg-emerald-500" />
|
|
67
|
+
<div className="flex items-center gap-2 mb-3 text-emerald-400">
|
|
68
|
+
<Database size={18} />
|
|
69
|
+
<span className="font-bold text-sm tracking-wide">TARGET DB</span>
|
|
70
|
+
</div>
|
|
71
|
+
<div className="text-sm font-mono text-slate-300">
|
|
72
|
+
<span className="opacity-50">Conn:</span> {data.connector}<br/>
|
|
73
|
+
<span className="opacity-50">Table:</span> {data.resource}
|
|
74
|
+
</div>
|
|
75
|
+
{data.lastStatus && (
|
|
76
|
+
<div className="mt-3 text-[10px] text-slate-400 bg-black/40 p-1.5 rounded flex items-center gap-1.5">
|
|
77
|
+
<CheckCircle2 size={12} className={data.lastStatus.includes('Failed') ? 'text-rose-400' : 'text-emerald-400'} />
|
|
78
|
+
<span className="truncate">{data.lastStatus}</span>
|
|
79
|
+
</div>
|
|
80
|
+
)}
|
|
81
|
+
</div>
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
const nodeTypes = {
|
|
85
|
+
trigger: TriggerNode,
|
|
86
|
+
pipeline: PipelineNode,
|
|
87
|
+
destination: DestinationNode
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
export const DatawayOrchestratorCanvas = ({ onClose }: { onClose: () => void }) => {
|
|
91
|
+
const [nodes, setNodes, onNodesChange] = useNodesState([]);
|
|
92
|
+
const [edges, setEdges, onEdgesChange] = useEdgesState([]);
|
|
93
|
+
const [crons, setCrons] = useState<CronJob[]>([]);
|
|
94
|
+
const [pipelines, setPipelines] = useState<any[]>([]);
|
|
95
|
+
|
|
96
|
+
// Form state for new Cron
|
|
97
|
+
const [formName, setFormName] = useState('');
|
|
98
|
+
const [formCron, setFormCron] = useState('0 3 * * *');
|
|
99
|
+
const [formPipeline, setFormPipeline] = useState('');
|
|
100
|
+
const [formSrcConn, setFormSrcConn] = useState('googlesheets');
|
|
101
|
+
const [formSrcRes, setFormSrcRes] = useState('');
|
|
102
|
+
const [formTgtConn, setFormTgtConn] = useState('postgres');
|
|
103
|
+
const [formTgtRes, setFormTgtRes] = useState('ventas');
|
|
104
|
+
|
|
105
|
+
const loadData = async () => {
|
|
106
|
+
try {
|
|
107
|
+
const [cronsRes, pipeRes] = await Promise.all([
|
|
108
|
+
fetch('/api/dataway/crons').then(r => r.json()),
|
|
109
|
+
fetch('/api/dataway/pipelines').then(r => r.json())
|
|
110
|
+
]);
|
|
111
|
+
|
|
112
|
+
setCrons(cronsRes.crons || []);
|
|
113
|
+
setPipelines(pipeRes.pipelines || []);
|
|
114
|
+
|
|
115
|
+
buildGraph(cronsRes.crons || []);
|
|
116
|
+
} catch (e) {
|
|
117
|
+
console.error("Failed to load orchestrator data", e);
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const buildGraph = (jobList: CronJob[]) => {
|
|
122
|
+
const newNodes: Node[] = [];
|
|
123
|
+
const newEdges: Edge[] = [];
|
|
124
|
+
|
|
125
|
+
jobList.forEach((job, i) => {
|
|
126
|
+
const startY = i * 250 + 50;
|
|
127
|
+
|
|
128
|
+
// 1. Trigger
|
|
129
|
+
newNodes.push({
|
|
130
|
+
id: `trigger-${job.id}`,
|
|
131
|
+
type: 'trigger',
|
|
132
|
+
position: { x: 50, y: startY },
|
|
133
|
+
data: { cron: job.cron_expression }
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// 2. Pipeline
|
|
137
|
+
newNodes.push({
|
|
138
|
+
id: `pipe-${job.id}`,
|
|
139
|
+
type: 'pipeline',
|
|
140
|
+
position: { x: 400, y: startY + 10 },
|
|
141
|
+
data: { name: job.pipeline_name || `Pipeline #${job.pipeline_id}` }
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// 3. Destination
|
|
145
|
+
newNodes.push({
|
|
146
|
+
id: `dest-${job.id}`,
|
|
147
|
+
type: 'destination',
|
|
148
|
+
position: { x: 800, y: startY },
|
|
149
|
+
data: {
|
|
150
|
+
connector: job.target_connector,
|
|
151
|
+
resource: job.target_resource,
|
|
152
|
+
lastStatus: job.last_status,
|
|
153
|
+
isActive: job.is_active
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
// Edges
|
|
158
|
+
newEdges.push({
|
|
159
|
+
id: `e1-${job.id}`,
|
|
160
|
+
source: `trigger-${job.id}`,
|
|
161
|
+
target: `pipe-${job.id}`,
|
|
162
|
+
animated: job.is_active,
|
|
163
|
+
style: { stroke: job.is_active ? '#f59e0b' : '#334155', strokeWidth: 2 },
|
|
164
|
+
markerEnd: { type: MarkerType.ArrowClosed, color: job.is_active ? '#f59e0b' : '#334155' }
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
newEdges.push({
|
|
168
|
+
id: `e2-${job.id}`,
|
|
169
|
+
source: `pipe-${job.id}`,
|
|
170
|
+
target: `dest-${job.id}`,
|
|
171
|
+
animated: job.is_active,
|
|
172
|
+
style: { stroke: job.is_active ? '#10b981' : '#334155', strokeWidth: 2 },
|
|
173
|
+
markerEnd: { type: MarkerType.ArrowClosed, color: job.is_active ? '#10b981' : '#334155' }
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
setNodes(newNodes);
|
|
178
|
+
setEdges(newEdges);
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
useEffect(() => {
|
|
182
|
+
loadData();
|
|
183
|
+
}, []);
|
|
184
|
+
|
|
185
|
+
const handleCreateJob = async () => {
|
|
186
|
+
if (!formName || !formPipeline || !formSrcRes || !formTgtRes) return alert("Faltan campos (Origen, Destino, Plantilla)");
|
|
187
|
+
|
|
188
|
+
await fetch('/api/dataway/crons', {
|
|
189
|
+
method: 'POST',
|
|
190
|
+
headers: { 'Content-Type': 'application/json' },
|
|
191
|
+
body: JSON.stringify({
|
|
192
|
+
name: formName,
|
|
193
|
+
cron_expression: formCron,
|
|
194
|
+
pipeline_id: parseInt(formPipeline),
|
|
195
|
+
source_connector: formSrcConn,
|
|
196
|
+
source_resource: formSrcRes,
|
|
197
|
+
target_connector: formTgtConn,
|
|
198
|
+
target_resource: formTgtRes,
|
|
199
|
+
is_active: false
|
|
200
|
+
})
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
loadData();
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
const handleToggle = async (id: number, currentActive: boolean) => {
|
|
207
|
+
await fetch(`/api/dataway/crons/${id}/toggle`, {
|
|
208
|
+
method: 'PUT',
|
|
209
|
+
headers: { 'Content-Type': 'application/json' },
|
|
210
|
+
body: JSON.stringify({ is_active: !currentActive })
|
|
211
|
+
});
|
|
212
|
+
loadData();
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
const handleDelete = async (id: number) => {
|
|
216
|
+
if (!confirm("Are you sure?")) return;
|
|
217
|
+
await fetch(`/api/dataway/crons/${id}`, { method: 'DELETE' });
|
|
218
|
+
loadData();
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
return (
|
|
222
|
+
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-md p-4 flex-col text-slate-200">
|
|
223
|
+
<div className="w-[98vw] h-[95vh] bg-[#090b11] rounded-2xl border border-slate-700 shadow-2xl flex overflow-hidden">
|
|
224
|
+
|
|
225
|
+
{/* Visual Canvas Layout */}
|
|
226
|
+
<div className="flex-1 relative">
|
|
227
|
+
<ReactFlow
|
|
228
|
+
nodes={nodes}
|
|
229
|
+
edges={edges}
|
|
230
|
+
onNodesChange={onNodesChange}
|
|
231
|
+
onEdgesChange={onEdgesChange}
|
|
232
|
+
nodeTypes={nodeTypes}
|
|
233
|
+
fitView
|
|
234
|
+
className="bg-[#050505]"
|
|
235
|
+
>
|
|
236
|
+
<Background color="#334155" gap={20} size={1} />
|
|
237
|
+
<Controls className="!bg-slate-900 !border-slate-700 !fill-slate-400" />
|
|
238
|
+
<MiniMap className="!bg-slate-900 !border-slate-700" maskColor="rgba(0,0,0,0.6)" />
|
|
239
|
+
</ReactFlow>
|
|
240
|
+
|
|
241
|
+
{/* Header Overlay */}
|
|
242
|
+
<div className="absolute top-4 left-4 bg-slate-900/60 backdrop-blur p-4 rounded-xl border border-slate-700 shadow-xl">
|
|
243
|
+
<h2 className="text-xl font-bold bg-gradient-to-r from-teal-400 to-indigo-400 bg-clip-text text-transparent flex items-center gap-2">
|
|
244
|
+
<Clock className="text-teal-400" />
|
|
245
|
+
Dataway Orchestrator (Level 14) 🚀
|
|
246
|
+
</h2>
|
|
247
|
+
<p className="text-sm text-slate-400 mt-1">Autonomous ETL Cron Engine linked to Postgres Templates</p>
|
|
248
|
+
</div>
|
|
249
|
+
|
|
250
|
+
<button onClick={onClose} className="absolute top-4 right-4 bg-slate-800 p-2 rounded-lg hover:bg-rose-500/20 text-slate-400 transition-colors">
|
|
251
|
+
Cerrar Orchestrator
|
|
252
|
+
</button>
|
|
253
|
+
</div>
|
|
254
|
+
|
|
255
|
+
{/* Sidebar Controls */}
|
|
256
|
+
<div className="w-[400px] border-l border-slate-800 bg-slate-900 flex flex-col">
|
|
257
|
+
<div className="p-4 border-b border-slate-800 font-bold bg-slate-950 uppercase tracking-widest text-xs text-slate-500">
|
|
258
|
+
Create Auto-Job
|
|
259
|
+
</div>
|
|
260
|
+
|
|
261
|
+
<div className="p-4 space-y-4 border-b border-slate-800">
|
|
262
|
+
<div>
|
|
263
|
+
<label className="text-xs text-slate-400 mb-1 block">Job Name</label>
|
|
264
|
+
<input value={formName} onChange={e=>setFormName(e.target.value)} className="w-full bg-black/40 border border-slate-700 rounded-md px-3 py-2 text-sm" placeholder="Sincronización Nocturna" />
|
|
265
|
+
</div>
|
|
266
|
+
<div className="flex gap-2">
|
|
267
|
+
<div className="flex-1">
|
|
268
|
+
<label className="text-xs text-slate-400 mb-1 block">Cron Format</label>
|
|
269
|
+
<input value={formCron} onChange={e=>setFormCron(e.target.value)} className="w-full bg-black/40 border border-slate-700 rounded-md px-3 py-2 text-sm font-mono text-amber-400" />
|
|
270
|
+
</div>
|
|
271
|
+
</div>
|
|
272
|
+
<div>
|
|
273
|
+
<label className="text-xs text-slate-400 mb-1 block">Pipeline Template</label>
|
|
274
|
+
<select value={formPipeline} onChange={e=>setFormPipeline(e.target.value)} className="w-full bg-black/40 border border-slate-700 rounded-md px-3 py-2 text-sm text-indigo-300">
|
|
275
|
+
<option value="">Select Pipeline...</option>
|
|
276
|
+
{pipelines.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
|
|
277
|
+
</select>
|
|
278
|
+
</div>
|
|
279
|
+
|
|
280
|
+
<div className="grid grid-cols-2 gap-2 p-3 bg-black/30 rounded-lg border border-slate-800">
|
|
281
|
+
<div>
|
|
282
|
+
<label className="text-xs text-slate-500 mb-1 block">Source Conn</label>
|
|
283
|
+
<input value={formSrcConn} onChange={e=>setFormSrcConn(e.target.value)} className="w-full bg-transparent border-b border-slate-700 text-xs py-1" />
|
|
284
|
+
</div>
|
|
285
|
+
<div>
|
|
286
|
+
<label className="text-xs text-slate-500 mb-1 block">Source Table/Sheet</label>
|
|
287
|
+
<input value={formSrcRes} onChange={e=>setFormSrcRes(e.target.value)} className="w-full bg-transparent border-b border-slate-700 text-xs py-1" />
|
|
288
|
+
</div>
|
|
289
|
+
<div>
|
|
290
|
+
<label className="text-xs text-slate-500 mb-1 block mt-2">Target Conn</label>
|
|
291
|
+
<input value={formTgtConn} onChange={e=>setFormTgtConn(e.target.value)} className="w-full bg-transparent border-b border-slate-700 text-xs py-1" />
|
|
292
|
+
</div>
|
|
293
|
+
<div>
|
|
294
|
+
<label className="text-xs text-slate-500 mb-1 block mt-2">Target Table</label>
|
|
295
|
+
<input value={formTgtRes} onChange={e=>setFormTgtRes(e.target.value)} className="w-full bg-transparent border-b border-slate-700 text-xs py-1 text-emerald-400" />
|
|
296
|
+
</div>
|
|
297
|
+
</div>
|
|
298
|
+
|
|
299
|
+
<button onClick={handleCreateJob} className="w-full bg-indigo-600 hover:bg-indigo-500 text-white font-bold py-2 rounded-lg transition-colors flex justify-center items-center gap-2 text-sm">
|
|
300
|
+
<FastForward size={16} /> Deploy to Engine
|
|
301
|
+
</button>
|
|
302
|
+
</div>
|
|
303
|
+
|
|
304
|
+
<div className="flex-1 overflow-y-auto p-4 space-y-3">
|
|
305
|
+
<h3 className="text-xs font-bold text-slate-500 uppercase tracking-widest mb-4">Deployed Engine Jobs</h3>
|
|
306
|
+
|
|
307
|
+
{crons.map(job => (
|
|
308
|
+
<div key={job.id} className={`p-3 rounded-xl border transition-all ${job.is_active ? 'bg-indigo-950/30 border-indigo-500/50' : 'bg-slate-800/50 border-slate-700'}`}>
|
|
309
|
+
<div className="flex justify-between items-start mb-2">
|
|
310
|
+
<div className="font-bold text-sm truncate pr-2">{job.name}</div>
|
|
311
|
+
<div className="flex gap-2">
|
|
312
|
+
<button onClick={() => handleToggle(job.id, job.is_active)} className={`p-1.5 rounded-md ${job.is_active ? 'bg-amber-500/20 text-amber-500' : 'bg-emerald-500/20 text-emerald-500'}`}>
|
|
313
|
+
{job.is_active ? <Pause size={14} /> : <Play size={14} />}
|
|
314
|
+
</button>
|
|
315
|
+
<button onClick={() => handleDelete(job.id)} className="p-1.5 bg-rose-500/10 text-rose-500 rounded-md hover:bg-rose-500/20">
|
|
316
|
+
<Trash2 size={14} />
|
|
317
|
+
</button>
|
|
318
|
+
</div>
|
|
319
|
+
</div>
|
|
320
|
+
<div className="flex items-center gap-2 text-xs font-mono mb-2">
|
|
321
|
+
<Clock size={12} className="text-slate-500" />
|
|
322
|
+
<span className="text-amber-400">{job.cron_expression}</span>
|
|
323
|
+
<span className={`px-2 py-0.5 rounded-full text-[10px] ${job.is_active ? 'bg-emerald-500/20 text-emerald-400' : 'bg-slate-700 text-slate-400'}`}>
|
|
324
|
+
{job.is_active ? 'RUNNING IN BACKGROUND' : 'PAUSED'}
|
|
325
|
+
</span>
|
|
326
|
+
</div>
|
|
327
|
+
{job.last_status && (
|
|
328
|
+
<div className="text-[10px] bg-black/30 p-1.5 rounded text-amber-100/70 border border-slate-800 truncate">
|
|
329
|
+
Status: {job.last_status}
|
|
330
|
+
</div>
|
|
331
|
+
)}
|
|
332
|
+
</div>
|
|
333
|
+
))}
|
|
334
|
+
{crons.length === 0 && (
|
|
335
|
+
<div className="text-center text-slate-500 text-sm mt-10">
|
|
336
|
+
No cron jobs scheduled via Orchestrator.
|
|
337
|
+
</div>
|
|
338
|
+
)}
|
|
339
|
+
</div>
|
|
340
|
+
</div>
|
|
341
|
+
|
|
342
|
+
</div>
|
|
343
|
+
</div>
|
|
344
|
+
);
|
|
345
|
+
};
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import React, { useState, useEffect } from 'react';
|
|
2
|
+
import { X, Search, Save, Play, Trash2, Database, Code2, Loader2 } from 'lucide-react';
|
|
3
|
+
|
|
4
|
+
interface Pipeline {
|
|
5
|
+
id: number;
|
|
6
|
+
name: string;
|
|
7
|
+
description: string;
|
|
8
|
+
connector_id: string;
|
|
9
|
+
resource: string;
|
|
10
|
+
m_script: string;
|
|
11
|
+
created_at: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface PipelineManagerProps {
|
|
15
|
+
isOpen: boolean;
|
|
16
|
+
onClose: () => void;
|
|
17
|
+
currentScript: string;
|
|
18
|
+
activeConnector: string | null;
|
|
19
|
+
activeResource: string | null;
|
|
20
|
+
onLoadPipeline: (script: string) => void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const DatawayPipelineManager: React.FC<PipelineManagerProps> = ({
|
|
24
|
+
isOpen, onClose, currentScript, activeConnector, activeResource, onLoadPipeline
|
|
25
|
+
}) => {
|
|
26
|
+
const [viewMode, setViewMode] = useState<'list' | 'save'>('list');
|
|
27
|
+
const [pipelines, setPipelines] = useState<Pipeline[]>([]);
|
|
28
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
29
|
+
|
|
30
|
+
// Save Form State
|
|
31
|
+
const [name, setName] = useState('');
|
|
32
|
+
const [description, setDescription] = useState('');
|
|
33
|
+
const [isSaving, setIsSaving] = useState(false);
|
|
34
|
+
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
if (isOpen) {
|
|
37
|
+
setViewMode('list');
|
|
38
|
+
fetchPipelines();
|
|
39
|
+
}
|
|
40
|
+
}, [isOpen]);
|
|
41
|
+
|
|
42
|
+
const fetchPipelines = async () => {
|
|
43
|
+
setIsLoading(true);
|
|
44
|
+
try {
|
|
45
|
+
const res = await fetch('http://localhost:3000/api/dataway/pipelines');
|
|
46
|
+
const data = await res.json();
|
|
47
|
+
if (data.pipelines) {
|
|
48
|
+
setPipelines(data.pipelines);
|
|
49
|
+
}
|
|
50
|
+
} catch (err) {
|
|
51
|
+
console.error('Failed to fetch pipelines', err);
|
|
52
|
+
} finally {
|
|
53
|
+
setIsLoading(false);
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const handleSave = async (e: React.FormEvent) => {
|
|
58
|
+
e.preventDefault();
|
|
59
|
+
if (!name.trim() || !activeConnector || !activeResource || !currentScript) return;
|
|
60
|
+
|
|
61
|
+
setIsSaving(true);
|
|
62
|
+
try {
|
|
63
|
+
const res = await fetch('http://localhost:3000/api/dataway/pipelines', {
|
|
64
|
+
method: 'POST',
|
|
65
|
+
headers: { 'Content-Type': 'application/json' },
|
|
66
|
+
body: JSON.stringify({
|
|
67
|
+
name,
|
|
68
|
+
description,
|
|
69
|
+
connectorId: activeConnector,
|
|
70
|
+
resource: activeResource,
|
|
71
|
+
mScript: currentScript
|
|
72
|
+
})
|
|
73
|
+
});
|
|
74
|
+
if (res.ok) {
|
|
75
|
+
setName('');
|
|
76
|
+
setDescription('');
|
|
77
|
+
setViewMode('list');
|
|
78
|
+
fetchPipelines();
|
|
79
|
+
}
|
|
80
|
+
} catch (err) {
|
|
81
|
+
console.error('Failed to save pipeline', err);
|
|
82
|
+
} finally {
|
|
83
|
+
setIsSaving(false);
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const handleDelete = async (id: number) => {
|
|
88
|
+
if (!confirm('¿Seguro que deseas borrar esta plantilla?')) return;
|
|
89
|
+
try {
|
|
90
|
+
await fetch(`http://localhost:3000/api/dataway/pipelines/${id}`, { method: 'DELETE' });
|
|
91
|
+
fetchPipelines();
|
|
92
|
+
} catch (err) {
|
|
93
|
+
console.error('Failed to delete', err);
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
if (!isOpen) return null;
|
|
98
|
+
|
|
99
|
+
return (
|
|
100
|
+
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/50 backdrop-blur-sm p-4 animate-in fade-in duration-200">
|
|
101
|
+
<div className="bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-2xl shadow-2xl w-full max-w-2xl overflow-hidden flex flex-col max-h-[85vh]">
|
|
102
|
+
<div className="flex items-center justify-between p-4 border-b border-[var(--border-color)] bg-[var(--bg-elevated)]">
|
|
103
|
+
<h3 className="font-bold flex items-center gap-2 text-[var(--text-primary)]">
|
|
104
|
+
<Database className="w-4 h-4 text-[var(--brand-primary)]" />
|
|
105
|
+
Librería de Pipelines ETL
|
|
106
|
+
</h3>
|
|
107
|
+
<div className="flex items-center gap-2">
|
|
108
|
+
<div className="flex bg-[var(--bg-surface)] p-1 rounded-lg border border-[var(--border-color)]">
|
|
109
|
+
<button
|
|
110
|
+
onClick={() => setViewMode('list')}
|
|
111
|
+
className={`px-3 py-1 rounded-md text-xs font-semibold transition-colors ${viewMode === 'list' ? 'bg-white shadow text-[var(--brand-primary)]' : 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'}`}
|
|
112
|
+
>
|
|
113
|
+
Catálogo
|
|
114
|
+
</button>
|
|
115
|
+
<button
|
|
116
|
+
onClick={() => setViewMode('save')}
|
|
117
|
+
className={`px-3 py-1 rounded-md text-xs font-semibold transition-colors ${viewMode === 'save' ? 'bg-white shadow text-[var(--brand-primary)]' : 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'}`}
|
|
118
|
+
>
|
|
119
|
+
Nueva Plantilla
|
|
120
|
+
</button>
|
|
121
|
+
</div>
|
|
122
|
+
<div className="w-px h-6 bg-[var(--border-color)] mx-2"></div>
|
|
123
|
+
<button onClick={onClose} className="p-1.5 hover:bg-[var(--bg-surface)] rounded-md transition-colors text-[var(--text-secondary)]">
|
|
124
|
+
<X className="w-4 h-4" />
|
|
125
|
+
</button>
|
|
126
|
+
</div>
|
|
127
|
+
</div>
|
|
128
|
+
|
|
129
|
+
<div className="flex-1 overflow-y-auto p-4 bg-[var(--bg-primary)]">
|
|
130
|
+
{viewMode === 'list' ? (
|
|
131
|
+
<div className="space-y-3">
|
|
132
|
+
{isLoading ? (
|
|
133
|
+
<div className="flex justify-center p-8"><Loader2 className="w-6 h-6 animate-spin text-[var(--brand-primary)]" /></div>
|
|
134
|
+
) : pipelines.length === 0 ? (
|
|
135
|
+
<div className="text-center p-8 text-[var(--text-secondary)]">
|
|
136
|
+
<Database className="w-12 h-12 mx-auto mb-3 opacity-20" />
|
|
137
|
+
<p>No tienes pipelines guardados.</p>
|
|
138
|
+
</div>
|
|
139
|
+
) : (
|
|
140
|
+
pipelines.map(p => (
|
|
141
|
+
<div key={p.id} className="border border-[var(--border-color)] rounded-xl p-4 hover:border-[var(--brand-primary)]/50 transition-colors bg-[var(--bg-surface)]/30 group">
|
|
142
|
+
<div className="flex justify-between items-start mb-2">
|
|
143
|
+
<div>
|
|
144
|
+
<h4 className="font-bold text-[var(--text-primary)]">{p.name}</h4>
|
|
145
|
+
<p className="text-xs text-[var(--text-secondary)] mt-0.5">{p.description || "Sin descripción"}</p>
|
|
146
|
+
</div>
|
|
147
|
+
<div className="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
|
148
|
+
<button
|
|
149
|
+
onClick={() => { onLoadPipeline(p.m_script); onClose(); }}
|
|
150
|
+
className="flex items-center gap-1.5 bg-[var(--brand-primary)] hover:opacity-90 text-white px-3 py-1.5 rounded-lg text-xs font-bold transition-opacity"
|
|
151
|
+
>
|
|
152
|
+
<Play className="w-3.5 h-3.5" /> Cargar & Ejecutar
|
|
153
|
+
</button>
|
|
154
|
+
<button onClick={() => handleDelete(p.id)} className="p-1.5 text-red-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors">
|
|
155
|
+
<Trash2 className="w-4 h-4" />
|
|
156
|
+
</button>
|
|
157
|
+
</div>
|
|
158
|
+
</div>
|
|
159
|
+
<div className="mt-3 flex items-center gap-3 text-[10px] font-mono font-medium text-[var(--text-secondary)]">
|
|
160
|
+
<span className="bg-[var(--bg-elevated)] px-2 py-1 rounded border border-[var(--border-color)]">
|
|
161
|
+
{p.connector_id} : {p.resource}
|
|
162
|
+
</span>
|
|
163
|
+
<span className="truncate flex-1 flex items-center gap-1.5">
|
|
164
|
+
<Code2 className="w-3 h-3" />
|
|
165
|
+
{p.m_script.substring(0, 50)}...
|
|
166
|
+
</span>
|
|
167
|
+
<span>{new Date(p.created_at).toLocaleDateString()}</span>
|
|
168
|
+
</div>
|
|
169
|
+
</div>
|
|
170
|
+
))
|
|
171
|
+
)}
|
|
172
|
+
</div>
|
|
173
|
+
) : (
|
|
174
|
+
<form onSubmit={handleSave} className="max-w-md mx-auto space-y-4 py-4">
|
|
175
|
+
{!currentScript ? (
|
|
176
|
+
<div className="p-4 bg-orange-50 border border-orange-200 text-orange-800 rounded-lg text-sm text-center">
|
|
177
|
+
No hay un script activo en el editor para guardar.
|
|
178
|
+
</div>
|
|
179
|
+
) : (
|
|
180
|
+
<>
|
|
181
|
+
<div className="flex flex-col gap-1.5">
|
|
182
|
+
<label className="text-xs font-bold text-[var(--text-secondary)] uppercase">Nombre del Pipeline</label>
|
|
183
|
+
<input
|
|
184
|
+
type="text"
|
|
185
|
+
autoFocus
|
|
186
|
+
required
|
|
187
|
+
value={name}
|
|
188
|
+
onChange={(e) => setName(e.target.value)}
|
|
189
|
+
placeholder="Ej. Limpieza de Ventas Mensuales"
|
|
190
|
+
className="w-full px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-lg text-sm outline-none focus:ring-2 focus:ring-[var(--brand-primary)]/50"
|
|
191
|
+
/>
|
|
192
|
+
</div>
|
|
193
|
+
<div className="flex flex-col gap-1.5">
|
|
194
|
+
<label className="text-xs font-bold text-[var(--text-secondary)] uppercase">Descripción (Opcional)</label>
|
|
195
|
+
<textarea
|
|
196
|
+
value={description}
|
|
197
|
+
onChange={(e) => setDescription(e.target.value)}
|
|
198
|
+
placeholder="¿Qué hace esta transformación?"
|
|
199
|
+
rows={3}
|
|
200
|
+
className="w-full px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-color)] rounded-lg text-sm outline-none focus:ring-2 focus:ring-[var(--brand-primary)]/50 resize-none"
|
|
201
|
+
/>
|
|
202
|
+
</div>
|
|
203
|
+
|
|
204
|
+
<div className="p-3 bg-[var(--bg-elevated)] border border-[var(--border-color)] rounded-lg">
|
|
205
|
+
<div className="text-[10px] font-bold text-[var(--text-secondary)] uppercase mb-1">M-Script a Guardar</div>
|
|
206
|
+
<pre className="text-xs font-mono text-[var(--text-primary)] whitespace-pre-wrap truncate max-h-24 overflow-hidden">
|
|
207
|
+
{currentScript}
|
|
208
|
+
</pre>
|
|
209
|
+
</div>
|
|
210
|
+
|
|
211
|
+
<button
|
|
212
|
+
type="submit"
|
|
213
|
+
disabled={isSaving || !name.trim()}
|
|
214
|
+
className="w-full py-2.5 bg-[var(--brand-primary)] hover:opacity-90 text-white rounded-xl text-sm font-bold shadow-sm transition-all disabled:opacity-50 flex items-center justify-center gap-2 mt-4"
|
|
215
|
+
>
|
|
216
|
+
{isSaving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
|
|
217
|
+
Guardar en Postgres
|
|
218
|
+
</button>
|
|
219
|
+
</>
|
|
220
|
+
)}
|
|
221
|
+
</form>
|
|
222
|
+
)}
|
|
223
|
+
</div>
|
|
224
|
+
</div>
|
|
225
|
+
</div>
|
|
226
|
+
);
|
|
227
|
+
};
|