broadfield-dev commited on
Commit
efa1454
·
verified ·
1 Parent(s): ec90544

Update templates/index.html

Browse files
Files changed (1) hide show
  1. templates/index.html +171 -98
templates/index.html CHANGED
@@ -12,7 +12,6 @@
12
  .source-col-row.active { background-color: #cfe2ff; font-weight: bold; }
13
  pre { background: #212529; color: #adbac7; padding: 15px; border-radius: 6px; font-size: 0.8em; max-height: 250px; overflow: auto; }
14
 
15
- /* Builder Styles */
16
  .builder-row { border: 1px solid #dee2e6; border-radius: 6px; background: #fff; padding: 10px; margin-bottom: 10px; position: relative; }
17
  .builder-row.mode-list { border-left: 4px solid #198754; }
18
  .builder-row.mode-python { border-left: 4px solid #fd7e14; }
@@ -31,7 +30,6 @@
31
  <div><input type="password" id="token" class="form-control form-control-sm" placeholder="HF Token" style="width: 200px;"></div>
32
  </div>
33
 
34
- <!-- 1. CONFIG -->
35
  <div class="box">
36
  <div class="row g-2">
37
  <div class="col-md-4">
@@ -46,9 +44,7 @@
46
  </div>
47
  </div>
48
 
49
- <!-- WORKSPACE -->
50
  <div class="row" id="workspace" style="display:none;">
51
- <!-- LEFT: EXPLORER -->
52
  <div class="col-md-4">
53
  <div class="box h-100">
54
  <h6>Source Columns</h6>
@@ -59,7 +55,6 @@
59
  </div>
60
  </div>
61
 
62
- <!-- RIGHT: BUILDER -->
63
  <div class="col-md-8">
64
  <div class="box h-100">
65
  <div class="d-flex justify-content-between mb-3">
@@ -95,32 +90,26 @@
95
  </div>
96
  </div>
97
 
98
- <!-- Status Box -->
99
  <div id="job_status" class="alert mt-3" style="display:none;"></div>
100
  <div id="final_preview" class="mt-3" style="display:none;"></div>
101
-
102
  </div>
103
  </div>
104
  </div>
105
 
106
-
107
-
108
  <script>
109
  let schemaData = {};
110
  let sampleRows = [];
111
-
112
- // --- TEMPLATES ---
113
  function addBuilderRow() {
114
  const container = document.getElementById('builder_container');
115
  const id = 'row_' + Date.now();
116
 
117
- // Build Source Options
118
  let sourceOpts = '<option value="">Select Source...</option>';
119
  for(let col in schemaData) {
120
  let label = col + (schemaData[col].type === 'List' ? ' [List]' : '');
121
  sourceOpts += `<option value="${col}">${label}</option>`;
122
  }
123
-
124
  const div = document.createElement('div');
125
  div.className = "builder-row mode-simple";
126
  div.id = id;
@@ -143,33 +132,29 @@
143
  </div>
144
  </div>
145
 
146
- <!-- DYNAMIC INPUT AREA -->
147
  <div class="inputs-area">
148
- <!-- Default Simple Mode -->
149
  <div class="input-group input-group-sm">
150
  <span class="input-group-text">Source</span>
151
  <select class="form-select source-col">${sourceOpts}</select>
152
- <input type="text" class="form-control" placeholder="Optional .dot.notation" id="${id}_simple_extra">
153
  </div>
154
  </div>
155
  `;
156
  container.appendChild(div);
157
  }
158
-
159
  function changeMode(rowId, mode) {
160
  const row = document.getElementById(rowId);
161
  const area = row.querySelector('.inputs-area');
162
  const badge = row.querySelector('.mode-badge');
163
 
164
- // Reset classes
165
  row.className = `builder-row mode-${mode}`;
166
 
167
- // Build Source Options again
168
  let sourceOpts = '<option value="">Select Col...</option>';
169
  for(let col in schemaData) {
170
  sourceOpts += `<option value="${col}">${col}</option>`;
171
  }
172
-
173
  if(mode === 'simple') {
174
  badge.innerText = "Simple Path";
175
  area.innerHTML = `
@@ -208,56 +193,93 @@
208
  </div>`;
209
  }
210
  }
211
-
212
  function getRecipe() {
213
  const rows = document.querySelectorAll('.builder-row');
214
  let columns = [];
215
 
216
  rows.forEach(r => {
217
- const name = r.querySelector('.target-name').value;
218
  const mode = r.querySelector('.mode-select').value;
219
 
220
- if(!name) return; // skip empty names
221
-
 
 
 
 
222
  if(mode === 'simple') {
223
  let src = r.querySelector('.source-col').value;
224
- let extra = r.querySelector('.source-extra').value;
225
- if(extra) { src = extra.startsWith('.') ? src + extra : src + '.' + extra; }
 
 
 
 
 
 
226
 
 
 
 
 
 
 
 
227
  columns.push({ type: 'simple', name: name, source: src });
228
  }
229
  else if(mode === 'list_search') {
 
 
 
 
 
 
 
 
 
 
 
230
  columns.push({
231
  type: 'list_search',
232
  name: name,
233
- source: r.querySelector('.source-col').value,
234
- filter_key: r.querySelector('.filter-key').value,
235
- filter_val: r.querySelector('.filter-val').value,
236
- target_key: r.querySelector('.target-key').value
237
  });
238
  }
239
  else if(mode === 'python') {
 
 
 
 
 
 
 
 
240
  columns.push({
241
  type: 'python',
242
  name: name,
243
- expression: r.querySelector('.python-expr').value
244
  });
245
  }
246
  });
247
-
248
- return {
249
- filter_rule: document.getElementById('source_filter').value,
250
  columns: columns
251
  };
 
 
 
252
  }
253
-
254
- async function loadMetadata() {
255
  const btn = document.querySelector('button[onclick="loadMetadata()"]');
256
  const tokenVal = document.getElementById('token').value;
257
  const datasetVal = document.getElementById('dataset_id').value;
258
-
259
  if(!datasetVal) { alert("Please enter a Dataset ID"); return; }
260
-
261
  btn.disabled = true; btn.innerText = "...";
262
 
263
  try {
@@ -272,11 +294,9 @@
272
  const data = await res.json();
273
 
274
  if(data.status === 'success') {
275
- // Populate Configs
276
  const confSel = document.getElementById('config_select');
277
  confSel.innerHTML = '';
278
 
279
- // Safe check: Ensure configs is an array
280
  if (Array.isArray(data.configs) && data.configs.length > 0) {
281
  data.configs.forEach(c => {
282
  confSel.innerHTML += `<option value="${c}">${c}</option>`;
@@ -285,11 +305,9 @@
285
  confSel.innerHTML = `<option value="default">default</option>`;
286
  }
287
  confSel.disabled = false;
288
-
289
- // Populate Splits safely
290
  populateSplits(data.splits);
291
 
292
- // Set license if UI element exists and data returned it
293
  const licInput = document.getElementById('license_input');
294
  if(data.license_detected && licInput) {
295
  licInput.value = data.license_detected;
@@ -306,26 +324,23 @@
306
 
307
  btn.disabled = false; btn.innerText = "Connect";
308
  }
309
-
310
  function populateSplits(splits) {
311
  const sel = document.getElementById('split_select');
312
  sel.innerHTML = '';
313
 
314
- // Safe check: Ensure splits is an array
315
  if (Array.isArray(splits) && splits.length > 0) {
316
  splits.forEach(s => sel.innerHTML += `<option value="${s}">${s}</option>`);
317
  } else {
318
- // Fallback if array is empty or undefined
319
  sel.innerHTML = `<option value="train">train (fallback)</option>`;
320
  }
321
  sel.disabled = false;
322
  }
323
-
324
  async function updateSplits() {
325
  const conf = document.getElementById('config_select').value;
326
  const ds = document.getElementById('dataset_id').value;
327
  const token = document.getElementById('token').value;
328
-
329
  try {
330
  const res = await fetch('/get_splits', {
331
  method: 'POST', headers: {'Content-Type': 'application/json'},
@@ -344,38 +359,46 @@
344
  populateSplits(['train', 'test', 'validation']);
345
  }
346
  }
347
-
348
  async function startInspection() {
349
  const btn = document.getElementById('inspect_btn');
350
  btn.innerText = "Scanning..."; btn.disabled = true;
351
-
352
- const res = await fetch('/inspect_rows', {
353
- method: 'POST', headers: {'Content-Type': 'application/json'},
354
- body: JSON.stringify({
355
- token: document.getElementById('token').value,
356
- dataset_id: document.getElementById('dataset_id').value,
357
- config: document.getElementById('config_select').value,
358
- split: document.getElementById('split_select').value
359
- })
360
- });
361
- const data = await res.json();
362
 
363
- if(data.status === 'success') {
364
- document.getElementById('workspace').style.display = 'flex';
365
- sampleRows = data.samples;
366
- schemaData = data.schema;
 
 
 
 
 
 
 
367
 
368
- // Render Source List
369
- const list = document.getElementById('source_col_list');
370
- list.innerHTML = '';
371
- for(let col in schemaData) {
372
- let badge = schemaData[col].type === 'List' ? '<span class="badge bg-warning text-dark float-end">List</span>' : '';
373
- list.innerHTML += `<li class="list-group-item source-col-row" onclick="previewCol('${col}')">${col} ${badge}</li>`;
 
 
 
 
 
 
 
374
  }
375
- } else { alert(data.message); }
376
- btn.innerText = "Analyze Schema"; btn.disabled = false;
 
 
 
 
 
377
  }
378
-
379
  function previewCol(col) {
380
  let html = '';
381
  sampleRows.forEach(r => {
@@ -385,50 +408,100 @@
385
  });
386
  document.getElementById('data_preview_box').innerHTML = `<pre>${html}</pre>`;
387
  }
388
-
389
  async function runPreview() {
390
  const box = document.getElementById('final_preview');
391
- box.style.display='block'; box.innerHTML = "Generating...";
 
392
 
393
- const res = await fetch('/preview', {
394
- method: 'POST', headers: {'Content-Type': 'application/json'},
395
- body: JSON.stringify({
396
- token: document.getElementById('token').value,
397
- dataset_id: document.getElementById('dataset_id').value,
398
- config: document.getElementById('config_select').value,
399
- split: document.getElementById('split_select').value,
400
- recipe: getRecipe()
401
- })
402
- });
403
- const data = await res.json();
404
- if(data.status === 'success') box.innerHTML = `<pre>${JSON.stringify(data.rows, null, 2)}</pre>`;
405
- else box.innerHTML = `<div class="text-danger">${data.message}</div>`;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
406
  }
407
-
408
  async function executeJob() {
 
 
 
 
 
 
 
 
 
 
 
 
 
409
  const res = await fetch('/execute', {
410
- method: 'POST', headers: {'Content-Type': 'application/json'},
 
411
  body: JSON.stringify({
412
  token: document.getElementById('token').value,
413
  dataset_id: document.getElementById('dataset_id').value,
414
  config: document.getElementById('config_select').value,
415
  split: document.getElementById('split_select').value,
416
- target_id: document.getElementById('target_id').value,
417
- recipe: getRecipe(),
418
  max_rows: document.getElementById('max_rows').value,
419
  license: document.getElementById('license_input').value
420
-
421
  })
422
  });
 
423
  const data = await res.json();
 
424
  if(data.status === 'started') {
425
- document.getElementById('job_status').style.display = 'block';
426
- document.getElementById('job_status').innerText = "Job Started...";
427
- // Simple polling
428
- setInterval(async () => {
 
 
429
  const s = await fetch(`/status/${data.job_id}`).then(r=>r.json());
430
- if(s.status === 'completed') document.getElementById('job_status').innerText = `Done! Processed ${s.result.rows_processed}`;
 
 
 
 
 
 
 
 
431
  }, 2000);
 
 
432
  }
433
  }
434
  </script>
 
12
  .source-col-row.active { background-color: #cfe2ff; font-weight: bold; }
13
  pre { background: #212529; color: #adbac7; padding: 15px; border-radius: 6px; font-size: 0.8em; max-height: 250px; overflow: auto; }
14
 
 
15
  .builder-row { border: 1px solid #dee2e6; border-radius: 6px; background: #fff; padding: 10px; margin-bottom: 10px; position: relative; }
16
  .builder-row.mode-list { border-left: 4px solid #198754; }
17
  .builder-row.mode-python { border-left: 4px solid #fd7e14; }
 
30
  <div><input type="password" id="token" class="form-control form-control-sm" placeholder="HF Token" style="width: 200px;"></div>
31
  </div>
32
 
 
33
  <div class="box">
34
  <div class="row g-2">
35
  <div class="col-md-4">
 
44
  </div>
45
  </div>
46
 
 
47
  <div class="row" id="workspace" style="display:none;">
 
48
  <div class="col-md-4">
49
  <div class="box h-100">
50
  <h6>Source Columns</h6>
 
55
  </div>
56
  </div>
57
 
 
58
  <div class="col-md-8">
59
  <div class="box h-100">
60
  <div class="d-flex justify-content-between mb-3">
 
90
  </div>
91
  </div>
92
 
 
93
  <div id="job_status" class="alert mt-3" style="display:none;"></div>
94
  <div id="final_preview" class="mt-3" style="display:none;"></div>
 
95
  </div>
96
  </div>
97
  </div>
98
 
 
 
99
  <script>
100
  let schemaData = {};
101
  let sampleRows = [];
102
+
 
103
  function addBuilderRow() {
104
  const container = document.getElementById('builder_container');
105
  const id = 'row_' + Date.now();
106
 
 
107
  let sourceOpts = '<option value="">Select Source...</option>';
108
  for(let col in schemaData) {
109
  let label = col + (schemaData[col].type === 'List' ? ' [List]' : '');
110
  sourceOpts += `<option value="${col}">${label}</option>`;
111
  }
112
+
113
  const div = document.createElement('div');
114
  div.className = "builder-row mode-simple";
115
  div.id = id;
 
132
  </div>
133
  </div>
134
 
 
135
  <div class="inputs-area">
 
136
  <div class="input-group input-group-sm">
137
  <span class="input-group-text">Source</span>
138
  <select class="form-select source-col">${sourceOpts}</select>
139
+ <input type="text" class="form-control source-extra" placeholder="Optional .dot.notation">
140
  </div>
141
  </div>
142
  `;
143
  container.appendChild(div);
144
  }
145
+
146
  function changeMode(rowId, mode) {
147
  const row = document.getElementById(rowId);
148
  const area = row.querySelector('.inputs-area');
149
  const badge = row.querySelector('.mode-badge');
150
 
 
151
  row.className = `builder-row mode-${mode}`;
152
 
 
153
  let sourceOpts = '<option value="">Select Col...</option>';
154
  for(let col in schemaData) {
155
  sourceOpts += `<option value="${col}">${col}</option>`;
156
  }
157
+
158
  if(mode === 'simple') {
159
  badge.innerText = "Simple Path";
160
  area.innerHTML = `
 
193
  </div>`;
194
  }
195
  }
196
+
197
  function getRecipe() {
198
  const rows = document.querySelectorAll('.builder-row');
199
  let columns = [];
200
 
201
  rows.forEach(r => {
202
+ const name = r.querySelector('.target-name').value.trim();
203
  const mode = r.querySelector('.mode-select').value;
204
 
205
+ // Skip rows with no target name
206
+ if(!name) {
207
+ console.warn('Skipping row with empty target name');
208
+ return;
209
+ }
210
+
211
  if(mode === 'simple') {
212
  let src = r.querySelector('.source-col').value;
213
+ let extra = r.querySelector('.source-extra').value.trim();
214
+
215
+ // CRITICAL FIX: Validate that source is selected
216
+ if(!src) {
217
+ console.error(`Row "${name}" has no source column selected`);
218
+ alert(`Error: Column "${name}" has no source selected!`);
219
+ return;
220
+ }
221
 
222
+ // Append extra path if provided
223
+ if(extra) {
224
+ // Handle both .dot.notation and dot.notation
225
+ src = extra.startsWith('.') ? src + extra : src + '.' + extra;
226
+ }
227
+
228
+ console.log(`Simple path: ${name} <- ${src}`);
229
  columns.push({ type: 'simple', name: name, source: src });
230
  }
231
  else if(mode === 'list_search') {
232
+ const srcCol = r.querySelector('.source-col').value;
233
+ const filterKey = r.querySelector('.filter-key').value.trim();
234
+ const filterVal = r.querySelector('.filter-val').value.trim();
235
+ const targetKey = r.querySelector('.target-key').value.trim();
236
+
237
+ if(!srcCol || !filterKey || !filterVal || !targetKey) {
238
+ console.error(`List search column "${name}" is incomplete`);
239
+ alert(`Error: Column "${name}" list search is incomplete!`);
240
+ return;
241
+ }
242
+
243
  columns.push({
244
  type: 'list_search',
245
  name: name,
246
+ source: srcCol,
247
+ filter_key: filterKey,
248
+ filter_val: filterVal,
249
+ target_key: targetKey
250
  });
251
  }
252
  else if(mode === 'python') {
253
+ const expr = r.querySelector('.python-expr').value.trim();
254
+
255
+ if(!expr) {
256
+ console.error(`Python column "${name}" has no expression`);
257
+ alert(`Error: Column "${name}" has no Python expression!`);
258
+ return;
259
+ }
260
+
261
  columns.push({
262
  type: 'python',
263
  name: name,
264
+ expression: expr
265
  });
266
  }
267
  });
268
+
269
+ const recipe = {
270
+ filter_rule: document.getElementById('source_filter').value.trim(),
271
  columns: columns
272
  };
273
+
274
+ console.log('Generated recipe:', JSON.stringify(recipe, null, 2));
275
+ return recipe;
276
  }
277
+
278
+ async function loadMetadata() {
279
  const btn = document.querySelector('button[onclick="loadMetadata()"]');
280
  const tokenVal = document.getElementById('token').value;
281
  const datasetVal = document.getElementById('dataset_id').value;
 
282
  if(!datasetVal) { alert("Please enter a Dataset ID"); return; }
 
283
  btn.disabled = true; btn.innerText = "...";
284
 
285
  try {
 
294
  const data = await res.json();
295
 
296
  if(data.status === 'success') {
 
297
  const confSel = document.getElementById('config_select');
298
  confSel.innerHTML = '';
299
 
 
300
  if (Array.isArray(data.configs) && data.configs.length > 0) {
301
  data.configs.forEach(c => {
302
  confSel.innerHTML += `<option value="${c}">${c}</option>`;
 
305
  confSel.innerHTML = `<option value="default">default</option>`;
306
  }
307
  confSel.disabled = false;
308
+
 
309
  populateSplits(data.splits);
310
 
 
311
  const licInput = document.getElementById('license_input');
312
  if(data.license_detected && licInput) {
313
  licInput.value = data.license_detected;
 
324
 
325
  btn.disabled = false; btn.innerText = "Connect";
326
  }
327
+
328
  function populateSplits(splits) {
329
  const sel = document.getElementById('split_select');
330
  sel.innerHTML = '';
331
 
 
332
  if (Array.isArray(splits) && splits.length > 0) {
333
  splits.forEach(s => sel.innerHTML += `<option value="${s}">${s}</option>`);
334
  } else {
 
335
  sel.innerHTML = `<option value="train">train (fallback)</option>`;
336
  }
337
  sel.disabled = false;
338
  }
339
+
340
  async function updateSplits() {
341
  const conf = document.getElementById('config_select').value;
342
  const ds = document.getElementById('dataset_id').value;
343
  const token = document.getElementById('token').value;
 
344
  try {
345
  const res = await fetch('/get_splits', {
346
  method: 'POST', headers: {'Content-Type': 'application/json'},
 
359
  populateSplits(['train', 'test', 'validation']);
360
  }
361
  }
362
+
363
  async function startInspection() {
364
  const btn = document.getElementById('inspect_btn');
365
  btn.innerText = "Scanning..."; btn.disabled = true;
 
 
 
 
 
 
 
 
 
 
 
366
 
367
+ try {
368
+ const res = await fetch('/inspect_rows', {
369
+ method: 'POST', headers: {'Content-Type': 'application/json'},
370
+ body: JSON.stringify({
371
+ token: document.getElementById('token').value,
372
+ dataset_id: document.getElementById('dataset_id').value,
373
+ config: document.getElementById('config_select').value,
374
+ split: document.getElementById('split_select').value
375
+ })
376
+ });
377
+ const data = await res.json();
378
 
379
+ if(data.status === 'success') {
380
+ document.getElementById('workspace').style.display = 'flex';
381
+ sampleRows = data.samples;
382
+ schemaData = data.schema;
383
+
384
+ const list = document.getElementById('source_col_list');
385
+ list.innerHTML = '';
386
+ for(let col in schemaData) {
387
+ let badge = schemaData[col].type === 'List' ? '<span class="badge bg-warning text-dark float-end">List</span>' : '';
388
+ list.innerHTML += `<li class="list-group-item source-col-row" onclick="previewCol('${col}')">${col} ${badge}</li>`;
389
+ }
390
+ } else {
391
+ alert('Error: ' + data.message);
392
  }
393
+ } catch(e) {
394
+ console.error(e);
395
+ alert('Network error: ' + e);
396
+ }
397
+
398
+ btn.innerText = "Analyze Schema";
399
+ btn.disabled = false;
400
  }
401
+
402
  function previewCol(col) {
403
  let html = '';
404
  sampleRows.forEach(r => {
 
408
  });
409
  document.getElementById('data_preview_box').innerHTML = `<pre>${html}</pre>`;
410
  }
411
+
412
  async function runPreview() {
413
  const box = document.getElementById('final_preview');
414
+ box.style.display='block';
415
+ box.innerHTML = "Generating preview...";
416
 
417
+ const recipe = getRecipe();
418
+
419
+ // Validate recipe has columns
420
+ if(!recipe.columns || recipe.columns.length === 0) {
421
+ box.innerHTML = '<div class="alert alert-warning">No columns defined! Please add at least one column.</div>';
422
+ return;
423
+ }
424
+
425
+ console.log('Sending preview request with recipe:', recipe);
426
+
427
+ try {
428
+ const res = await fetch('/preview', {
429
+ method: 'POST',
430
+ headers: {'Content-Type': 'application/json'},
431
+ body: JSON.stringify({
432
+ token: document.getElementById('token').value,
433
+ dataset_id: document.getElementById('dataset_id').value,
434
+ config: document.getElementById('config_select').value,
435
+ split: document.getElementById('split_select').value,
436
+ recipe: recipe
437
+ })
438
+ });
439
+
440
+ const data = await res.json();
441
+ console.log('Preview response:', data);
442
+
443
+ if(data.status === 'success') {
444
+ box.innerHTML = `<pre>${JSON.stringify(data.rows, null, 2)}</pre>`;
445
+ } else {
446
+ box.innerHTML = `<div class="alert alert-danger">Error: ${data.message}</div>`;
447
+ }
448
+ } catch(e) {
449
+ console.error('Preview error:', e);
450
+ box.innerHTML = `<div class="alert alert-danger">Network error: ${e}</div>`;
451
+ }
452
  }
453
+
454
  async function executeJob() {
455
+ const recipe = getRecipe();
456
+
457
+ if(!recipe.columns || recipe.columns.length === 0) {
458
+ alert('No columns defined!');
459
+ return;
460
+ }
461
+
462
+ const targetId = document.getElementById('target_id').value.trim();
463
+ if(!targetId) {
464
+ alert('Please enter a target repository name!');
465
+ return;
466
+ }
467
+
468
  const res = await fetch('/execute', {
469
+ method: 'POST',
470
+ headers: {'Content-Type': 'application/json'},
471
  body: JSON.stringify({
472
  token: document.getElementById('token').value,
473
  dataset_id: document.getElementById('dataset_id').value,
474
  config: document.getElementById('config_select').value,
475
  split: document.getElementById('split_select').value,
476
+ target_id: targetId,
477
+ recipe: recipe,
478
  max_rows: document.getElementById('max_rows').value,
479
  license: document.getElementById('license_input').value
 
480
  })
481
  });
482
+
483
  const data = await res.json();
484
+
485
  if(data.status === 'started') {
486
+ const statusBox = document.getElementById('job_status');
487
+ statusBox.style.display = 'block';
488
+ statusBox.className = 'alert alert-info mt-3';
489
+ statusBox.innerText = "Job started, processing...";
490
+
491
+ const interval = setInterval(async () => {
492
  const s = await fetch(`/status/${data.job_id}`).then(r=>r.json());
493
+ if(s.status === 'completed') {
494
+ clearInterval(interval);
495
+ statusBox.className = 'alert alert-success mt-3';
496
+ statusBox.innerText = `✅ Done! Processed ${s.result.rows_processed} rows.`;
497
+ } else if(s.status === 'failed') {
498
+ clearInterval(interval);
499
+ statusBox.className = 'alert alert-danger mt-3';
500
+ statusBox.innerText = `❌ Job failed: ${s.error}`;
501
+ }
502
  }, 2000);
503
+ } else {
504
+ alert('Failed to start job: ' + (data.message || 'Unknown error'));
505
  }
506
  }
507
  </script>