trackops 1.0.0 → 1.0.1
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/README.md +59 -6
- package/lib/control.js +1 -0
- package/lib/init.js +12 -10
- package/lib/opera.js +3 -3
- package/lib/server.js +141 -0
- package/lib/skills.js +1 -1
- package/package.json +16 -3
- package/templates/etapa/agent.md +2 -2
- package/templates/etapa/references/etapa-cycle.md +1 -1
- package/templates/opera/agent.md +1 -1
- package/templates/skills/project-starter-skill/SKILL.md +5 -3
- package/ui/css/base.css +266 -0
- package/ui/css/charts.css +327 -0
- package/ui/css/components.css +570 -0
- package/ui/css/panels.css +953 -0
- package/ui/css/tokens.css +227 -0
- package/ui/favicon.svg +5 -0
- package/ui/index.html +91 -351
- package/ui/js/api.js +203 -0
- package/ui/js/app.js +199 -0
- package/ui/js/console-logger.js +172 -0
- package/ui/js/icons.js +104 -0
- package/ui/js/onboarding.js +437 -0
- package/ui/js/router.js +125 -0
- package/ui/js/state.js +129 -0
- package/ui/js/theme.js +100 -0
- package/ui/js/time-tracker.js +248 -0
- package/ui/js/utils.js +172 -0
- package/ui/js/views/board.js +254 -0
- package/ui/js/views/execution.js +256 -0
- package/ui/js/views/flash.js +47 -0
- package/ui/js/views/insights.js +339 -0
- package/ui/js/views/overview.js +364 -0
- package/ui/js/views/settings.js +243 -0
- package/ui/js/views/sidebar.js +132 -0
- package/ui/js/views/skills.js +162 -0
- package/ui/js/views/tasks.js +405 -0
- package/ui/js/views/topbar.js +183 -0
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* board.js — Tablero Kanban con drag & drop
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { icon } from '../icons.js';
|
|
6
|
+
import * as state from '../state.js';
|
|
7
|
+
import * as api from '../api.js';
|
|
8
|
+
import * as router from '../router.js';
|
|
9
|
+
import { flash } from './flash.js';
|
|
10
|
+
import { esc, debounce } from '../utils.js';
|
|
11
|
+
|
|
12
|
+
const COLUMNS = [
|
|
13
|
+
{ id: 'pending', label: 'Pendiente' },
|
|
14
|
+
{ id: 'in_progress', label: 'En Progreso' },
|
|
15
|
+
{ id: 'in_review', label: 'En Revisión' },
|
|
16
|
+
{ id: 'blocked', label: 'Bloqueado' },
|
|
17
|
+
{ id: 'completed', label: 'Completado' },
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
let _dragTaskId = null;
|
|
21
|
+
|
|
22
|
+
export async function render() {
|
|
23
|
+
const payload = state.getPayload();
|
|
24
|
+
if (!payload) return '<div class="empty-state" style="margin:3rem">Sin datos del proyecto.</div>';
|
|
25
|
+
|
|
26
|
+
const tasks = _filterTasks(payload.derived.tasks);
|
|
27
|
+
const hasCancelled = tasks.some(t => t.status === 'cancelled');
|
|
28
|
+
const columns = hasCancelled
|
|
29
|
+
? [...COLUMNS, { id: 'cancelled', label: 'Cancelado' }]
|
|
30
|
+
: COLUMNS;
|
|
31
|
+
|
|
32
|
+
const html = `
|
|
33
|
+
<div class="view-enter">
|
|
34
|
+
<div class="section-header">
|
|
35
|
+
<div class="section-header-left">
|
|
36
|
+
<p class="eyebrow">Tablero</p>
|
|
37
|
+
<h2>Tablero Operativo</h2>
|
|
38
|
+
</div>
|
|
39
|
+
<div style="display:flex;gap:var(--space-2)">
|
|
40
|
+
<button class="btn btn-ghost btn-sm" id="board-filter-done" type="button">
|
|
41
|
+
${icon('check', 14)} ${_showCompleted() ? 'Ocultar completadas' : 'Mostrar completadas'}
|
|
42
|
+
</button>
|
|
43
|
+
<button class="btn btn-primary btn-sm" id="new-task-btn" type="button" aria-label="Crear nueva tarea">
|
|
44
|
+
${icon('plus', 14)} Nueva tarea
|
|
45
|
+
</button>
|
|
46
|
+
</div>
|
|
47
|
+
</div>
|
|
48
|
+
|
|
49
|
+
<div class="board-grid" id="board" aria-label="Tablero de tareas por estado" role="region">
|
|
50
|
+
${columns.map(col => {
|
|
51
|
+
const colTasks = tasks.filter(t => t.status === col.id);
|
|
52
|
+
return _renderColumn(col, colTasks);
|
|
53
|
+
}).join('')}
|
|
54
|
+
</div>
|
|
55
|
+
</div>
|
|
56
|
+
`;
|
|
57
|
+
|
|
58
|
+
setTimeout(() => _bindEvents(), 0);
|
|
59
|
+
return html;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function _filterTasks(tasks) {
|
|
63
|
+
let list = tasks;
|
|
64
|
+
const query = state.get('searchQuery')?.toLowerCase();
|
|
65
|
+
if (query) {
|
|
66
|
+
list = list.filter(t =>
|
|
67
|
+
t.title.toLowerCase().includes(query) ||
|
|
68
|
+
t.id.toLowerCase().includes(query) ||
|
|
69
|
+
(t.summary || '').toLowerCase().includes(query)
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
if (!_showCompleted()) {
|
|
73
|
+
list = list.filter(t => t.status !== 'completed' && t.status !== 'cancelled');
|
|
74
|
+
}
|
|
75
|
+
return list;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function _showCompleted() {
|
|
79
|
+
return sessionStorage.getItem('board-show-completed') === 'true';
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function _renderColumn(col, tasks) {
|
|
83
|
+
return `
|
|
84
|
+
<section class="board-column col-${col.id}" data-status="${col.id}" aria-label="Columna ${col.label}">
|
|
85
|
+
<div class="board-column-header">
|
|
86
|
+
<h3 class="board-column-title" id="col-${col.id}">
|
|
87
|
+
<span class="board-column-dot" aria-hidden="true"></span>
|
|
88
|
+
${esc(col.label)}
|
|
89
|
+
</h3>
|
|
90
|
+
<span class="board-column-count" aria-label="${tasks.length} tareas">${tasks.length}</span>
|
|
91
|
+
</div>
|
|
92
|
+
<div class="board-column-body" aria-labelledby="col-${col.id}" role="list">
|
|
93
|
+
${tasks.map(t => _renderCard(t)).join('')}
|
|
94
|
+
${tasks.length === 0
|
|
95
|
+
? `<div class="empty-state" style="padding:var(--space-5);min-height:80px;border-style:dashed">Sin tareas</div>`
|
|
96
|
+
: ''}
|
|
97
|
+
</div>
|
|
98
|
+
</section>
|
|
99
|
+
`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function _renderCard(task) {
|
|
103
|
+
const isSelected = task.id === state.get('selectedTaskId');
|
|
104
|
+
const statusLabels = state.getStatusLabels();
|
|
105
|
+
const phases = state.getPhases();
|
|
106
|
+
const phaseInfo = phases.find(p => p.id === task.phase);
|
|
107
|
+
|
|
108
|
+
const priorityVariant = { P0: 'danger', P1: 'warning', P2: 'accent', P3: 'muted' };
|
|
109
|
+
|
|
110
|
+
return `
|
|
111
|
+
<article
|
|
112
|
+
class="task-card ${isSelected ? 'is-selected' : ''}"
|
|
113
|
+
data-task-id="${esc(task.id)}"
|
|
114
|
+
data-status="${esc(task.status)}"
|
|
115
|
+
draggable="true"
|
|
116
|
+
role="listitem"
|
|
117
|
+
tabindex="0"
|
|
118
|
+
aria-label="${esc(task.title)}, ${statusLabels[task.status] || task.status}, prioridad ${task.priority}"
|
|
119
|
+
aria-selected="${isSelected}"
|
|
120
|
+
>
|
|
121
|
+
<strong class="task-card-title">${esc(task.title)}</strong>
|
|
122
|
+
<span class="task-card-id">${esc(task.id)}</span>
|
|
123
|
+
<p class="task-card-summary">${esc(task.summary || 'Sin descripción.')}</p>
|
|
124
|
+
<div class="task-card-meta">
|
|
125
|
+
<span class="badge badge-${priorityVariant[task.priority] || 'muted'}">${esc(task.priority)}</span>
|
|
126
|
+
<span class="badge badge-muted">${esc(phaseInfo?.label || task.phase)}</span>
|
|
127
|
+
${task.stream ? `<span class="badge badge-muted">${esc(task.stream)}</span>` : ''}
|
|
128
|
+
${task.blocker ? `<span class="badge badge-danger" title="${esc(task.blocker)}">${icon('alertTriangle', 10)} Bloqueada</span>` : ''}
|
|
129
|
+
</div>
|
|
130
|
+
</article>
|
|
131
|
+
`;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function _bindEvents() {
|
|
135
|
+
const board = document.getElementById('board');
|
|
136
|
+
if (!board) return;
|
|
137
|
+
|
|
138
|
+
// Clic en task card → seleccionar y navegar a tasks
|
|
139
|
+
board.addEventListener('click', e => {
|
|
140
|
+
const card = e.target.closest('.task-card');
|
|
141
|
+
if (!card) return;
|
|
142
|
+
|
|
143
|
+
const id = card.dataset.taskId;
|
|
144
|
+
state.update('selectedTaskId', id);
|
|
145
|
+
|
|
146
|
+
// Navegar al editor si hubo doble clic; si un clic simple, solo marcar
|
|
147
|
+
if (e.detail === 2) {
|
|
148
|
+
router.navigate('tasks');
|
|
149
|
+
} else {
|
|
150
|
+
// Resaltar la card seleccionada sin re-render completo
|
|
151
|
+
board.querySelectorAll('.task-card').forEach(c => {
|
|
152
|
+
c.classList.toggle('is-selected', c.dataset.taskId === id);
|
|
153
|
+
c.setAttribute('aria-selected', c.dataset.taskId === id ? 'true' : 'false');
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// Teclado en cards (Enter = seleccionar, Space = toggle)
|
|
159
|
+
board.addEventListener('keydown', e => {
|
|
160
|
+
const card = e.target.closest('.task-card');
|
|
161
|
+
if (!card) return;
|
|
162
|
+
if (e.key === 'Enter') {
|
|
163
|
+
state.update('selectedTaskId', card.dataset.taskId);
|
|
164
|
+
router.navigate('tasks');
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
// Toggle mostrar completadas
|
|
169
|
+
document.getElementById('board-filter-done')?.addEventListener('click', () => {
|
|
170
|
+
const current = _showCompleted();
|
|
171
|
+
sessionStorage.setItem('board-show-completed', current ? 'false' : 'true');
|
|
172
|
+
router.refresh();
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
// Nueva tarea
|
|
176
|
+
document.getElementById('new-task-btn')?.addEventListener('click', () => {
|
|
177
|
+
state.update('selectedTaskId', null);
|
|
178
|
+
router.navigate('tasks');
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
// Drag & drop
|
|
182
|
+
_bindDragDrop(board);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function _bindDragDrop(board) {
|
|
186
|
+
// Dragstart
|
|
187
|
+
board.addEventListener('dragstart', e => {
|
|
188
|
+
const card = e.target.closest('.task-card');
|
|
189
|
+
if (!card) return;
|
|
190
|
+
_dragTaskId = card.dataset.taskId;
|
|
191
|
+
card.classList.add('is-dragging');
|
|
192
|
+
e.dataTransfer.setData('text/plain', _dragTaskId);
|
|
193
|
+
e.dataTransfer.effectAllowed = 'move';
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
board.addEventListener('dragend', e => {
|
|
197
|
+
const card = e.target.closest('.task-card');
|
|
198
|
+
card?.classList.remove('is-dragging');
|
|
199
|
+
_dragTaskId = null;
|
|
200
|
+
board.querySelectorAll('.board-column').forEach(col => col.classList.remove('is-drop-target'));
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
board.addEventListener('dragover', e => {
|
|
204
|
+
e.preventDefault();
|
|
205
|
+
const col = e.target.closest('.board-column');
|
|
206
|
+
if (!col) return;
|
|
207
|
+
e.dataTransfer.dropEffect = 'move';
|
|
208
|
+
board.querySelectorAll('.board-column').forEach(c => c.classList.remove('is-drop-target'));
|
|
209
|
+
col.classList.add('is-drop-target');
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
board.addEventListener('dragleave', e => {
|
|
213
|
+
const col = e.target.closest('.board-column');
|
|
214
|
+
if (!col) return;
|
|
215
|
+
// Solo eliminar si salimos de la columna, no de un hijo
|
|
216
|
+
if (!col.contains(e.relatedTarget)) {
|
|
217
|
+
col.classList.remove('is-drop-target');
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
board.addEventListener('drop', async e => {
|
|
222
|
+
e.preventDefault();
|
|
223
|
+
const col = e.target.closest('.board-column');
|
|
224
|
+
if (!col) return;
|
|
225
|
+
col.classList.remove('is-drop-target');
|
|
226
|
+
|
|
227
|
+
const taskId = e.dataTransfer.getData('text/plain') || _dragTaskId;
|
|
228
|
+
if (!taskId) return;
|
|
229
|
+
|
|
230
|
+
const newStatus = col.dataset.status;
|
|
231
|
+
const task = state.getPayload()?.derived?.tasks?.find(t => t.id === taskId);
|
|
232
|
+
if (!task || task.status === newStatus) return;
|
|
233
|
+
|
|
234
|
+
const statusToAction = {
|
|
235
|
+
pending: 'pending',
|
|
236
|
+
in_progress: 'start',
|
|
237
|
+
in_review: 'review',
|
|
238
|
+
blocked: 'block',
|
|
239
|
+
completed: 'complete',
|
|
240
|
+
cancelled: 'cancel',
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
const action = statusToAction[newStatus];
|
|
244
|
+
if (!action) return;
|
|
245
|
+
|
|
246
|
+
try {
|
|
247
|
+
await api.taskAction(taskId, action, `Movida a ${newStatus} desde el tablero.`);
|
|
248
|
+
flash(`Tarea movida a ${newStatus}.`, 'success');
|
|
249
|
+
window.dispatchEvent(new CustomEvent('ops:refresh'));
|
|
250
|
+
} catch (err) {
|
|
251
|
+
flash(err.message, 'error');
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
}
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* execution.js — Consola integrada + sesiones de comandos
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { icon } from '../icons.js';
|
|
6
|
+
import * as state from '../state.js';
|
|
7
|
+
import * as api from '../api.js';
|
|
8
|
+
import { flash } from './flash.js';
|
|
9
|
+
import { esc, formatDate } from '../utils.js';
|
|
10
|
+
|
|
11
|
+
const QUICK_COMMANDS = [
|
|
12
|
+
{ label: 'status', cmd: 'npx trackops status' },
|
|
13
|
+
{ label: 'sincronizar docs', cmd: 'npx trackops sync' },
|
|
14
|
+
{ label: 'siguientes tareas', cmd: 'npx trackops next' },
|
|
15
|
+
{ label: 'refrescar repo', cmd: 'npx trackops refresh-repo' },
|
|
16
|
+
{ label: 'git status', cmd: 'git status --short' },
|
|
17
|
+
{ label: 'git log', cmd: 'git log --oneline -10' },
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
export async function render() {
|
|
21
|
+
const sessions = state.get('sessions');
|
|
22
|
+
const selected = sessions.find(s => s.id === state.get('selectedSessionId'));
|
|
23
|
+
|
|
24
|
+
return `
|
|
25
|
+
<div class="view-enter">
|
|
26
|
+
<div class="section-header">
|
|
27
|
+
<div class="section-header-left">
|
|
28
|
+
<p class="eyebrow">Ejecución</p>
|
|
29
|
+
<h2>Consola de Comandos</h2>
|
|
30
|
+
</div>
|
|
31
|
+
</div>
|
|
32
|
+
|
|
33
|
+
<div style="display:grid;grid-template-columns:300px 1fr;gap:var(--space-4);align-items:start">
|
|
34
|
+
|
|
35
|
+
<!-- Panel izquierdo: input + sesiones -->
|
|
36
|
+
<div style="display:flex;flex-direction:column;gap:var(--space-4)">
|
|
37
|
+
|
|
38
|
+
<!-- Command input -->
|
|
39
|
+
<div class="panel">
|
|
40
|
+
<div class="panel-header">
|
|
41
|
+
<p class="panel-title">Ejecutar</p>
|
|
42
|
+
</div>
|
|
43
|
+
<div class="panel-body" style="display:flex;flex-direction:column;gap:var(--space-3)">
|
|
44
|
+
<div class="field">
|
|
45
|
+
<label for="cmd-input">Comando</label>
|
|
46
|
+
<textarea id="cmd-input" rows="3"
|
|
47
|
+
placeholder="npx trackops status"
|
|
48
|
+
aria-label="Introduce el comando a ejecutar"
|
|
49
|
+
style="font-family:var(--font-mono);font-size:var(--text-sm)"></textarea>
|
|
50
|
+
</div>
|
|
51
|
+
<button class="btn btn-primary" type="button" id="run-cmd-btn" aria-label="Ejecutar comando">
|
|
52
|
+
${icon('execution', 15)} Ejecutar
|
|
53
|
+
</button>
|
|
54
|
+
</div>
|
|
55
|
+
<div class="panel-footer">
|
|
56
|
+
<p class="label-sm" style="margin-bottom:var(--space-2)">Comandos rápidos</p>
|
|
57
|
+
<div class="preset-strip">
|
|
58
|
+
${QUICK_COMMANDS.map(c => `
|
|
59
|
+
<button class="chip" type="button" data-quick="${esc(c.cmd)}"
|
|
60
|
+
aria-label="Ejecutar ${esc(c.label)}">
|
|
61
|
+
${esc(c.label)}
|
|
62
|
+
</button>
|
|
63
|
+
`).join('')}
|
|
64
|
+
</div>
|
|
65
|
+
</div>
|
|
66
|
+
</div>
|
|
67
|
+
|
|
68
|
+
<!-- Lista de sesiones -->
|
|
69
|
+
<div class="panel">
|
|
70
|
+
<div class="panel-header">
|
|
71
|
+
<p class="panel-title">Sesiones</p>
|
|
72
|
+
<span class="badge badge-muted">${sessions.length}</span>
|
|
73
|
+
</div>
|
|
74
|
+
<div class="panel-body" style="max-height:360px;overflow-y:auto">
|
|
75
|
+
${sessions.length === 0
|
|
76
|
+
? `<div class="empty-state">Sin sesiones activas.</div>`
|
|
77
|
+
: `<div class="stack stack-sm">
|
|
78
|
+
${sessions.slice().reverse().map(s => `
|
|
79
|
+
<div class="session-pill ${s.id === state.get('selectedSessionId') ? 'is-selected' : ''}"
|
|
80
|
+
data-session-id="${esc(s.id)}"
|
|
81
|
+
role="button" tabindex="0"
|
|
82
|
+
aria-selected="${s.id === state.get('selectedSessionId')}"
|
|
83
|
+
aria-label="${esc(s.command)}, ${esc(s.status)}">
|
|
84
|
+
<span class="session-pill-cmd">${esc(s.command)}</span>
|
|
85
|
+
<span class="badge session-pill-status ${_sessionBadgeClass(s.status)}">${esc(s.status)}</span>
|
|
86
|
+
</div>
|
|
87
|
+
`).join('')}
|
|
88
|
+
</div>`
|
|
89
|
+
}
|
|
90
|
+
</div>
|
|
91
|
+
</div>
|
|
92
|
+
|
|
93
|
+
</div>
|
|
94
|
+
|
|
95
|
+
<!-- Terminal output -->
|
|
96
|
+
<div class="terminal-surface" style="min-height:600px;display:flex;flex-direction:column">
|
|
97
|
+
<div class="terminal-header">
|
|
98
|
+
<div class="terminal-dots" aria-hidden="true">
|
|
99
|
+
<span></span><span></span><span></span>
|
|
100
|
+
</div>
|
|
101
|
+
<p class="terminal-title">${selected ? esc(selected.command) : 'ops@terminal — sin sesión activa'}</p>
|
|
102
|
+
<div style="display:flex;gap:var(--space-2)">
|
|
103
|
+
${selected && selected.status === 'running' ? `
|
|
104
|
+
<button class="btn btn-ghost btn-sm" id="kill-session-btn" type="button" aria-label="Terminar proceso">
|
|
105
|
+
${icon('stop', 13)} Detener
|
|
106
|
+
</button>
|
|
107
|
+
` : ''}
|
|
108
|
+
</div>
|
|
109
|
+
</div>
|
|
110
|
+
<pre class="terminal-output" id="terminal-output" aria-label="Salida del comando" aria-live="polite">${selected ? esc(selected.output) : '# Ejecuta un comando para ver la salida aquí…\n'}</pre>
|
|
111
|
+
${selected ? `<div class="panel-footer" style="display:flex;justify-content:space-between;align-items:center;font-family:var(--font-mono);font-size:var(--text-xs)">
|
|
112
|
+
<span class="text-muted">Iniciado: ${formatDate(selected.startedAt)}</span>
|
|
113
|
+
<span class="badge ${_sessionBadgeClass(selected.status)}">${esc(selected.status)}${selected.exitCode != null ? ` (${selected.exitCode})` : ''}</span>
|
|
114
|
+
</div>` : ''}
|
|
115
|
+
</div>
|
|
116
|
+
|
|
117
|
+
</div>
|
|
118
|
+
</div>
|
|
119
|
+
`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function _sessionBadgeClass(status) {
|
|
123
|
+
const map = {
|
|
124
|
+
running: 'badge-info',
|
|
125
|
+
completed: 'badge-success',
|
|
126
|
+
failed: 'badge-danger',
|
|
127
|
+
terminated:'badge-warning',
|
|
128
|
+
};
|
|
129
|
+
return map[status] || 'badge-muted';
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function bindEvents() {
|
|
133
|
+
// Run command button
|
|
134
|
+
document.getElementById('run-cmd-btn')?.addEventListener('click', _runCommand);
|
|
135
|
+
|
|
136
|
+
// Ctrl+Enter en el textarea
|
|
137
|
+
document.getElementById('cmd-input')?.addEventListener('keydown', e => {
|
|
138
|
+
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
|
139
|
+
e.preventDefault();
|
|
140
|
+
_runCommand();
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// Quick commands
|
|
145
|
+
document.querySelectorAll('[data-quick]').forEach(btn => {
|
|
146
|
+
btn.addEventListener('click', () => {
|
|
147
|
+
const input = document.getElementById('cmd-input');
|
|
148
|
+
if (input) {
|
|
149
|
+
input.value = btn.dataset.quick;
|
|
150
|
+
input.focus();
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
// Seleccionar sesión
|
|
156
|
+
document.querySelectorAll('[data-session-id]').forEach(pill => {
|
|
157
|
+
pill.addEventListener('click', () => {
|
|
158
|
+
const id = pill.dataset.sessionId;
|
|
159
|
+
state.update('selectedSessionId', id);
|
|
160
|
+
import('../router.js').then(r => r.refresh());
|
|
161
|
+
});
|
|
162
|
+
pill.addEventListener('keydown', e => {
|
|
163
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
164
|
+
e.preventDefault();
|
|
165
|
+
pill.click();
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function _runCommand() {
|
|
172
|
+
const input = document.getElementById('cmd-input');
|
|
173
|
+
const cmd = input?.value.trim();
|
|
174
|
+
if (!cmd) return;
|
|
175
|
+
|
|
176
|
+
const btn = document.getElementById('run-cmd-btn');
|
|
177
|
+
if (btn) { btn.disabled = true; btn.innerHTML = `${icon('spinner', 15)} Ejecutando…`; }
|
|
178
|
+
|
|
179
|
+
try {
|
|
180
|
+
const result = await api.runCommand(cmd);
|
|
181
|
+
const sessionId = result.session?.id || result.sessionId;
|
|
182
|
+
|
|
183
|
+
// Añadir sesión al estado
|
|
184
|
+
const sessions = state.get('sessions');
|
|
185
|
+
const newSession = {
|
|
186
|
+
id: sessionId,
|
|
187
|
+
command: cmd,
|
|
188
|
+
status: 'running',
|
|
189
|
+
exitCode: null,
|
|
190
|
+
output: '',
|
|
191
|
+
startedAt: new Date().toISOString(),
|
|
192
|
+
};
|
|
193
|
+
sessions.push(newSession);
|
|
194
|
+
state.update('sessions', sessions);
|
|
195
|
+
state.update('selectedSessionId', sessionId);
|
|
196
|
+
|
|
197
|
+
// Refresh UI
|
|
198
|
+
import('../router.js').then(r => r.refresh()).then(() => {
|
|
199
|
+
_subscribeToStream(sessionId, cmd);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
if (input) input.value = '';
|
|
203
|
+
} catch (err) {
|
|
204
|
+
flash(err.message, 'error');
|
|
205
|
+
} finally {
|
|
206
|
+
if (btn) { btn.disabled = false; btn.innerHTML = `${icon('execution', 15)} Ejecutar`; }
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function _subscribeToStream(sessionId, _cmd) {
|
|
211
|
+
const es = api.streamSession(sessionId);
|
|
212
|
+
const terminal = document.getElementById('terminal-output');
|
|
213
|
+
|
|
214
|
+
es.addEventListener('message', e => {
|
|
215
|
+
try {
|
|
216
|
+
const data = JSON.parse(e.data);
|
|
217
|
+
const sessions = state.get('sessions');
|
|
218
|
+
const session = sessions.find(s => s.id === sessionId);
|
|
219
|
+
|
|
220
|
+
if (data.type === 'stdout' || data.type === 'stderr') {
|
|
221
|
+
if (session) session.output += data.chunk;
|
|
222
|
+
if (terminal && state.get('selectedSessionId') === sessionId) {
|
|
223
|
+
terminal.textContent = session?.output || '';
|
|
224
|
+
terminal.scrollTop = terminal.scrollHeight;
|
|
225
|
+
}
|
|
226
|
+
} else if (data.type === 'done') {
|
|
227
|
+
if (session) {
|
|
228
|
+
session.status = data.status;
|
|
229
|
+
session.exitCode = data.exitCode;
|
|
230
|
+
session.output = data.output;
|
|
231
|
+
}
|
|
232
|
+
state.update('sessions', sessions);
|
|
233
|
+
if (state.get('selectedSessionId') === sessionId) {
|
|
234
|
+
import('../router.js').then(r => r.refresh());
|
|
235
|
+
}
|
|
236
|
+
es.close();
|
|
237
|
+
window.dispatchEvent(new CustomEvent('ops:refresh'));
|
|
238
|
+
}
|
|
239
|
+
} catch (err) {
|
|
240
|
+
console.error('[execution] Error parseando evento SSE:', err);
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
es.onerror = () => {
|
|
245
|
+
const sessions = state.get('sessions');
|
|
246
|
+
const session = sessions.find(s => s.id === sessionId);
|
|
247
|
+
if (session && session.status === 'running') {
|
|
248
|
+
session.status = 'failed';
|
|
249
|
+
state.update('sessions', sessions);
|
|
250
|
+
}
|
|
251
|
+
es.close();
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Vinculación de eventos se llama desde la vista
|
|
256
|
+
export { bindEvents as bind };
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* flash.js — Sistema de toasts / notificaciones
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const DURATION = 3500;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Muestra una notificación tipo toast
|
|
9
|
+
* @param {string} message
|
|
10
|
+
* @param {'success'|'error'|'warning'|'info'} [type='info']
|
|
11
|
+
* @param {number} [duration] ms antes de desaparecer (0 = permanente)
|
|
12
|
+
*/
|
|
13
|
+
export function flash(message, type = 'info', duration = DURATION) {
|
|
14
|
+
const container = document.getElementById('flash-container');
|
|
15
|
+
if (!container) return;
|
|
16
|
+
|
|
17
|
+
const icons = {
|
|
18
|
+
success: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>',
|
|
19
|
+
error: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>',
|
|
20
|
+
warning: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>',
|
|
21
|
+
info: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>',
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const colorMap = {
|
|
25
|
+
success: 'var(--success)',
|
|
26
|
+
error: 'var(--danger)',
|
|
27
|
+
warning: 'var(--warning)',
|
|
28
|
+
info: 'var(--accent)',
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const toastEl = document.createElement('div');
|
|
32
|
+
toastEl.className = `flash flash-${type}`;
|
|
33
|
+
toastEl.setAttribute('role', 'alert');
|
|
34
|
+
toastEl.innerHTML = `
|
|
35
|
+
<span style="color:${colorMap[type] || 'var(--accent)'}; flex-shrink:0">${icons[type] || icons.info}</span>
|
|
36
|
+
<span>${message}</span>
|
|
37
|
+
`;
|
|
38
|
+
|
|
39
|
+
container.appendChild(toastEl);
|
|
40
|
+
|
|
41
|
+
if (duration > 0) {
|
|
42
|
+
setTimeout(() => {
|
|
43
|
+
toastEl.classList.add('flash-exit');
|
|
44
|
+
setTimeout(() => toastEl.remove(), 300);
|
|
45
|
+
}, duration);
|
|
46
|
+
}
|
|
47
|
+
}
|