taskmanager-engine 0.1.0__py3-none-any.whl

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.
taskmanager/ui/app.js ADDED
@@ -0,0 +1,1390 @@
1
+ // ==========================================================================
2
+ // TaskManager SPA Dashboard Controller (Linear Dark Theme)
3
+ // ==========================================================================
4
+
5
+ const getBasePath = () => {
6
+ const path = window.location.pathname;
7
+ return path.replace(/\/index\.html$/, "").replace(/\/+$/, "");
8
+ };
9
+ const BASE_PATH = getBasePath();
10
+ const API_BASE = window.location.origin + BASE_PATH;
11
+ let ws = null;
12
+ let currentTab = "overview";
13
+
14
+ // --- Modern Linear Dark Toast Notification System ---
15
+ const toast = {
16
+ show(title, msg = "", type = "info", duration = 4000) {
17
+ const container = document.getElementById("toast-container");
18
+ if (!container) return;
19
+
20
+ const icons = {
21
+ success: "✅",
22
+ error: "❌",
23
+ warning: "⚠️",
24
+ info: "ℹ️",
25
+ };
26
+
27
+ const toastElem = document.createElement("div");
28
+ toastElem.className = `toast toast-${type}`;
29
+ toastElem.innerHTML = `
30
+ <span class="toast-icon">${icons[type] || icons.info}</span>
31
+ <div class="toast-body">
32
+ <div class="toast-title">${escapeHtml(title)}</div>
33
+ ${msg ? `<div class="toast-msg">${escapeHtml(msg)}</div>` : ""}
34
+ </div>
35
+ <button class="toast-close" onclick="this.closest('.toast').remove()">&times;</button>
36
+ `;
37
+
38
+ container.appendChild(toastElem);
39
+
40
+ requestAnimationFrame(() => {
41
+ toastElem.classList.add("show");
42
+ });
43
+
44
+ if (duration > 0) {
45
+ setTimeout(() => {
46
+ toastElem.classList.remove("show");
47
+ setTimeout(() => toastElem.remove(), 350);
48
+ }, duration);
49
+ }
50
+ },
51
+ success(title, msg) { this.show(title, msg, "success", 4000); },
52
+ error(title, msg) { this.show(title, msg, "error", 5500); },
53
+ warning(title, msg) { this.show(title, msg, "warning", 4500); },
54
+ info(title, msg) { this.show(title, msg, "info", 4000); },
55
+ };
56
+
57
+ // --- Initialization ---
58
+ document.addEventListener("DOMContentLoaded", () => {
59
+ setupTabs();
60
+ connectWebSocket();
61
+ refreshCurrentTab();
62
+
63
+ // Polling fallback every 3 seconds for continuous live refresh
64
+ setInterval(() => {
65
+ refreshCurrentTab(true);
66
+ }, 3000);
67
+ });
68
+
69
+ // --- Tab Management ---
70
+ function setupTabs() {
71
+ document.querySelectorAll(".nav-tab").forEach(button => {
72
+ button.addEventListener("click", () => {
73
+ const tab = button.getAttribute("data-tab");
74
+ switchTab(tab);
75
+ });
76
+ });
77
+ }
78
+
79
+ function switchTab(tab) {
80
+ currentTab = tab;
81
+ document.querySelectorAll(".nav-tab").forEach(b => {
82
+ b.classList.toggle("active", b.getAttribute("data-tab") === tab);
83
+ });
84
+ document.querySelectorAll(".tab-pane").forEach(pane => {
85
+ pane.classList.toggle("active", pane.id === `tab-${tab}`);
86
+ });
87
+ refreshCurrentTab();
88
+ }
89
+
90
+ function refreshCurrentTab(isBackground = false) {
91
+ if (currentTab === "overview") fetchOverview();
92
+ if (currentTab === "workers") fetchWorkers();
93
+ if (currentTab === "queues") fetchTasks();
94
+ if (currentTab === "schedules") fetchSchedules();
95
+ if (currentTab === "dlq") fetchDlq();
96
+ if (currentTab === "history") {
97
+ if (!isBackground) fetchHistory();
98
+ fetchObservabilityMetrics();
99
+ }
100
+ }
101
+
102
+ // --- WebSocket Live Stream ---
103
+ function connectWebSocket() {
104
+ const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
105
+ const wsPath = `${BASE_PATH}/ws/events`;
106
+ const wsUrl = `${protocol}//${window.location.host}${wsPath}`;
107
+
108
+ const dot = document.getElementById("wsDot");
109
+ const text = document.getElementById("wsText");
110
+
111
+ try {
112
+ ws = new WebSocket(wsUrl);
113
+
114
+ ws.onopen = () => {
115
+ dot.classList.remove("disconnected");
116
+ text.innerText = "Ao Vivo";
117
+ logEvent("WS", "Conectado ao canal de eventos em tempo real");
118
+ };
119
+
120
+ ws.onmessage = (event) => {
121
+ try {
122
+ const payload = JSON.parse(event.data);
123
+ handleLiveEvent(payload);
124
+ } catch (err) {
125
+ console.error("Invalid WS message", err);
126
+ }
127
+ };
128
+
129
+ ws.onclose = () => {
130
+ dot.classList.add("disconnected");
131
+ text.innerText = "Reconectando...";
132
+ setTimeout(connectWebSocket, 3000);
133
+ };
134
+
135
+ ws.onerror = () => {
136
+ ws.close();
137
+ };
138
+ } catch (err) {
139
+ dot.classList.add("disconnected");
140
+ text.innerText = "Desconectado";
141
+ }
142
+ }
143
+
144
+ function handleLiveEvent(evt) {
145
+ const type = evt.type || "EVENT";
146
+ const data = evt.data || {};
147
+ let summary = JSON.stringify(data);
148
+
149
+ if (type === "job:enqueued") summary = `Job ${data.job_id?.substring(0, 8)} (${data.task || ""}) enfileirado na fila [${data.queue}]`;
150
+ else if (type === "job:delayed") summary = `Job ${data.job_id?.substring(0, 8)} (${data.task || ""}) agendado com delay na fila [${data.queue}]`;
151
+ else if (type === "job:active") summary = `Worker ${data.worker_id?.substring(0, 8)} executando job ${data.job_id?.substring(0, 8)} (${data.task || ""})`;
152
+ else if (type === "job:completed") summary = `Job ${data.job_id?.substring(0, 8)} completado com sucesso (${data.duration !== undefined ? data.duration.toFixed(2) : "0.00"}s)`;
153
+ else if (type === "job:failed") summary = `Job ${data.job_id?.substring(0, 8)} FALHOU -> DLQ [${data.queue}]: ${data.error || "Erro"}`;
154
+ else if (type === "job:retrying") summary = `Job ${data.job_id?.substring(0, 8)} agendado para retry (${data.retry_count}/${data.max_retries})`;
155
+ else if (type === "job:cancelled") summary = `Job ${data.job_id?.substring(0, 8)} cancelado na fila [${data.queue}]`;
156
+ else if (type === "job:replayed") summary = `Job ${data.job_id?.substring(0, 8)} reenfileirado da DLQ para a fila [${data.queue}]`;
157
+ else if (type === "worker:heartbeat") {
158
+ summary = `Worker ${data.name} [${data.status}] CPU: ${data.cpu_percent}% Mem: ${data.memory_mb}MB`;
159
+ updateWorkerTelemetryCard(data.cpu_percent, data.memory_mb, `${data.name} [${data.status}]`);
160
+ }
161
+ else if (type === "schedule:triggered") summary = `Cron ${data.schedule_id?.substring(0, 8)} disparou job ${data.job_id?.substring(0, 8)}`;
162
+ else if (type === "schedule:created" || type === "schedule:updated" || type === "schedule:deleted") summary = `Rotina agendada atualizada: ${type}`;
163
+ else if (type === "worker:spawned" || type === "worker:stopped") summary = `Worker status alterado: ${type}`;
164
+
165
+ logEvent(type, summary);
166
+
167
+ // Instantly refresh current tab state in real time for any relevant event
168
+ if (currentTab === "overview") fetchOverview();
169
+ if (currentTab === "workers") fetchWorkers();
170
+ if (currentTab === "schedules" && type.startsWith("schedule:")) fetchSchedules();
171
+ if (currentTab === "dlq" && (type === "job:failed" || type === "job:replayed")) fetchDlq();
172
+ if (currentTab === "history" && type.startsWith("job:")) {
173
+ fetchHistory();
174
+ fetchObservabilityMetrics();
175
+ }
176
+ }
177
+
178
+ function updateWorkerTelemetryCard(cpuVal, memMB, detailText) {
179
+ const cpuElem = document.getElementById("m-cpu");
180
+ const cpuBar = document.getElementById("m-cpu-bar");
181
+ const cpuSub = document.getElementById("m-cpu-sub");
182
+ if (cpuElem && cpuBar) {
183
+ const numCpu = Number(cpuVal) || 0;
184
+ cpuElem.innerText = `${numCpu.toFixed(1)}%`;
185
+ cpuBar.style.width = `${Math.min(100, Math.max(0, numCpu))}%`;
186
+ cpuBar.className = "metric-progress-fill" + (numCpu > 85 ? " danger" : (numCpu > 70 ? " warn" : ""));
187
+ if (cpuSub && detailText) cpuSub.innerText = detailText;
188
+ }
189
+
190
+ const memElem = document.getElementById("m-memory");
191
+ const memBar = document.getElementById("m-memory-bar");
192
+ const memSub = document.getElementById("m-memory-sub");
193
+ if (memElem && memBar) {
194
+ const numMem = Number(memMB) || 0;
195
+ memElem.innerText = `${numMem.toFixed(1)} MB`;
196
+ // Visual bar relative to 256MB per worker
197
+ const visualPct = Math.min(100, (numMem / 256) * 100);
198
+ memBar.style.width = `${visualPct}%`;
199
+ memBar.className = "metric-progress-fill" + (numMem > 500 ? " danger" : (numMem > 250 ? " warn" : ""));
200
+ if (memSub && detailText) memSub.innerText = detailText;
201
+ }
202
+ }
203
+
204
+ function logEvent(type, msg) {
205
+ const stream = document.getElementById("live-event-stream");
206
+ if (!stream) return;
207
+
208
+ const now = new Date().toLocaleTimeString();
209
+ const line = document.createElement("div");
210
+ line.className = "event-line";
211
+ line.innerHTML = `
212
+ <span class="event-time">${now}</span>
213
+ <span class="event-type">${escapeHtml(type)}</span>
214
+ <span class="event-msg">${escapeHtml(msg)}</span>
215
+ `;
216
+
217
+ stream.prepend(line);
218
+
219
+ // Limit to 50 lines
220
+ while (stream.children.length > 50) {
221
+ stream.removeChild(stream.lastChild);
222
+ }
223
+ }
224
+
225
+ // --- REST API Data Fetchers ---
226
+
227
+ async function fetchOverview() {
228
+ try {
229
+ const res = await fetch(`${API_BASE}/api/overview`);
230
+ const data = await res.json();
231
+
232
+ const setText = (id, val) => {
233
+ const el = document.getElementById(id);
234
+ if (el) el.innerText = val !== undefined && val !== null ? val : 0;
235
+ };
236
+
237
+ setText("m-workers", data.workers_count);
238
+ const workersSub = document.getElementById("m-workers-sub");
239
+ if (workersSub) workersSub.innerText = `${data.total_workers || data.workers_count || 0} total registrados`;
240
+
241
+ setText("m-active-jobs", data.active_jobs);
242
+ setText("m-pending-jobs", data.total_pending);
243
+ setText("m-delayed-jobs", data.total_delayed);
244
+ setText("m-dlq-jobs", data.total_dlq);
245
+ setText("m-schedules", data.schedules_count);
246
+
247
+ // Worker CPU & Memory Telemetry Updates
248
+ const cpuVal = data.worker_cpu_percent !== undefined ? data.worker_cpu_percent : 0;
249
+ const memMB = data.worker_memory_mb !== undefined ? data.worker_memory_mb : 0;
250
+ const detail = data.worker_memory_detail || "Processos worker";
251
+ updateWorkerTelemetryCard(cpuVal, memMB, detail);
252
+
253
+ const tbody = document.getElementById("overview-queues-table");
254
+ if (!tbody) return;
255
+ if (!data.queues || data.queues.length === 0) {
256
+ tbody.innerHTML = `<tr><td colspan="5" style="text-align: center; color: var(--ink-subtle);">Nenhuma fila ativa.</td></tr>`;
257
+ return;
258
+ }
259
+
260
+ tbody.innerHTML = data.queues.map(q => `
261
+ <tr>
262
+ <td><strong>${escapeHtml(q.queue)}</strong></td>
263
+ <td><span class="badge ${q.pending > 0 ? "badge-active" : "badge-pending"}">${q.pending}</span></td>
264
+ <td><span class="badge ${q.delayed > 0 ? "badge-delayed" : "badge-pending"}">${q.delayed}</span></td>
265
+ <td><span class="badge ${q.dlq > 0 ? "badge-failed" : "badge-pending"}">${q.dlq}</span></td>
266
+ <td>
267
+ <div class="action-group">
268
+ <button class="btn-action" title="Enfileirar Tarefa" onclick="quickEnqueueTask('', '${escapeHtml(q.queue)}')">⚡ Enfileirar</button>
269
+ <button class="btn-action" title="Ver Tarefas" onclick="switchTab('queues')">Ver Tarefas</button>
270
+ ${q.queue !== 'default' && q.pending === 0 && q.delayed === 0 && q.dlq === 0 ? `<button class="btn-action btn-action-danger" title="Excluir Fila Vazia" onclick="deleteQueue('${escapeHtml(q.queue)}')">🗑</button>` : ''}
271
+ </div>
272
+ </td>
273
+ </tr>
274
+ `).join("");
275
+ } catch (err) {
276
+ console.error("Failed to fetch overview", err);
277
+ }
278
+ }
279
+
280
+ async function fetchWorkers() {
281
+ try {
282
+ const res = await fetch(`${API_BASE}/api/workers`);
283
+ const workers = await res.json();
284
+ const container = document.getElementById("workers-container");
285
+ const countBadge = document.getElementById("workers-count-badge");
286
+
287
+ if (countBadge) countBadge.innerText = `${workers.length} workers`;
288
+
289
+ if (!container) return;
290
+ if (workers.length === 0) {
291
+ container.innerHTML = `<div style="color: var(--ink-subtle);">Nenhum worker ativo encontrado. Clique em <strong>+ Criar ▾ ➔ Iniciar Novo Worker</strong> acima ou execute <code>taskmanager worker</code> no terminal.</div>`;
292
+ return;
293
+ }
294
+
295
+ container.innerHTML = workers.map(w => {
296
+ const isDead = w.status === "dead";
297
+ let badgeClass = "badge-idle";
298
+ if (isDead) badgeClass = "badge-failed";
299
+ else if (w.status === "busy") badgeClass = "badge-active";
300
+ else if (w.status === "paused" || w.status === "throttled") badgeClass = "badge-delayed";
301
+
302
+ const pauseBtn = w.status === "paused"
303
+ ? `<button class="btn-action" onclick="resumeWorker('${w.id}')">▶ Retomar</button>`
304
+ : `<button class="btn-action" onclick="pauseWorker('${w.id}')">⏸ Pausar</button>`;
305
+
306
+ return `
307
+ <div class="worker-card">
308
+ <div class="worker-header">
309
+ <div>
310
+ <div class="worker-title">${escapeHtml(w.name)}</div>
311
+ <div style="font-size: 11px; color: var(--ink-tertiary); font-family: var(--font-mono);">${w.id.substring(0, 8)}</div>
312
+ </div>
313
+ <span class="badge ${badgeClass}">${w.status.toUpperCase()}</span>
314
+ </div>
315
+ <div class="worker-stat-row">
316
+ <span>Filas Atendidas</span>
317
+ <strong>${escapeHtml(w.queues.join(", ") || "default")}</strong>
318
+ </div>
319
+ <div class="worker-stat-row">
320
+ <span>Jobs Ativos / Concorrência</span>
321
+ <strong>${w.active_jobs_count} / ${w.concurrency}</strong>
322
+ </div>
323
+ <div class="worker-stat-row">
324
+ <span>Uso de CPU / Memória</span>
325
+ <strong>${w.cpu_percent}% / ${w.memory_mb} MB</strong>
326
+ </div>
327
+ <div class="worker-stat-row">
328
+ <span>Jobs Concluídos / Falhas</span>
329
+ <strong>${w.completed_jobs_count} / ${w.failed_jobs_count}</strong>
330
+ </div>
331
+ <div class="worker-stat-row">
332
+ <span>Último Heartbeat</span>
333
+ <strong>${timeAgo(w.last_heartbeat)}</strong>
334
+ </div>
335
+ <div style="margin-top: 14px; padding-top: 10px; border-top: 1px solid var(--hairline); display: flex; gap: 6px; justify-content: flex-end;">
336
+ ${pauseBtn}
337
+ <button class="btn-action btn-action-danger" onclick="stopWorker('${w.id}')">⏹ Parar</button>
338
+ </div>
339
+ </div>
340
+ `;
341
+ }).join("");
342
+ } catch (err) {
343
+ console.error("Failed to fetch workers", err);
344
+ }
345
+ }
346
+
347
+ function openSpawnWorkerModal() {
348
+ const nameInput = document.getElementById("spawn-worker-name");
349
+ if (nameInput) {
350
+ nameInput.value = `worker-ui-${Math.random().toString(36).substring(2, 6)}`;
351
+ }
352
+ openModal("modal-spawn-worker");
353
+ }
354
+
355
+ async function handleSpawnWorkerSubmit(e) {
356
+ e.preventDefault();
357
+ const name = document.getElementById("spawn-worker-name")?.value.trim() || undefined;
358
+ const queuesRaw = document.getElementById("spawn-worker-queues")?.value.trim() || "default";
359
+ const queues = queuesRaw.split(",").map(q => q.trim()).filter(Boolean);
360
+ const concurrency = parseInt(document.getElementById("spawn-worker-concurrency")?.value || "5", 10);
361
+ const maxMemRaw = document.getElementById("spawn-worker-max-mem")?.value.trim();
362
+ const maxCpuRaw = document.getElementById("spawn-worker-max-cpu")?.value.trim();
363
+
364
+ const payload = {
365
+ name,
366
+ queues,
367
+ concurrency,
368
+ max_memory_mb: maxMemRaw ? parseFloat(maxMemRaw) : null,
369
+ max_cpu_percent: maxCpuRaw ? parseFloat(maxCpuRaw) : null,
370
+ };
371
+
372
+ try {
373
+ const res = await fetch(`${API_BASE}/api/workers/spawn`, {
374
+ method: "POST",
375
+ headers: { "Content-Type": "application/json" },
376
+ body: JSON.stringify(payload),
377
+ });
378
+
379
+ if (res.ok) {
380
+ closeModal("modal-spawn-worker");
381
+ fetchWorkers();
382
+ fetchOverview();
383
+ logEvent("WORKER", `Novo worker '${name || 'dinâmico'}' iniciado com sucesso.`);
384
+ toast.success("Worker iniciado", `Worker '${name || 'dinâmico'}' está ativo e escutando [${queues.join(', ')}].`);
385
+ } else {
386
+ let errMsg = "Erro desconhecido";
387
+ try {
388
+ const errData = await res.json();
389
+ errMsg = errData.detail || errData.message || JSON.stringify(errData);
390
+ } catch {
391
+ errMsg = await res.text();
392
+ }
393
+ toast.error("Erro ao iniciar worker", errMsg);
394
+ }
395
+ } catch (err) {
396
+ toast.error("Falha na requisição", err.message);
397
+ }
398
+ }
399
+
400
+ async function pauseWorker(workerId) {
401
+ try {
402
+ const res = await fetch(`${API_BASE}/api/workers/${workerId}/pause`, { method: "POST" });
403
+ if (res.ok) {
404
+ fetchWorkers();
405
+ logEvent("CONTROL", `Worker ${workerId.substring(0, 8)} pausado.`);
406
+ toast.warning("Worker pausado", `O worker ${workerId.substring(0, 8)} pausou o consumo de tarefas.`);
407
+ }
408
+ } catch (err) {
409
+ toast.error("Erro ao pausar worker", err.message);
410
+ }
411
+ }
412
+
413
+ async function resumeWorker(workerId) {
414
+ try {
415
+ const res = await fetch(`${API_BASE}/api/workers/${workerId}/resume`, { method: "POST" });
416
+ if (res.ok) {
417
+ fetchWorkers();
418
+ logEvent("CONTROL", `Worker ${workerId.substring(0, 8)} retomado.`);
419
+ toast.success("Worker retomado", `O worker ${workerId.substring(0, 8)} voltou a processar tarefas.`);
420
+ }
421
+ } catch (err) {
422
+ toast.error("Erro ao retomar worker", err.message);
423
+ }
424
+ }
425
+
426
+ async function stopWorker(workerId) {
427
+ try {
428
+ const res = await fetch(`${API_BASE}/api/workers/${workerId}/stop`, { method: "POST" });
429
+ if (res.ok) {
430
+ fetchWorkers();
431
+ logEvent("CONTROL", `Worker ${workerId.substring(0, 8)} encerrado.`);
432
+ toast.info("Worker encerrado", `O worker ${workerId.substring(0, 8)} foi finalizado com sucesso.`);
433
+ }
434
+ } catch (err) {
435
+ toast.error("Erro ao parar worker", err.message);
436
+ }
437
+ }
438
+
439
+ let cachedTasks = [];
440
+
441
+ async function fetchTasks() {
442
+ try {
443
+ const res = await fetch(`${API_BASE}/api/tasks`);
444
+ cachedTasks = await res.json();
445
+ populateTaskDropdowns(cachedTasks);
446
+
447
+ const tbody = document.getElementById("tasks-table");
448
+ if (cachedTasks.length === 0) {
449
+ tbody.innerHTML = `<tr><td colspan="7" style="text-align: center; color: var(--ink-subtle);">Nenhuma tarefa registrada no TaskRegistry.</td></tr>`;
450
+ return;
451
+ }
452
+
453
+ tbody.innerHTML = cachedTasks.map(t => `
454
+ <tr>
455
+ <td><strong>${escapeHtml(t.name)}</strong></td>
456
+ <td><code>${escapeHtml(t.queue)}</code></td>
457
+ <td>${t.max_retries}</td>
458
+ <td>${t.retry_backoff}s</td>
459
+ <td>${t.timeout ? `${t.timeout}s` : "Sem limite"}</td>
460
+ <td><span class="badge ${t.is_async ? 'badge-active' : 'badge-pending'}">${t.is_async ? 'Async Coroutine' : 'Sync Function'}</span></td>
461
+ <td>
462
+ <div class="action-group">
463
+ <button class="btn-action" title="Disparar Tarefa Imediata" onclick="quickEnqueueTask('${escapeHtml(t.name)}', '${escapeHtml(t.queue)}')">⚡ Enfileirar</button>
464
+ <button class="btn-action" title="Configurar Cron ou Intervalo" onclick="quickScheduleTask('${escapeHtml(t.name)}', '${escapeHtml(t.queue)}')">⏰ Agendar</button>
465
+ </div>
466
+ </td>
467
+ </tr>
468
+ `).join("");
469
+ } catch (err) {
470
+ console.error("Failed to fetch tasks", err);
471
+ }
472
+ }
473
+
474
+ function populateTaskDropdowns(tasks) {
475
+ const enqSelect = document.getElementById("enq-task-select");
476
+ const schedSelect = document.getElementById("sched-task-select");
477
+ if (!enqSelect || !schedSelect) return;
478
+
479
+ const currentEnq = enqSelect.value;
480
+ const currentSched = schedSelect.value;
481
+
482
+ const optionsHtml = `
483
+ <optgroup label="⚡ Executores de Script Embutidos">
484
+ <option value="system.run_command">system.run_command (Executar Script / Comando Shell)</option>
485
+ <option value="system.run_script">system.run_script (Executar Script Python .py)</option>
486
+ </optgroup>
487
+ <optgroup label="📦 Tarefas Python Registradas (@task)">
488
+ ${tasks.filter(t => !t.name.startsWith("system.")).map(t => `<option value="${escapeHtml(t.name)}">${escapeHtml(t.name)} (fila: ${escapeHtml(t.queue)})</option>`).join("")}
489
+ </optgroup>
490
+ <optgroup label="⚙️ Customizado">
491
+ <option value="__custom__">Outra tarefa / Nome customizado...</option>
492
+ </optgroup>
493
+ `;
494
+
495
+ enqSelect.innerHTML = optionsHtml;
496
+ schedSelect.innerHTML = optionsHtml;
497
+
498
+ if (currentEnq) enqSelect.value = currentEnq;
499
+ if (currentSched) schedSelect.value = currentSched;
500
+ }
501
+
502
+ function handleTaskSelectChange(prefix) {
503
+ const select = document.getElementById(`${prefix}-task-select`);
504
+ const customGroup = document.getElementById(`group-${prefix}-custom-task`);
505
+ const queueInput = document.getElementById(`${prefix}-queue`);
506
+ const argsTextarea = document.getElementById(`${prefix}-args`);
507
+ const helpDiv = document.getElementById(`${prefix}-args-help`);
508
+ if (!select) return;
509
+
510
+ const taskName = select.value;
511
+
512
+ if (taskName === "__custom__") {
513
+ if (customGroup) customGroup.style.display = "block";
514
+ if (argsTextarea) argsTextarea.value = '{\n "args": [],\n "kwargs": {}\n}';
515
+ if (helpDiv) helpDiv.innerText = "💡 Informe os argumentos JSON da tarefa customizada.";
516
+ return;
517
+ }
518
+
519
+ if (customGroup) customGroup.style.display = "none";
520
+
521
+ // Look up task metadata in cachedTasks
522
+ const taskObj = cachedTasks.find(t => t.name === taskName);
523
+ if (taskObj) {
524
+ if (queueInput) queueInput.value = taskObj.queue || "default";
525
+
526
+ // Set formatted sample payload with real parameters & types
527
+ const samplePayload = {
528
+ args: [],
529
+ kwargs: taskObj.sample_kwargs || {}
530
+ };
531
+ if (argsTextarea) {
532
+ argsTextarea.value = JSON.stringify(samplePayload, null, 2);
533
+ }
534
+
535
+ // Show function signature and docstring helper
536
+ if (helpDiv) {
537
+ const paramsList = (taskObj.parameters || []).map(p => {
538
+ return `${p.name}${p.has_default ? `=${JSON.stringify(p.default)}` : ''}`;
539
+ }).join(", ");
540
+ const doc = taskObj.docstring ? ` — ${taskObj.docstring.split('\n')[0]}` : '';
541
+ helpDiv.innerText = `💡 ${taskName}(${paramsList})${doc}`;
542
+ }
543
+ } else {
544
+ if (argsTextarea && !argsTextarea.value.trim()) {
545
+ argsTextarea.value = '{\n "args": [],\n "kwargs": {}\n}';
546
+ }
547
+ if (helpDiv) helpDiv.innerText = "💡 Passe argumentos posicionais ('args') ou nomeados ('kwargs').";
548
+ }
549
+ }
550
+
551
+ async function fetchSchedules() {
552
+ try {
553
+ const res = await fetch(`${API_BASE}/api/schedules`);
554
+ const schedules = await res.json();
555
+ const tbody = document.getElementById("schedules-table");
556
+
557
+ if (schedules.length === 0) {
558
+ tbody.innerHTML = `<tr><td colspan="8" style="text-align: center; color: var(--ink-subtle);">Nenhum cron/agendamento cadastrado. Clique em "+ Novo Cron" para adicionar.</td></tr>`;
559
+ return;
560
+ }
561
+
562
+ tbody.innerHTML = schedules.map(s => {
563
+ const expr = s.schedule_type === "cron" ? s.cron_expression : `${s.interval_seconds}s`;
564
+ const nextRunStr = s.next_run ? timeUntil(s.next_run) : "--";
565
+ const statusBadge = s.enabled ? `<span class="badge badge-completed">ATIVO</span>` : `<span class="badge badge-failed">PAUSADO</span>`;
566
+
567
+ return `
568
+ <tr>
569
+ <td><strong>${escapeHtml(s.name)}</strong></td>
570
+ <td><code>${escapeHtml(s.task_name)}</code></td>
571
+ <td>${escapeHtml(s.queue)}</td>
572
+ <td><code>${escapeHtml(expr)}</code></td>
573
+ <td>${statusBadge}</td>
574
+ <td>${nextRunStr}</td>
575
+ <td>${s.total_runs}</td>
576
+ <td>
577
+ <div class="action-group">
578
+ <button class="btn-action" title="Disparar Agora" onclick="triggerSchedule('${s.id}')">⚡ Executar</button>
579
+ <button class="btn-action" title="${s.enabled ? 'Pausar' : 'Ativar'}" onclick="toggleSchedule('${s.id}', ${!s.enabled})">${s.enabled ? '⏸ Pausar' : '▶ Ativar'}</button>
580
+ <button class="btn-action btn-action-danger" title="Excluir Agendamento" onclick="deleteSchedule('${s.id}')">🗑</button>
581
+ </div>
582
+ </td>
583
+ </tr>
584
+ `;
585
+ }).join("");
586
+ } catch (err) {
587
+ console.error("Failed to fetch schedules", err);
588
+ }
589
+ }
590
+
591
+ async function fetchDlq(selectedQueue = null) {
592
+ try {
593
+ const filterSelect = document.getElementById("dlq-filter-queue");
594
+ const queue = selectedQueue || (filterSelect ? filterSelect.value : "all") || "all";
595
+ const res = await fetch(`${API_BASE}/api/dlq/${queue}`);
596
+ const jobs = await res.json();
597
+ const tbody = document.getElementById("dlq-table");
598
+ const countBadge = document.getElementById("dlq-count-badge");
599
+
600
+ if (countBadge) {
601
+ countBadge.innerText = `${jobs.length} falha${jobs.length !== 1 ? 's' : ''}`;
602
+ }
603
+
604
+ // Populate queue filter dropdown with active queues
605
+ if (filterSelect && filterSelect.dataset.populated !== "true") {
606
+ try {
607
+ const queuesRes = await fetch(`${API_BASE}/api/queues`);
608
+ if (queuesRes.ok) {
609
+ const queues = await queuesRes.json();
610
+ const currentVal = filterSelect.value || "all";
611
+ const optionsHtml = `<option value="all">Todas as Filas</option>` +
612
+ queues.map(q => `<option value="${escapeHtml(q.queue)}">${escapeHtml(q.queue)} (${q.dlq})</option>`).join("");
613
+ filterSelect.innerHTML = optionsHtml;
614
+ filterSelect.value = currentVal;
615
+ }
616
+ } catch {
617
+ // Soft fail
618
+ }
619
+ }
620
+
621
+ if (!tbody) return;
622
+ if (jobs.length === 0) {
623
+ tbody.innerHTML = `<tr><td colspan="6" style="text-align: center; color: var(--ink-subtle);">Nenhum job falho na Dead Letter Queue ${queue === 'all' ? 'em nenhuma fila' : `da fila [${queue}]`}.</td></tr>`;
624
+ return;
625
+ }
626
+
627
+ tbody.innerHTML = jobs.map(j => `
628
+ <tr>
629
+ <td><code>${j.id.substring(0, 8)}</code></td>
630
+ <td><strong>${escapeHtml(j.task_name)}</strong></td>
631
+ <td><code>${escapeHtml(j.queue)}</code></td>
632
+ <td style="color: var(--semantic-error);">${escapeHtml(j.error || "Erro desconhecido")}</td>
633
+ <td>${j.retry_count} / ${j.max_retries}</td>
634
+ <td>
635
+ <div class="action-group">
636
+ <button class="btn-action" title="Ver Detalhes do Erro" onclick="showJobDetails('${j.id}')">🔍 Detalhes</button>
637
+ <button class="btn-action" title="Reenfileirar Job na Fila" onclick="replayDlqJob('${j.id}')">⚡ Replay</button>
638
+ </div>
639
+ </td>
640
+ </tr>
641
+ `).join("");
642
+ } catch (err) {
643
+ console.error("Failed to fetch DLQ", err);
644
+ }
645
+ }
646
+
647
+ // --- Actions ---
648
+
649
+ async function triggerSchedule(id) {
650
+ try {
651
+ const res = await fetch(`${API_BASE}/api/schedules/${id}/trigger`, { method: "POST" });
652
+ if (res.ok) {
653
+ toast.success("Rotina disparada", "Tarefa colocada na fila para execução imediata.");
654
+ fetchSchedules();
655
+ fetchOverview();
656
+ } else {
657
+ const err = await res.json();
658
+ toast.error("Erro ao disparar rotina", err.detail || "Falha na execução");
659
+ }
660
+ } catch (err) {
661
+ toast.error("Erro ao disparar rotina", err.message);
662
+ }
663
+ }
664
+
665
+ async function toggleSchedule(id, enabled) {
666
+ try {
667
+ const res = await fetch(`${API_BASE}/api/schedules/${id}/toggle`, {
668
+ method: "POST",
669
+ headers: { "Content-Type": "application/json" },
670
+ body: JSON.stringify({ enabled }),
671
+ });
672
+ if (res.ok) {
673
+ toast.info(enabled ? "Rotina ativada" : "Rotina pausada", "Status do agendamento atualizado.");
674
+ fetchSchedules();
675
+ }
676
+ } catch (err) {
677
+ toast.error("Erro ao alterar rotina", err.message);
678
+ }
679
+ }
680
+
681
+ async function deleteSchedule(id) {
682
+ try {
683
+ const res = await fetch(`${API_BASE}/api/schedules/${id}`, { method: "DELETE" });
684
+ if (res.ok) {
685
+ toast.info("Rotina excluída", "Agendamento removido com sucesso.");
686
+ fetchSchedules();
687
+ }
688
+ } catch (err) {
689
+ toast.error("Erro ao excluir agendamento", err.message);
690
+ }
691
+ }
692
+
693
+ async function replayDlqJob(jobId) {
694
+ try {
695
+ const res = await fetch(`${API_BASE}/api/dlq/${jobId}/replay`, { method: "POST" });
696
+ if (res.ok) {
697
+ toast.success("Job reenfileirado", "Job reenviado para reprocessamento na fila.");
698
+ fetchDlq();
699
+ fetchOverview();
700
+ }
701
+ } catch (err) {
702
+ toast.error("Erro ao reenfileirar job", err.message);
703
+ }
704
+ }
705
+
706
+ async function handlePurgeDlq() {
707
+ const filterSelect = document.getElementById("dlq-filter-queue");
708
+ const queue = (filterSelect ? filterSelect.value : "all") || "all";
709
+ await purgeDlq(queue);
710
+ }
711
+
712
+ async function purgeDlq(queue = "all") {
713
+ try {
714
+ const res = await fetch(`${API_BASE}/api/dlq/${queue}/purge`, { method: "POST" });
715
+ if (res.ok) {
716
+ toast.info("DLQ limpa", `Jobs falhos ${queue === 'all' ? 'de todas as filas' : `na fila [${queue}]`} foram removidos.`);
717
+ fetchDlq(queue);
718
+ fetchOverview();
719
+ }
720
+ } catch (err) {
721
+ toast.error("Erro ao limpar DLQ", err.message);
722
+ }
723
+ }
724
+
725
+ async function showJobDetails(jobId) {
726
+ try {
727
+ const res = await fetch(`${API_BASE}/api/jobs/${jobId}`);
728
+ const job = await res.json();
729
+ document.getElementById("job-detail-title").innerText = `Job ${job.id}`;
730
+ document.getElementById("job-detail-content").innerText = JSON.stringify(job, null, 2);
731
+ openModal("modal-job-detail");
732
+ } catch (err) {
733
+ toast.error("Erro ao buscar detalhes", err.message);
734
+ }
735
+ }
736
+
737
+ async function quickEnqueueTask(taskName, queue) {
738
+ if (cachedTasks.length === 0) await fetchTasks();
739
+ openModal("modal-enqueue");
740
+ const select = document.getElementById("enq-task-select");
741
+ if (select) {
742
+ select.value = taskName;
743
+ handleTaskSelectChange("enq");
744
+ }
745
+ if (queue) {
746
+ document.getElementById("enq-queue").value = queue;
747
+ }
748
+ }
749
+
750
+ // --- Modals & Forms ---
751
+
752
+ function openModal(id) {
753
+ document.getElementById(id).classList.add("show");
754
+ }
755
+
756
+ function closeModal(id) {
757
+ document.getElementById(id).classList.remove("show");
758
+ }
759
+
760
+ async function openEnqueueModal() {
761
+ if (cachedTasks.length === 0) await fetchTasks();
762
+ openModal("modal-enqueue");
763
+ handleTaskSelectChange("enq");
764
+ }
765
+
766
+ async function quickScheduleTask(taskName, queue) {
767
+ if (cachedTasks.length === 0) await fetchTasks();
768
+ openScheduleModal(taskName);
769
+ if (queue) {
770
+ const qInput = document.getElementById("sched-queue");
771
+ if (qInput) qInput.value = queue;
772
+ }
773
+ }
774
+
775
+ async function openScheduleModal(taskName = null) {
776
+ if (cachedTasks.length === 0) await fetchTasks();
777
+ const nameInput = document.getElementById("sched-name");
778
+ const select = document.getElementById("sched-task-select");
779
+ const cronInput = document.getElementById("sched-cron");
780
+ const intervalInput = document.getElementById("sched-interval");
781
+
782
+ if (cronInput && !cronInput.value) cronInput.value = "*/5 * * * *";
783
+ if (intervalInput && !intervalInput.value) intervalInput.value = "60";
784
+
785
+ openModal("modal-schedule");
786
+ if (taskName && select) {
787
+ select.value = taskName;
788
+ if (nameInput) {
789
+ nameInput.value = `Rotina - ${taskName}`;
790
+ }
791
+ }
792
+ handleTaskSelectChange("sched");
793
+ }
794
+
795
+ function toggleScheduleTypeFields() {
796
+ const type = document.getElementById("sched-type").value;
797
+ document.getElementById("group-cron").style.display = type === "cron" ? "block" : "none";
798
+ document.getElementById("group-interval").style.display = type === "interval" ? "block" : "none";
799
+ }
800
+
801
+ async function handleEnqueueSubmit(e) {
802
+ e.preventDefault();
803
+ const selectVal = document.getElementById("enq-task-select").value;
804
+ const customVal = document.getElementById("enq-task-name").value.trim();
805
+ const taskName = selectVal === "__custom__" ? customVal : selectVal;
806
+
807
+ if (!taskName) {
808
+ toast.warning("Selecione uma tarefa", "Por favor, selecione ou informe o nome da tarefa.");
809
+ return;
810
+ }
811
+
812
+ const queue = document.getElementById("enq-queue").value.trim() || "default";
813
+ const delay = parseFloat(document.getElementById("enq-delay").value) || 0;
814
+ const argsRaw = document.getElementById("enq-args").value.trim();
815
+
816
+ let parsedArgs = { args: [], kwargs: {} };
817
+ if (argsRaw) {
818
+ try {
819
+ const obj = JSON.parse(argsRaw);
820
+ if (Array.isArray(obj)) parsedArgs.args = obj;
821
+ else if (typeof obj === "object") {
822
+ parsedArgs.args = obj.args || [];
823
+ parsedArgs.kwargs = obj.kwargs || (obj.args === undefined ? obj : {});
824
+ }
825
+ } catch (err) {
826
+ toast.error("JSON Inválido", "Verifique a formatação dos argumentos JSON da tarefa.");
827
+ return;
828
+ }
829
+ }
830
+
831
+ try {
832
+ const res = await fetch(`${API_BASE}/api/tasks/${taskName}/enqueue`, {
833
+ method: "POST",
834
+ headers: { "Content-Type": "application/json" },
835
+ body: JSON.stringify({
836
+ queue,
837
+ delay,
838
+ args: parsedArgs.args,
839
+ kwargs: parsedArgs.kwargs,
840
+ }),
841
+ });
842
+
843
+ if (res.ok) {
844
+ closeModal("modal-enqueue");
845
+ document.getElementById("form-enqueue").reset();
846
+ fetchOverview();
847
+ toast.success("Tarefa enfileirada", `Job '${taskName}' enviado para a fila [${queue}].`);
848
+ } else {
849
+ let errMsg = "Erro desconhecido";
850
+ try {
851
+ const errData = await res.json();
852
+ errMsg = errData.detail || errData.message || JSON.stringify(errData);
853
+ } catch {
854
+ errMsg = await res.text();
855
+ }
856
+ toast.error("Erro ao enfileirar", errMsg);
857
+ }
858
+ } catch (err) {
859
+ toast.error("Falha na requisição", err.message);
860
+ }
861
+ }
862
+
863
+ async function handleScheduleSubmit(e) {
864
+ e.preventDefault();
865
+ const name = document.getElementById("sched-name").value.trim();
866
+ const selectVal = document.getElementById("sched-task-select").value;
867
+ const customVal = document.getElementById("sched-task").value.trim();
868
+ const taskName = selectVal === "__custom__" ? customVal : selectVal;
869
+
870
+ if (!taskName) {
871
+ toast.warning("Selecione uma tarefa", "Por favor, selecione ou informe o nome da tarefa.");
872
+ return;
873
+ }
874
+
875
+ const scheduleType = document.getElementById("sched-type").value;
876
+ const queue = document.getElementById("sched-queue").value.trim() || "default";
877
+ let cronExpr = document.getElementById("sched-cron").value.trim();
878
+ const intervalSec = parseFloat(document.getElementById("sched-interval").value) || 60;
879
+ const argsRaw = document.getElementById("sched-args").value.trim();
880
+
881
+ if (scheduleType === "cron" && !cronExpr) {
882
+ cronExpr = "*/5 * * * *";
883
+ }
884
+
885
+ let parsedArgs = { args: [], kwargs: {} };
886
+ if (argsRaw) {
887
+ try {
888
+ const obj = JSON.parse(argsRaw);
889
+ if (Array.isArray(obj)) parsedArgs.args = obj;
890
+ else if (typeof obj === "object") {
891
+ parsedArgs.args = obj.args || [];
892
+ parsedArgs.kwargs = obj.kwargs || (obj.args === undefined ? obj : {});
893
+ }
894
+ } catch (err) {
895
+ toast.error("JSON Inválido", "Verifique a formatação dos argumentos JSON da rotina.");
896
+ return;
897
+ }
898
+ }
899
+
900
+ const payload = {
901
+ name,
902
+ task_name: taskName,
903
+ queue,
904
+ schedule_type: scheduleType,
905
+ cron_expression: scheduleType === "cron" ? cronExpr : null,
906
+ interval_seconds: scheduleType === "interval" ? intervalSec : null,
907
+ args: parsedArgs.args,
908
+ kwargs: parsedArgs.kwargs,
909
+ enabled: true,
910
+ };
911
+
912
+ try {
913
+ const res = await fetch(`${API_BASE}/api/schedules`, {
914
+ method: "POST",
915
+ headers: { "Content-Type": "application/json" },
916
+ body: JSON.stringify(payload),
917
+ });
918
+
919
+ if (res.ok) {
920
+ closeModal("modal-schedule");
921
+ document.getElementById("form-schedule").reset();
922
+ fetchSchedules();
923
+ fetchOverview();
924
+ toast.success("Cron cadastrado", `Rotina '${name}' configurada com sucesso.`);
925
+ } else {
926
+ let errMsg = "Erro desconhecido";
927
+ try {
928
+ const errData = await res.json();
929
+ errMsg = errData.detail || errData.message || JSON.stringify(errData);
930
+ } catch {
931
+ errMsg = await res.text();
932
+ }
933
+ toast.error("Erro ao criar agendamento", errMsg);
934
+ }
935
+ } catch (err) {
936
+ toast.error("Falha na requisição", err.message);
937
+ }
938
+ }
939
+
940
+ // --- Helpers ---
941
+
942
+ function escapeHtml(str) {
943
+ if (!str) return "";
944
+ return String(str)
945
+ .replace(/&/g, "&amp;")
946
+ .replace(/</g, "&lt;")
947
+ .replace(/>/g, "&gt;")
948
+ .replace(/"/g, "&quot;")
949
+ .replace(/'/g, "&#039;");
950
+ }
951
+
952
+ function timeAgo(timestamp) {
953
+ if (!timestamp) return "--";
954
+ const diff = Math.max(0, Math.round((Date.now() / 1000) - timestamp));
955
+ if (diff < 5) return "Agora mesmo";
956
+ if (diff < 60) return `Há ${diff}s`;
957
+ if (diff < 3600) return `Há ${Math.floor(diff / 60)} min`;
958
+ return `Há ${Math.floor(diff / 3600)} h`;
959
+ }
960
+
961
+ function timeUntil(timestamp) {
962
+ if (!timestamp) return "--";
963
+ const diff = Math.round(timestamp - (Date.now() / 1000));
964
+ if (diff <= 0) return "Agora";
965
+ if (diff < 60) return `Em ${diff}s`;
966
+ if (diff < 3600) return `Em ${Math.floor(diff / 60)} min`;
967
+ const date = new Date(timestamp * 1000);
968
+ return date.toLocaleDateString() + " " + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
969
+ }
970
+
971
+ // --- LGTM Observability & Execution History ---
972
+
973
+ let historySearchTimer = null;
974
+ function debounceFetchHistory() {
975
+ clearTimeout(historySearchTimer);
976
+ historySearchTimer = setTimeout(fetchHistory, 300);
977
+ }
978
+
979
+ async function fetchObservabilityMetrics() {
980
+ try {
981
+ const res = await fetch(`${API_BASE}/api/metrics/observability`);
982
+ const m = await res.json();
983
+
984
+ const rateElem = document.getElementById("obs-success-rate");
985
+ const rateBar = document.getElementById("obs-success-bar");
986
+ const rateSub = document.getElementById("obs-success-sub");
987
+ if (rateElem && rateBar) {
988
+ const rate = m.success_rate_percent !== undefined ? m.success_rate_percent : 100;
989
+ rateElem.innerText = `${rate.toFixed(1)}%`;
990
+ rateBar.style.width = `${rate}%`;
991
+ rateBar.className = "metric-progress-fill" + (rate < 80 ? " danger" : (rate < 95 ? " warn" : ""));
992
+ if (rateSub) rateSub.innerText = `${m.failed_count || 0} falhas de ${m.total_executions || 0} total`;
993
+ }
994
+
995
+ const avgElem = document.getElementById("obs-avg-duration");
996
+ if (avgElem) avgElem.innerText = `${m.avg_duration_ms || 0} ms`;
997
+
998
+ const p95Elem = document.getElementById("obs-p95-duration");
999
+ if (p95Elem) p95Elem.innerText = `${m.p95_duration_ms || 0} ms`;
1000
+
1001
+ const tpElem = document.getElementById("obs-throughput");
1002
+ if (tpElem) tpElem.innerText = `${m.throughput_per_minute || 0} / min`;
1003
+ } catch (err) {
1004
+ console.error("Failed to fetch observability metrics", err);
1005
+ }
1006
+ }
1007
+
1008
+ async function fetchHistory() {
1009
+ try {
1010
+ const status = document.getElementById("history-filter-status")?.value || "";
1011
+ const taskName = document.getElementById("history-search-task")?.value.trim() || "";
1012
+
1013
+ const params = new URLSearchParams({ limit: "50" });
1014
+ if (status) params.append("status", status);
1015
+ if (taskName) params.append("task_name", taskName);
1016
+
1017
+ const res = await fetch(`${API_BASE}/api/jobs/history?${params.toString()}`);
1018
+ const jobs = await res.json();
1019
+ const tbody = document.getElementById("history-table");
1020
+ const countBadge = document.getElementById("history-count-badge");
1021
+
1022
+ if (countBadge) countBadge.innerText = `${jobs.length} registros`;
1023
+
1024
+ if (!tbody) return;
1025
+ if (jobs.length === 0) {
1026
+ tbody.innerHTML = `<tr><td colspan="8" style="text-align: center; color: var(--ink-subtle);">Nenhuma execução encontrada para os filtros selecionados.</td></tr>`;
1027
+ return;
1028
+ }
1029
+
1030
+ tbody.innerHTML = jobs.map(j => {
1031
+ let badgeClass = "badge-pending";
1032
+ if (j.status === "completed") badgeClass = "badge-completed";
1033
+ else if (j.status === "failed") badgeClass = "badge-failed";
1034
+ else if (j.status === "active") badgeClass = "badge-active";
1035
+ else if (j.status === "delayed" || j.status === "retrying") badgeClass = "badge-delayed";
1036
+
1037
+ const durationStr = j.duration !== null && j.duration !== undefined ? `${(j.duration * 1000).toFixed(1)} ms` : "--";
1038
+ const timeStr = j.completed_at ? timeAgo(j.completed_at) : (j.started_at ? `Iniciado ${timeAgo(j.started_at)}` : timeAgo(j.created_at));
1039
+
1040
+ return `
1041
+ <tr>
1042
+ <td><span class="badge ${badgeClass}">${j.status.toUpperCase()}</span></td>
1043
+ <td><code style="cursor: pointer; text-decoration: underline;" onclick="openJobTraceModal('${j.id}')">${j.id.substring(0, 8)}</code></td>
1044
+ <td><strong>${escapeHtml(j.task_name)}</strong></td>
1045
+ <td><code>${escapeHtml(j.queue)}</code></td>
1046
+ <td>${durationStr}</td>
1047
+ <td>${escapeHtml(j.worker_id || "--")}</td>
1048
+ <td>${timeStr}</td>
1049
+ <td>
1050
+ <div class="action-group">
1051
+ <button class="btn-action" onclick="openJobTraceModal('${j.id}')">🔍 Trace & Logs</button>
1052
+ </div>
1053
+ </td>
1054
+ </tr>
1055
+ `;
1056
+ }).join("");
1057
+ } catch (err) {
1058
+ console.error("Failed to fetch history", err);
1059
+ }
1060
+ }
1061
+
1062
+ async function openJobTraceModal(jobId) {
1063
+ try {
1064
+ const res = await fetch(`${API_BASE}/api/jobs/${jobId}`);
1065
+ if (!res.ok) throw new Error("Job não encontrado");
1066
+ const job = await res.json();
1067
+
1068
+ document.getElementById("lgtm-modal-title").innerText = `Job ${job.id.substring(0, 8)}: ${job.task_name}`;
1069
+ document.getElementById("lgtm-modal-subtitle").innerText = `Fila: [${job.queue}] | Status: ${job.status.toUpperCase()} | Worker: ${job.worker_id || 'Nenhum'}`;
1070
+
1071
+ // 1. Render Tempo Trace Timeline
1072
+ const timelineContainer = document.getElementById("lgtm-trace-timeline");
1073
+ const steps = [
1074
+ {
1075
+ name: "Enfileirado",
1076
+ time: job.created_at,
1077
+ meta: `Criado e adicionado à fila '${job.queue}'`,
1078
+ status: "completed"
1079
+ }
1080
+ ];
1081
+
1082
+ if (job.started_at) {
1083
+ steps.push({
1084
+ name: "Processando",
1085
+ time: job.started_at,
1086
+ meta: `Consumido pelo worker '${job.worker_id || 'dev-worker'}'`,
1087
+ status: job.status === "active" ? "active" : "completed"
1088
+ });
1089
+ }
1090
+
1091
+ if (job.completed_at) {
1092
+ const dur = job.duration !== null ? `${(job.duration * 1000).toFixed(1)}ms` : "";
1093
+ steps.push({
1094
+ name: job.status === "failed" ? "Falhou (DLQ)" : "Finalizado",
1095
+ time: job.completed_at,
1096
+ meta: job.status === "failed" ? `Erro: ${job.error || 'Falha'} (Duração: ${dur})` : `Concluído com sucesso em ${dur}`,
1097
+ status: job.status === "failed" ? "failed" : "completed"
1098
+ });
1099
+ }
1100
+
1101
+ timelineContainer.innerHTML = steps.map((s, idx) => `
1102
+ <div class="trace-step">
1103
+ <div class="trace-dot ${s.status}"></div>
1104
+ <div class="trace-step-name">${s.name}</div>
1105
+ <div class="trace-step-meta">${timeAgo(s.time)} — ${escapeHtml(s.meta)}</div>
1106
+ </div>
1107
+ `).join("");
1108
+
1109
+ // 2. Render Loki Logs Console
1110
+ const logsContainer = document.getElementById("lgtm-logs-console");
1111
+ const logs = job.logs && job.logs.length > 0 ? job.logs : [`[INFO] Job registrado com ID ${job.id}`];
1112
+ if (job.traceback) {
1113
+ logs.push(`[ERROR] Traceback: ${job.traceback}`);
1114
+ }
1115
+
1116
+ logsContainer.innerHTML = logs.map(l => {
1117
+ const isErr = l.includes("[ERROR]") || l.includes("Falha") || l.includes("Traceback");
1118
+ return `<div class="log-entry"><span class="${isErr ? 'log-err' : 'log-msg'}">${escapeHtml(l)}</span></div>`;
1119
+ }).join("");
1120
+
1121
+ // 3. Render Payload & Output
1122
+ const payloadViewer = document.getElementById("lgtm-payload-viewer");
1123
+ const payloadData = {
1124
+ args: job.args,
1125
+ kwargs: job.kwargs,
1126
+ result: job.result,
1127
+ error: job.error,
1128
+ retry_count: job.retry_count,
1129
+ max_retries: job.max_retries,
1130
+ duration_seconds: job.duration
1131
+ };
1132
+ payloadViewer.innerText = JSON.stringify(payloadData, null, 2);
1133
+
1134
+ openModal("modal-lgtm-trace");
1135
+ } catch (err) {
1136
+ toast.error("Erro ao abrir observabilidade", err.message);
1137
+ }
1138
+ }
1139
+
1140
+ // --- Maintenance / Redis Flush Controller ---
1141
+
1142
+ function openMaintenanceModal() {
1143
+ openModal("modal-maintenance");
1144
+ }
1145
+
1146
+ async function executeMaintenanceFlush(target) {
1147
+ const targetNames = {
1148
+ queues: "Filas e Jobs",
1149
+ history: "Histórico de Execuções",
1150
+ all: "Banco de Dados Redis Completo (Reset Total)",
1151
+ };
1152
+
1153
+ try {
1154
+ const res = await fetch(`${API_BASE}/api/maintenance/flush`, {
1155
+ method: "POST",
1156
+ headers: { "Content-Type": "application/json" },
1157
+ body: JSON.stringify({ target }),
1158
+ });
1159
+
1160
+ if (res.ok) {
1161
+ closeModal("modal-maintenance");
1162
+ toast.success("Limpeza Concluída", `${targetNames[target] || target} foi limpo com sucesso no Redis.`);
1163
+ logEvent("SYSTEM", `Limpeza do Redis executada: ${target}`);
1164
+ refreshCurrentTab();
1165
+ fetchOverview();
1166
+ } else {
1167
+ const err = await res.json();
1168
+ toast.error("Erro na Limpeza", err.detail || "Falha ao executar limpeza no Redis.");
1169
+ }
1170
+ } catch (err) {
1171
+ toast.error("Falha na Requisição", err.message);
1172
+ }
1173
+ }
1174
+
1175
+ // --- Dropdown Menu Controller ---
1176
+
1177
+ function toggleDropdown(menuId) {
1178
+ const menu = document.getElementById(menuId);
1179
+ const parent = menu?.closest('.dropdown');
1180
+ if (!parent) return;
1181
+ const isOpen = parent.classList.contains('open');
1182
+ closeDropdowns();
1183
+ if (!isOpen) {
1184
+ parent.classList.add('open');
1185
+ }
1186
+ }
1187
+
1188
+ function closeDropdowns() {
1189
+ document.querySelectorAll('.dropdown.open').forEach(d => d.classList.remove('open'));
1190
+ }
1191
+
1192
+ document.addEventListener('click', (e) => {
1193
+ if (!e.target.closest('.dropdown')) {
1194
+ closeDropdowns();
1195
+ }
1196
+ });
1197
+
1198
+ // --- Queue Management (Create & Delete) ---
1199
+
1200
+ function openCreateQueueModal() {
1201
+ const input = document.getElementById("create-queue-name");
1202
+ if (input) {
1203
+ input.value = "";
1204
+ setTimeout(() => input.focus(), 50);
1205
+ }
1206
+ openModal("modal-create-queue");
1207
+ }
1208
+
1209
+ async function handleCreateQueueSubmit(e) {
1210
+ e.preventDefault();
1211
+ const input = document.getElementById("create-queue-name");
1212
+ const name = input?.value.trim();
1213
+ if (!name) {
1214
+ toast.warning("Nome da Fila", "Por favor informe um nome válido para a fila.");
1215
+ return;
1216
+ }
1217
+
1218
+ try {
1219
+ const res = await fetch(`${API_BASE}/api/queues`, {
1220
+ method: "POST",
1221
+ headers: { "Content-Type": "application/json" },
1222
+ body: JSON.stringify({ name }),
1223
+ });
1224
+
1225
+ if (res.ok) {
1226
+ closeModal("modal-create-queue");
1227
+ toast.success("Fila criada", `A fila [${name}] foi registrada com sucesso no Redis.`);
1228
+ logEvent("QUEUE", `Nova fila registrada: [${name}]`);
1229
+ fetchOverview();
1230
+ if (currentTab === "queues") fetchTasks();
1231
+ } else {
1232
+ const errData = await res.json().catch(() => ({}));
1233
+ toast.error("Erro ao criar fila", errData.detail || "Falha ao registrar fila.");
1234
+ }
1235
+ } catch (err) {
1236
+ toast.error("Falha na requisição", err.message);
1237
+ }
1238
+ }
1239
+
1240
+ async function deleteQueue(queueName) {
1241
+ if (queueName === "default") {
1242
+ toast.warning("Ação não permitida", "A fila padrão 'default' não pode ser excluída.");
1243
+ return;
1244
+ }
1245
+
1246
+ try {
1247
+ const res = await fetch(`${API_BASE}/api/queues/${queueName}`, { method: "DELETE" });
1248
+ if (res.ok) {
1249
+ toast.info("Fila excluída", `A fila [${queueName}] foi removida do Redis.`);
1250
+ logEvent("QUEUE", `Fila removida: [${queueName}]`);
1251
+ fetchOverview();
1252
+ if (currentTab === "queues") fetchTasks();
1253
+ } else {
1254
+ const errData = await res.json().catch(() => ({}));
1255
+ toast.error("Erro ao excluir fila", errData.detail || "Falha ao remover fila.");
1256
+ }
1257
+ } catch (err) {
1258
+ toast.error("Falha na requisição", err.message);
1259
+ }
1260
+ }
1261
+
1262
+ // --- Command Palette Controller (Ctrl+K / ⌘K) ---
1263
+
1264
+ let cmdSelectedIndex = 0;
1265
+ const defaultCommands = [
1266
+ { id: "new-task", icon: "⚡", title: "Nova Tarefa", desc: "Enfileirar job imediato ou com delay", action: () => openEnqueueModal() },
1267
+ { id: "new-cron", icon: "⏰", title: "Novo Cron / Agendamento", desc: "Programar rotina periódica ou intervalo", action: () => openScheduleModal() },
1268
+ { id: "new-queue", icon: "📦", title: "Nova Fila", desc: "Registrar uma nova fila no Redis", action: () => openCreateQueueModal() },
1269
+ { id: "new-worker", icon: "🤖", title: "Iniciar Novo Worker", desc: "Spawnar processo de worker dinâmico", action: () => openSpawnWorkerModal() },
1270
+ { id: "tab-overview", icon: "📊", title: "Ir para: Visão Geral", desc: "Métricas globais de filas e telemetria", action: () => switchTab("overview") },
1271
+ { id: "tab-workers", icon: "👥", title: "Ir para: Workers", desc: "Gerenciar workers ativos, pausar e retomar", action: () => switchTab("workers") },
1272
+ { id: "tab-queues", icon: "📋", title: "Ir para: Filas & Tarefas", desc: "Explorar funções @task registradas", action: () => switchTab("queues") },
1273
+ { id: "tab-schedules", icon: "📅", title: "Ir para: Cron & Agendamentos", desc: "Ver rotinas ativas e disparar", action: () => switchTab("schedules") },
1274
+ { id: "tab-dlq", icon: "⚠️", title: "Ir para: Dead Letter Queue (DLQ)", desc: "Inspecionar e fazer replay de falhas", action: () => switchTab("dlq") },
1275
+ { id: "tab-history", icon: "📈", title: "Ir para: Observabilidade & Histórico", desc: "Métricas LGTM, traces Tempo e logs Loki", action: () => switchTab("history") },
1276
+ { id: "flush-redis", icon: "🧹", title: "Limpar Redis & Manutenção", desc: "Abrir painel de flush atômico do Redis", action: () => openMaintenanceModal() },
1277
+ ];
1278
+
1279
+ function openCommandPalette() {
1280
+ openModal("modal-command-palette");
1281
+ const input = document.getElementById("cmd-search-input");
1282
+ if (input) {
1283
+ input.value = "";
1284
+ setTimeout(() => input.focus(), 50);
1285
+ }
1286
+ renderCommandResults(defaultCommands);
1287
+ }
1288
+
1289
+ function handleCommandSearch(query) {
1290
+ const q = (query || "").trim().toLowerCase();
1291
+ if (!q) {
1292
+ renderCommandResults(defaultCommands);
1293
+ return;
1294
+ }
1295
+
1296
+ const filtered = defaultCommands.filter(c =>
1297
+ c.title.toLowerCase().includes(q) || c.desc.toLowerCase().includes(q)
1298
+ );
1299
+
1300
+ // Search dynamically in registered tasks
1301
+ const matchingTasks = (cachedTasks || []).filter(t =>
1302
+ t.name.toLowerCase().includes(q) || (t.queue && t.queue.toLowerCase().includes(q))
1303
+ ).map(t => ({
1304
+ id: `task-${t.name}`,
1305
+ icon: "⚡",
1306
+ title: `Enfileirar: ${t.name}`,
1307
+ desc: `Fila: [${t.queue}] | Timeout: ${t.timeout ? `${t.timeout}s` : 'Sem limite'}`,
1308
+ action: () => quickEnqueueTask(t.name, t.queue)
1309
+ }));
1310
+
1311
+ renderCommandResults([...filtered, ...matchingTasks]);
1312
+ }
1313
+
1314
+ function renderCommandResults(list) {
1315
+ const container = document.getElementById("cmd-palette-results");
1316
+ if (!container) return;
1317
+ cmdSelectedIndex = 0;
1318
+
1319
+ if (list.length === 0) {
1320
+ container.innerHTML = `<div style="padding: 16px; text-align: center; color: var(--ink-subtle); font-size: 13px;">Nenhum comando ou tarefa encontrada.</div>`;
1321
+ return;
1322
+ }
1323
+
1324
+ window._activeCommandsList = list;
1325
+
1326
+ container.innerHTML = list.map((item, idx) => `
1327
+ <div class="cmd-item ${idx === 0 ? 'selected' : ''}" data-index="${idx}" onclick="executeCommandByIndex(${idx})">
1328
+ <div class="cmd-item-left">
1329
+ <span style="font-size: 15px;">${item.icon}</span>
1330
+ <div>
1331
+ <div style="font-weight: 500; color: var(--ink); font-size: 13px;">${escapeHtml(item.title)}</div>
1332
+ <div style="font-size: 11px; color: var(--ink-subtle);">${escapeHtml(item.desc)}</div>
1333
+ </div>
1334
+ </div>
1335
+ <span style="font-size: 11px; color: var(--ink-tertiary);">↵</span>
1336
+ </div>
1337
+ `).join("");
1338
+ }
1339
+
1340
+ function executeCommandByIndex(index) {
1341
+ const list = window._activeCommandsList || defaultCommands;
1342
+ if (list[index] && typeof list[index].action === "function") {
1343
+ closeModal("modal-command-palette");
1344
+ list[index].action();
1345
+ }
1346
+ }
1347
+
1348
+ function handleCommandKeyDown(e) {
1349
+ const list = window._activeCommandsList || [];
1350
+ if (list.length === 0) return;
1351
+
1352
+ if (e.key === "ArrowDown") {
1353
+ e.preventDefault();
1354
+ cmdSelectedIndex = (cmdSelectedIndex + 1) % list.length;
1355
+ updateSelectedCmdItem();
1356
+ } else if (e.key === "ArrowUp") {
1357
+ e.preventDefault();
1358
+ cmdSelectedIndex = (cmdSelectedIndex - 1 + list.length) % list.length;
1359
+ updateSelectedCmdItem();
1360
+ } else if (e.key === "Enter") {
1361
+ e.preventDefault();
1362
+ executeCommandByIndex(cmdSelectedIndex);
1363
+ }
1364
+ }
1365
+
1366
+ function updateSelectedCmdItem() {
1367
+ document.querySelectorAll(".cmd-item").forEach((el, idx) => {
1368
+ el.classList.toggle("selected", idx === cmdSelectedIndex);
1369
+ if (idx === cmdSelectedIndex) {
1370
+ el.scrollIntoView({ block: "nearest" });
1371
+ }
1372
+ });
1373
+ }
1374
+
1375
+ // Global Keyboard Shortcut: Ctrl+K / Cmd+K
1376
+ document.addEventListener("keydown", (e) => {
1377
+ if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k") {
1378
+ e.preventDefault();
1379
+ const modal = document.getElementById("modal-command-palette");
1380
+ if (modal?.classList.contains("show")) {
1381
+ closeModal("modal-command-palette");
1382
+ } else {
1383
+ openCommandPalette();
1384
+ }
1385
+ } else if (e.key === "Escape") {
1386
+ closeModal("modal-command-palette");
1387
+ closeDropdowns();
1388
+ }
1389
+ });
1390
+