trackops 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +358 -0
- package/bin/trackops.js +103 -0
- package/lib/config.js +97 -0
- package/lib/control.js +575 -0
- package/lib/i18n.js +53 -0
- package/lib/init.js +200 -0
- package/lib/opera.js +202 -0
- package/lib/registry.js +182 -0
- package/lib/server.js +451 -0
- package/lib/skills.js +159 -0
- package/locales/en.json +142 -0
- package/locales/es.json +142 -0
- package/package.json +46 -0
- package/templates/etapa/agent.md +26 -0
- package/templates/etapa/genesis.md +94 -0
- package/templates/etapa/references/autonomy-and-recovery.md +117 -0
- package/templates/etapa/references/etapa-cycle.md +193 -0
- package/templates/etapa/registry.md +28 -0
- package/templates/etapa/router.md +39 -0
- package/templates/hooks/post-checkout +2 -0
- package/templates/hooks/post-commit +2 -0
- package/templates/hooks/post-merge +2 -0
- package/templates/opera/agent.md +26 -0
- package/templates/opera/genesis.md +94 -0
- package/templates/opera/references/autonomy-and-recovery.md +117 -0
- package/templates/opera/references/opera-cycle.md +193 -0
- package/templates/opera/registry.md +28 -0
- package/templates/opera/router.md +39 -0
- package/templates/skills/changelog-updater/SKILL.md +69 -0
- package/templates/skills/commiter/SKILL.md +99 -0
- package/templates/skills/project-starter-skill/SKILL.md +202 -0
- package/templates/skills/project-starter-skill/references/opera-cycle.md +193 -0
- package/ui/app.js +950 -0
- package/ui/index.html +356 -0
- package/ui/styles.css +688 -0
package/ui/app.js
ADDED
|
@@ -0,0 +1,950 @@
|
|
|
1
|
+
const BOARD = ["pending", "in_progress", "in_review", "blocked", "completed"];
|
|
2
|
+
const STATUS_ACTIONS = {
|
|
3
|
+
pending: "pending", in_progress: "start", in_review: "review",
|
|
4
|
+
blocked: "block", completed: "complete", cancelled: "cancel",
|
|
5
|
+
};
|
|
6
|
+
let STATUS = {
|
|
7
|
+
pending: { label: "Pending", action: "pending" },
|
|
8
|
+
in_progress: { label: "In progress", action: "start" },
|
|
9
|
+
in_review: { label: "In review", action: "review" },
|
|
10
|
+
blocked: { label: "Blocked", action: "block" },
|
|
11
|
+
completed: { label: "Completed", action: "complete" },
|
|
12
|
+
cancelled: { label: "Cancelled", action: "cancel" },
|
|
13
|
+
};
|
|
14
|
+
let PHASE = {};
|
|
15
|
+
let PHASES = [];
|
|
16
|
+
let LOCALE = "es";
|
|
17
|
+
const QUICK = ["trackops status", "trackops next", "trackops sync", "git status --short", "npm run build"];
|
|
18
|
+
|
|
19
|
+
const ui = {};
|
|
20
|
+
const state = {
|
|
21
|
+
projects: [],
|
|
22
|
+
registryFile: "",
|
|
23
|
+
currentProjectId: null,
|
|
24
|
+
payload: null,
|
|
25
|
+
selectedTaskId: null,
|
|
26
|
+
sessions: [],
|
|
27
|
+
selectedSessionId: null,
|
|
28
|
+
stream: null,
|
|
29
|
+
flashTimer: null,
|
|
30
|
+
loaded: false,
|
|
31
|
+
activeTab: "overview",
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
document.addEventListener("DOMContentLoaded", async () => {
|
|
35
|
+
cacheDom();
|
|
36
|
+
bind();
|
|
37
|
+
renderQuickCommands();
|
|
38
|
+
await loadProjects();
|
|
39
|
+
await refreshState();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
function cacheDom() {
|
|
43
|
+
Object.assign(ui, {
|
|
44
|
+
focusSummary: document.getElementById("focusSummary"),
|
|
45
|
+
projectName: document.getElementById("projectName"),
|
|
46
|
+
projectSelect: document.getElementById("projectSelect"),
|
|
47
|
+
runtimeBadge: document.getElementById("runtimeBadge"),
|
|
48
|
+
refreshButton: document.getElementById("refreshButton"),
|
|
49
|
+
syncButton: document.getElementById("syncButton"),
|
|
50
|
+
phaseBadge: document.getElementById("phaseBadge"),
|
|
51
|
+
branchBadge: document.getElementById("branchBadge"),
|
|
52
|
+
deliveryTarget: document.getElementById("deliveryTarget"),
|
|
53
|
+
summaryGrid: document.getElementById("summaryGrid"),
|
|
54
|
+
nextTaskCard: document.getElementById("nextTaskCard"),
|
|
55
|
+
projectList: document.getElementById("projectList"),
|
|
56
|
+
registerProjectForm: document.getElementById("registerProjectForm"),
|
|
57
|
+
registerRootInput: document.getElementById("registerRootInput"),
|
|
58
|
+
installProjectForm: document.getElementById("installProjectForm"),
|
|
59
|
+
installRootInput: document.getElementById("installRootInput"),
|
|
60
|
+
repoOverview: document.getElementById("repoOverview"),
|
|
61
|
+
docsList: document.getElementById("docsList"),
|
|
62
|
+
decisionList: document.getElementById("decisionList"),
|
|
63
|
+
phaseChart: document.getElementById("phaseChart"),
|
|
64
|
+
statusChart: document.getElementById("statusChart"),
|
|
65
|
+
activityChart: document.getElementById("activityChart"),
|
|
66
|
+
board: document.getElementById("board"),
|
|
67
|
+
editorTitle: document.getElementById("editorTitle"),
|
|
68
|
+
newTaskButton: document.getElementById("newTaskButton"),
|
|
69
|
+
resetTaskButton: document.getElementById("resetTaskButton"),
|
|
70
|
+
taskActionStrip: document.getElementById("taskActionStrip"),
|
|
71
|
+
taskForm: document.getElementById("taskForm"),
|
|
72
|
+
duplicateTaskButton: document.getElementById("duplicateTaskButton"),
|
|
73
|
+
commandForm: document.getElementById("commandForm"),
|
|
74
|
+
commandInput: document.getElementById("commandInput"),
|
|
75
|
+
commandPresets: document.getElementById("commandPresets"),
|
|
76
|
+
sessionList: document.getElementById("sessionList"),
|
|
77
|
+
terminalOutput: document.getElementById("terminalOutput"),
|
|
78
|
+
terminalStatus: document.getElementById("terminalStatus"),
|
|
79
|
+
executionMetrics: document.getElementById("executionMetrics"),
|
|
80
|
+
activityList: document.getElementById("activityList"),
|
|
81
|
+
findingList: document.getElementById("findingList"),
|
|
82
|
+
healthRail: document.getElementById("healthRail"),
|
|
83
|
+
flash: document.getElementById("flash"),
|
|
84
|
+
tabButtons: Array.from(document.querySelectorAll(".tab-button")),
|
|
85
|
+
tabPanels: Array.from(document.querySelectorAll(".tab-panel")),
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
ui.fields = {
|
|
89
|
+
title: document.getElementById("taskTitle"),
|
|
90
|
+
phase: document.getElementById("taskPhase"),
|
|
91
|
+
priority: document.getElementById("taskPriority"),
|
|
92
|
+
status: document.getElementById("taskStatus"),
|
|
93
|
+
stream: document.getElementById("taskStream"),
|
|
94
|
+
required: document.getElementById("taskRequired"),
|
|
95
|
+
summary: document.getElementById("taskSummary"),
|
|
96
|
+
dependsOn: document.getElementById("taskDependsOn"),
|
|
97
|
+
acceptance: document.getElementById("taskAcceptance"),
|
|
98
|
+
blocker: document.getElementById("taskBlocker"),
|
|
99
|
+
note: document.getElementById("taskNote"),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function bind() {
|
|
104
|
+
ui.tabButtons.forEach((button) => button.addEventListener("click", () => setActiveTab(button.dataset.tab)));
|
|
105
|
+
ui.projectSelect.addEventListener("change", async () => {
|
|
106
|
+
state.currentProjectId = ui.projectSelect.value;
|
|
107
|
+
localStorage.setItem("ops-dashboard-project", state.currentProjectId);
|
|
108
|
+
state.selectedTaskId = null;
|
|
109
|
+
await refreshState({ preserveSelection: false });
|
|
110
|
+
});
|
|
111
|
+
ui.refreshButton.addEventListener("click", async () => {
|
|
112
|
+
await loadProjects();
|
|
113
|
+
await refreshState({ preserveSelection: true });
|
|
114
|
+
});
|
|
115
|
+
ui.syncButton.addEventListener("click", syncDocs);
|
|
116
|
+
ui.newTaskButton.addEventListener("click", interceptSummaryButton(resetForm));
|
|
117
|
+
ui.resetTaskButton.addEventListener("click", interceptSummaryButton(resetForm));
|
|
118
|
+
ui.duplicateTaskButton.addEventListener("click", duplicateTask);
|
|
119
|
+
ui.taskForm.addEventListener("submit", saveTask);
|
|
120
|
+
ui.taskActionStrip.addEventListener("click", handleTaskAction);
|
|
121
|
+
ui.commandForm.addEventListener("submit", runCommand);
|
|
122
|
+
ui.registerProjectForm.addEventListener("submit", registerProject);
|
|
123
|
+
ui.installProjectForm.addEventListener("submit", installProject);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function interceptSummaryButton(handler) {
|
|
127
|
+
return (event) => {
|
|
128
|
+
event.preventDefault();
|
|
129
|
+
event.stopPropagation();
|
|
130
|
+
handler();
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function api(url, options = {}) {
|
|
135
|
+
const target = new URL(url, window.location.origin);
|
|
136
|
+
if (options.projectAware !== false && state.currentProjectId && !target.searchParams.has("project")) {
|
|
137
|
+
target.searchParams.set("project", state.currentProjectId);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const response = await fetch(target, {
|
|
141
|
+
...options,
|
|
142
|
+
headers: { "Content-Type": "application/json", ...(options.headers || {}) },
|
|
143
|
+
});
|
|
144
|
+
const text = await response.text();
|
|
145
|
+
const json = text ? JSON.parse(text) : {};
|
|
146
|
+
if (!response.ok || json.ok === false) {
|
|
147
|
+
throw new Error(json.error || "Operacion no disponible.");
|
|
148
|
+
}
|
|
149
|
+
return json;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function loadProjects() {
|
|
153
|
+
const payload = await api("/api/projects", { projectAware: false });
|
|
154
|
+
state.projects = payload.projects || [];
|
|
155
|
+
state.registryFile = payload.registryFile || "";
|
|
156
|
+
|
|
157
|
+
const stored = localStorage.getItem("ops-dashboard-project");
|
|
158
|
+
const preferred = state.projects.find((project) => project.id === stored && project.available)
|
|
159
|
+
? stored
|
|
160
|
+
: payload.currentProjectId;
|
|
161
|
+
state.currentProjectId = preferred || state.projects.find((project) => project.available)?.id || null;
|
|
162
|
+
renderProjectSelector();
|
|
163
|
+
renderProjectList();
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function refreshState({ preserveSelection = true } = {}) {
|
|
167
|
+
try {
|
|
168
|
+
if (!state.currentProjectId) {
|
|
169
|
+
await loadProjects();
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const payload = await api("/api/state");
|
|
173
|
+
const ids = new Set(payload.derived.tasks.map((task) => task.id));
|
|
174
|
+
state.payload = payload;
|
|
175
|
+
|
|
176
|
+
if (!preserveSelection || (state.selectedTaskId && !ids.has(state.selectedTaskId))) {
|
|
177
|
+
state.selectedTaskId = null;
|
|
178
|
+
}
|
|
179
|
+
if (!state.selectedTaskId && (!state.loaded || !preserveSelection)) {
|
|
180
|
+
state.selectedTaskId = payload.derived.nextTask?.id || payload.derived.tasks[0]?.id || null;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Apply dynamic i18n from server
|
|
184
|
+
if (payload.i18n) {
|
|
185
|
+
if (payload.i18n.locale) LOCALE = payload.i18n.locale;
|
|
186
|
+
if (payload.i18n.statusLabels) {
|
|
187
|
+
for (const [key, label] of Object.entries(payload.i18n.statusLabels)) {
|
|
188
|
+
if (STATUS[key]) STATUS[key].label = label;
|
|
189
|
+
else STATUS[key] = { label, action: STATUS_ACTIONS[key] || key };
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
if (payload.i18n.phases) {
|
|
193
|
+
PHASES = payload.i18n.phases;
|
|
194
|
+
PHASE = {};
|
|
195
|
+
for (const p of PHASES) PHASE[p.id] = p.label;
|
|
196
|
+
rebuildPhaseSelect();
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
state.loaded = true;
|
|
201
|
+
render();
|
|
202
|
+
} catch (error) {
|
|
203
|
+
flash(error.message, true);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function rebuildPhaseSelect() {
|
|
208
|
+
const sel = document.getElementById("taskPhase");
|
|
209
|
+
if (!sel || !PHASES.length) return;
|
|
210
|
+
const currentVal = sel.value;
|
|
211
|
+
sel.innerHTML = PHASES.map((p) => `<option value="${esc(p.id)}">${esc(p.id)} — ${esc(p.label)}</option>`).join("");
|
|
212
|
+
if (currentVal && [...sel.options].some((o) => o.value === currentVal)) sel.value = currentVal;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function render() {
|
|
216
|
+
renderHeader();
|
|
217
|
+
renderProjectList();
|
|
218
|
+
renderMetrics();
|
|
219
|
+
renderNext();
|
|
220
|
+
renderRepo();
|
|
221
|
+
renderDecisions();
|
|
222
|
+
renderCharts();
|
|
223
|
+
renderBoard();
|
|
224
|
+
renderEditor();
|
|
225
|
+
renderExecutionMetrics();
|
|
226
|
+
renderActivity();
|
|
227
|
+
renderFindings();
|
|
228
|
+
renderHealth();
|
|
229
|
+
renderSessions();
|
|
230
|
+
renderTerminal();
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function renderProjectSelector() {
|
|
234
|
+
ui.projectSelect.innerHTML = state.projects
|
|
235
|
+
.map(
|
|
236
|
+
(project) => `
|
|
237
|
+
<option value="${esc(project.id)}" ${project.id === state.currentProjectId ? "selected" : ""} ${project.available ? "" : "disabled"}>
|
|
238
|
+
${esc(project.name)}${project.available ? "" : " (no disponible)"}
|
|
239
|
+
</option>
|
|
240
|
+
`
|
|
241
|
+
)
|
|
242
|
+
.join("");
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function renderHeader() {
|
|
246
|
+
const { project, control, derived, runtime } = state.payload;
|
|
247
|
+
ui.projectName.textContent = project.name;
|
|
248
|
+
ui.focusSummary.textContent = `${control.meta.currentFocus} | ${derived.activePhase.label} | ${derived.totals.completed}/${derived.totals.all} completadas`;
|
|
249
|
+
ui.deliveryTarget.textContent = control.meta.deliveryTarget;
|
|
250
|
+
ui.phaseBadge.textContent = `${derived.activePhase.id} · ${derived.activePhase.label}`;
|
|
251
|
+
ui.branchBadge.textContent = runtime.branch || "sin rama";
|
|
252
|
+
ui.runtimeBadge.textContent = runtime.clean
|
|
253
|
+
? "Repo limpio"
|
|
254
|
+
: `${runtime.staged} staged | ${runtime.unstaged} unstaged | ${runtime.untracked} untracked`;
|
|
255
|
+
ui.runtimeBadge.className = `runtime-badge ${runtime.clean ? "is-clean" : "is-dirty"}`;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function renderProjectList() {
|
|
259
|
+
if (!state.projects.length) {
|
|
260
|
+
ui.projectList.innerHTML = '<div class="empty-state">No hay proyectos registrados.</div>';
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
ui.projectList.innerHTML = state.projects
|
|
265
|
+
.map(
|
|
266
|
+
(project) => `
|
|
267
|
+
<article class="project-row ${project.id === state.currentProjectId ? "is-active" : ""}">
|
|
268
|
+
<div class="project-row-meta">
|
|
269
|
+
<strong>${esc(project.name)}</strong>
|
|
270
|
+
<div class="meta-text">${esc(project.root)}</div>
|
|
271
|
+
</div>
|
|
272
|
+
<div class="tag-row">
|
|
273
|
+
<span class="tag ${project.available ? "success" : "warn"}">${project.available ? "activo" : "pendiente"}</span>
|
|
274
|
+
<button class="chip-button" type="button" data-project-select="${esc(project.id)}">Abrir</button>
|
|
275
|
+
</div>
|
|
276
|
+
</article>
|
|
277
|
+
`
|
|
278
|
+
)
|
|
279
|
+
.join("");
|
|
280
|
+
|
|
281
|
+
ui.projectList.querySelectorAll("[data-project-select]").forEach((button) => {
|
|
282
|
+
button.addEventListener("click", async () => {
|
|
283
|
+
state.currentProjectId = button.dataset.projectSelect;
|
|
284
|
+
localStorage.setItem("ops-dashboard-project", state.currentProjectId);
|
|
285
|
+
renderProjectSelector();
|
|
286
|
+
await refreshState({ preserveSelection: false });
|
|
287
|
+
});
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function renderMetrics() {
|
|
292
|
+
const { derived, runtime, docsDirty } = state.payload;
|
|
293
|
+
const cards = [
|
|
294
|
+
["Open work", derived.totals.all - derived.totals.completed - derived.totals.cancelled, `${derived.totals.pending} pendientes | ${derived.totals.inProgress} activas`],
|
|
295
|
+
["Bloqueos", derived.totals.blocked, derived.blockers[0]?.title || "Sin bloqueos"],
|
|
296
|
+
["Revision", derived.totals.inReview, derived.reviewTasks[0]?.title || "Sin tareas en revision"],
|
|
297
|
+
["Repo + docs", docsDirty.length ? docsDirty.length : runtime.clean ? "OK" : "DIRTY", docsDirty.length ? `Deriva: ${docsDirty.join(", ")}` : runtime.clean ? "Alineado" : "Arbol con cambios"],
|
|
298
|
+
];
|
|
299
|
+
|
|
300
|
+
ui.summaryGrid.innerHTML = cards
|
|
301
|
+
.map(
|
|
302
|
+
([title, value, support]) => `
|
|
303
|
+
<article class="metric-card">
|
|
304
|
+
<p class="eyebrow">${esc(title)}</p>
|
|
305
|
+
<p class="metric-value">${esc(value)}</p>
|
|
306
|
+
<p class="metric-support">${esc(support)}</p>
|
|
307
|
+
</article>
|
|
308
|
+
`
|
|
309
|
+
)
|
|
310
|
+
.join("");
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function renderNext() {
|
|
314
|
+
const task = state.payload.derived.nextTask;
|
|
315
|
+
ui.nextTaskCard.innerHTML = task ? taskSnippet(task) : '<div class="empty-state">No hay tareas abiertas.</div>';
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function renderRepo() {
|
|
319
|
+
const { project, runtime, docsDirty } = state.payload;
|
|
320
|
+
const rows = [
|
|
321
|
+
["Proyecto", project.name],
|
|
322
|
+
["Ruta", project.root],
|
|
323
|
+
["Ultimo commit", runtime.lastCommit ? `${runtime.lastCommit.shortHash} | ${runtime.lastCommit.subject}` : "Sin informacion"],
|
|
324
|
+
["Divergencia", `ahead ${runtime.ahead || 0} | behind ${runtime.behind || 0}`],
|
|
325
|
+
["Registro", state.registryFile || "No disponible"],
|
|
326
|
+
];
|
|
327
|
+
|
|
328
|
+
ui.repoOverview.innerHTML = rows
|
|
329
|
+
.map(([label, value]) => `<div class="info-row"><p class="label">${esc(label)}</p><p class="value-text">${esc(value)}</p></div>`)
|
|
330
|
+
.join("");
|
|
331
|
+
|
|
332
|
+
ui.docsList.innerHTML = (docsDirty.length ? docsDirty : ["Sin deriva"])
|
|
333
|
+
.map((item) => `<span class="tag ${docsDirty.length ? "warn" : "success"}">${esc(item)}</span>`)
|
|
334
|
+
.join("");
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function renderDecisions() {
|
|
338
|
+
const items = state.payload.control.decisionsPending || [];
|
|
339
|
+
ui.decisionList.innerHTML = items.length
|
|
340
|
+
? items
|
|
341
|
+
.map(
|
|
342
|
+
(item) => `
|
|
343
|
+
<article class="decision-item">
|
|
344
|
+
<p class="label">${esc(item.owner || "Pendiente")}</p>
|
|
345
|
+
<p class="value-text"><strong>${esc(item.title)}</strong></p>
|
|
346
|
+
<p class="meta-text">${esc(item.impact || "")}</p>
|
|
347
|
+
</article>
|
|
348
|
+
`
|
|
349
|
+
)
|
|
350
|
+
.join("")
|
|
351
|
+
: '<div class="empty-state">No hay decisiones pendientes.</div>';
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function renderCharts() {
|
|
355
|
+
renderPhaseChart();
|
|
356
|
+
renderStatusChart();
|
|
357
|
+
renderActivityChart();
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function renderPhaseChart() {
|
|
361
|
+
const stats = state.payload.derived.phaseStats || [];
|
|
362
|
+
ui.phaseChart.innerHTML = `
|
|
363
|
+
<div class="chart-bars">
|
|
364
|
+
${stats
|
|
365
|
+
.map((item) => {
|
|
366
|
+
const total = item.total || 1;
|
|
367
|
+
const percent = Math.round((item.completed / total) * 100);
|
|
368
|
+
return `
|
|
369
|
+
<div class="chart-bar-row">
|
|
370
|
+
<span>${esc(item.label)}</span>
|
|
371
|
+
<div class="bar-track"><div class="bar-fill" style="width:${percent}%"></div></div>
|
|
372
|
+
<strong>${item.completed}/${item.total}</strong>
|
|
373
|
+
</div>
|
|
374
|
+
`;
|
|
375
|
+
})
|
|
376
|
+
.join("")}
|
|
377
|
+
</div>
|
|
378
|
+
`;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function renderStatusChart() {
|
|
382
|
+
const totals = state.payload.derived.totals;
|
|
383
|
+
const rows = [
|
|
384
|
+
[STATUS.pending.label, totals.pending],
|
|
385
|
+
[STATUS.in_progress.label, totals.inProgress],
|
|
386
|
+
[STATUS.in_review.label, totals.inReview],
|
|
387
|
+
[STATUS.blocked.label, totals.blocked],
|
|
388
|
+
[STATUS.completed.label, totals.completed],
|
|
389
|
+
];
|
|
390
|
+
const max = Math.max(...rows.map((row) => row[1]), 1);
|
|
391
|
+
ui.statusChart.innerHTML = `
|
|
392
|
+
<div class="chart-bars">
|
|
393
|
+
${rows
|
|
394
|
+
.map(
|
|
395
|
+
([label, value]) => `
|
|
396
|
+
<div class="chart-bar-row">
|
|
397
|
+
<span>${esc(label)}</span>
|
|
398
|
+
<div class="bar-track"><div class="bar-fill" style="width:${Math.round((value / max) * 100)}%"></div></div>
|
|
399
|
+
<strong>${value}</strong>
|
|
400
|
+
</div>
|
|
401
|
+
`
|
|
402
|
+
)
|
|
403
|
+
.join("")}
|
|
404
|
+
</div>
|
|
405
|
+
`;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function renderActivityChart() {
|
|
409
|
+
const entries = history(state.payload.control.tasks);
|
|
410
|
+
const days = lastDays(10);
|
|
411
|
+
const counts = new Map(days.map((day) => [day, 0]));
|
|
412
|
+
entries.forEach((entry) => {
|
|
413
|
+
const day = entry.at.slice(0, 10);
|
|
414
|
+
if (counts.has(day)) counts.set(day, counts.get(day) + 1);
|
|
415
|
+
});
|
|
416
|
+
const max = Math.max(...counts.values(), 1);
|
|
417
|
+
ui.activityChart.innerHTML = `
|
|
418
|
+
<div class="chart-activity">
|
|
419
|
+
${Array.from(counts.entries())
|
|
420
|
+
.map(
|
|
421
|
+
([day, count]) => `
|
|
422
|
+
<div class="activity-bar-wrap">
|
|
423
|
+
<div class="activity-bar" style="height:${Math.max(12, Math.round((count / max) * 140))}px"></div>
|
|
424
|
+
<span class="activity-label">${esc(day.slice(5))}</span>
|
|
425
|
+
</div>
|
|
426
|
+
`
|
|
427
|
+
)
|
|
428
|
+
.join("")}
|
|
429
|
+
</div>
|
|
430
|
+
`;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function renderBoard() {
|
|
434
|
+
const tasks = state.payload.derived.tasks;
|
|
435
|
+
const columns = tasks.some((task) => task.status === "cancelled") ? [...BOARD, "cancelled"] : BOARD;
|
|
436
|
+
ui.board.innerHTML = columns.map((status) => renderColumn(status, tasks.filter((task) => task.status === status))).join("");
|
|
437
|
+
|
|
438
|
+
ui.board.querySelectorAll(".task-card").forEach((card) => {
|
|
439
|
+
card.addEventListener("click", () => {
|
|
440
|
+
state.selectedTaskId = card.dataset.taskId;
|
|
441
|
+
renderBoard();
|
|
442
|
+
renderEditor();
|
|
443
|
+
});
|
|
444
|
+
card.addEventListener("dragstart", (event) => {
|
|
445
|
+
event.dataTransfer.setData("text/plain", card.dataset.taskId);
|
|
446
|
+
event.dataTransfer.effectAllowed = "move";
|
|
447
|
+
});
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
ui.board.querySelectorAll(".board-column").forEach((column) => {
|
|
451
|
+
column.addEventListener("dragover", (event) => {
|
|
452
|
+
event.preventDefault();
|
|
453
|
+
column.classList.add("is-drop-target");
|
|
454
|
+
});
|
|
455
|
+
column.addEventListener("dragleave", () => column.classList.remove("is-drop-target"));
|
|
456
|
+
column.addEventListener("drop", async (event) => {
|
|
457
|
+
event.preventDefault();
|
|
458
|
+
column.classList.remove("is-drop-target");
|
|
459
|
+
const taskId = event.dataTransfer.getData("text/plain");
|
|
460
|
+
await moveTask(taskId, column.dataset.status);
|
|
461
|
+
});
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function renderColumn(status, tasks) {
|
|
466
|
+
return `
|
|
467
|
+
<section class="board-column" data-status="${esc(status)}">
|
|
468
|
+
<div class="column-head">
|
|
469
|
+
<h3 class="column-title">${esc(STATUS[status].label)}</h3>
|
|
470
|
+
<span class="column-count">${tasks.length}</span>
|
|
471
|
+
</div>
|
|
472
|
+
<div class="column-body">
|
|
473
|
+
${tasks.length ? tasks.map(renderCard).join("") : '<div class="empty-state">Sin tareas en esta columna.</div>'}
|
|
474
|
+
</div>
|
|
475
|
+
</section>
|
|
476
|
+
`;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function renderCard(task) {
|
|
480
|
+
return `
|
|
481
|
+
<button type="button" class="task-card ${task.id === state.selectedTaskId ? "is-selected" : ""}" data-task-id="${esc(task.id)}" data-status="${esc(task.status)}" draggable="true">
|
|
482
|
+
<strong class="task-title">${esc(task.title)}</strong>
|
|
483
|
+
<span class="task-id">${esc(task.id)}</span>
|
|
484
|
+
<p class="task-summary">${esc(task.summary || "Sin resumen operativo.")}</p>
|
|
485
|
+
<div class="task-meta">
|
|
486
|
+
<span class="task-chip priority-${esc(task.priority.toLowerCase())}">${esc(task.priority)}</span>
|
|
487
|
+
<span class="task-chip">${esc(task.phase)} · ${esc(PHASE[task.phase] || task.phase)}</span>
|
|
488
|
+
<span class="task-chip">${esc(task.stream || "General")}</span>
|
|
489
|
+
</div>
|
|
490
|
+
</button>
|
|
491
|
+
`;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function renderEditor() {
|
|
495
|
+
const task = selectedTask();
|
|
496
|
+
ui.taskActionStrip.style.opacity = task ? "1" : "0.45";
|
|
497
|
+
ui.taskActionStrip.style.pointerEvents = task ? "auto" : "none";
|
|
498
|
+
|
|
499
|
+
if (!task) {
|
|
500
|
+
ui.editorTitle.textContent = "Nueva tarea";
|
|
501
|
+
ui.taskForm.reset();
|
|
502
|
+
ui.fields.phase.value = PHASES[0]?.id || "";
|
|
503
|
+
ui.fields.priority.value = "P1";
|
|
504
|
+
ui.fields.status.value = "pending";
|
|
505
|
+
ui.fields.stream.value = "Operations";
|
|
506
|
+
ui.fields.required.checked = true;
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
ui.editorTitle.textContent = task.title;
|
|
511
|
+
ui.fields.title.value = task.title || "";
|
|
512
|
+
ui.fields.phase.value = task.phase || PHASES[0]?.id || "";
|
|
513
|
+
ui.fields.priority.value = task.priority || "P1";
|
|
514
|
+
ui.fields.status.value = task.status || "pending";
|
|
515
|
+
ui.fields.stream.value = task.stream || "";
|
|
516
|
+
ui.fields.required.checked = task.required !== false;
|
|
517
|
+
ui.fields.summary.value = task.summary || "";
|
|
518
|
+
ui.fields.dependsOn.value = (task.dependsOn || []).join("\n");
|
|
519
|
+
ui.fields.acceptance.value = (task.acceptance || []).join("\n");
|
|
520
|
+
ui.fields.blocker.value = task.blocker || "";
|
|
521
|
+
ui.fields.note.value = "";
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function renderExecutionMetrics() {
|
|
525
|
+
const { project, runtime, docsDirty } = state.payload;
|
|
526
|
+
const items = [
|
|
527
|
+
["Proyecto activo", project.name],
|
|
528
|
+
["Ruta", project.root],
|
|
529
|
+
["Rama", runtime.branch || "sin rama"],
|
|
530
|
+
["Docs con deriva", docsDirty.length ? docsDirty.join(", ") : "ninguna"],
|
|
531
|
+
["Sesiones de consola", String(state.sessions.length)],
|
|
532
|
+
];
|
|
533
|
+
|
|
534
|
+
ui.executionMetrics.innerHTML = items
|
|
535
|
+
.map(([label, value]) => `<div class="health-card"><p class="label">${esc(label)}</p><p class="value-text">${esc(value)}</p></div>`)
|
|
536
|
+
.join("");
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function renderActivity() {
|
|
540
|
+
const entries = history(state.payload.control.tasks).slice(0, 12);
|
|
541
|
+
ui.activityList.innerHTML = entries.length
|
|
542
|
+
? entries
|
|
543
|
+
.map(
|
|
544
|
+
(entry) => `
|
|
545
|
+
<article class="activity-item">
|
|
546
|
+
<p class="label">${esc(formatDate(entry.at))}</p>
|
|
547
|
+
<p class="value-text"><strong>${esc(entry.taskTitle)}</strong> | ${esc(entry.action)}</p>
|
|
548
|
+
<p class="meta-text">${esc(entry.note || "Sin nota")}</p>
|
|
549
|
+
</article>
|
|
550
|
+
`
|
|
551
|
+
)
|
|
552
|
+
.join("")
|
|
553
|
+
: '<div class="empty-state">Sin actividad reciente.</div>';
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
function renderFindings() {
|
|
557
|
+
const items = state.payload.derived.openFindings || [];
|
|
558
|
+
ui.findingList.innerHTML = items.length
|
|
559
|
+
? items
|
|
560
|
+
.map(
|
|
561
|
+
(item) => `
|
|
562
|
+
<article class="finding-item">
|
|
563
|
+
<p class="label">${esc((item.severity || "info").toUpperCase())}</p>
|
|
564
|
+
<p class="value-text"><strong>${esc(item.title)}</strong></p>
|
|
565
|
+
<p class="meta-text">${esc(item.detail || "")}</p>
|
|
566
|
+
</article>
|
|
567
|
+
`
|
|
568
|
+
)
|
|
569
|
+
.join("")
|
|
570
|
+
: '<div class="empty-state">No hay hallazgos abiertos.</div>';
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
function renderHealth() {
|
|
574
|
+
const { derived, docsDirty, runtime } = state.payload;
|
|
575
|
+
const completionRate = derived.totals.all ? Math.round((derived.totals.completed / derived.totals.all) * 100) : 0;
|
|
576
|
+
const blockerRate = derived.totals.all ? Math.round((derived.totals.blocked / derived.totals.all) * 100) : 0;
|
|
577
|
+
const items = [
|
|
578
|
+
["Completion rate", `${completionRate}%`],
|
|
579
|
+
["Blocker pressure", `${blockerRate}%`],
|
|
580
|
+
["Open findings", String((derived.openFindings || []).length)],
|
|
581
|
+
["Repo cleanliness", runtime.clean ? "limpio" : "con cambios"],
|
|
582
|
+
["Docs alignment", docsDirty.length ? "pendiente" : "alineado"],
|
|
583
|
+
];
|
|
584
|
+
|
|
585
|
+
ui.healthRail.innerHTML = items
|
|
586
|
+
.map(([label, value]) => `<div class="health-card"><p class="label">${esc(label)}</p><p class="metric-value">${esc(value)}</p></div>`)
|
|
587
|
+
.join("");
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
function renderQuickCommands() {
|
|
591
|
+
ui.commandPresets.innerHTML = QUICK.map((command) => `<button type="button" class="chip-button" data-command="${esc(command)}">${esc(command)}</button>`).join("");
|
|
592
|
+
ui.commandPresets.querySelectorAll("[data-command]").forEach((button) => {
|
|
593
|
+
button.addEventListener("click", () => {
|
|
594
|
+
ui.commandInput.value = button.dataset.command;
|
|
595
|
+
ui.commandInput.focus();
|
|
596
|
+
});
|
|
597
|
+
});
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
function renderSessions() {
|
|
601
|
+
ui.sessionList.innerHTML = state.sessions.length
|
|
602
|
+
? state.sessions
|
|
603
|
+
.map(
|
|
604
|
+
(session) => `
|
|
605
|
+
<button type="button" class="session-button ${session.id === state.selectedSessionId ? "is-selected" : ""}" data-session-id="${esc(session.id)}">
|
|
606
|
+
<span>${esc(session.projectName || "Proyecto")} | ${esc(session.command)}</span>
|
|
607
|
+
<span class="meta-text">${esc(session.status === "running" ? "running" : `exit ${session.exitCode ?? "-"}`)}</span>
|
|
608
|
+
</button>
|
|
609
|
+
`
|
|
610
|
+
)
|
|
611
|
+
.join("")
|
|
612
|
+
: '<div class="empty-state">Aun no hay sesiones de comandos.</div>';
|
|
613
|
+
|
|
614
|
+
ui.sessionList.querySelectorAll("[data-session-id]").forEach((button) => {
|
|
615
|
+
button.addEventListener("click", () => {
|
|
616
|
+
state.selectedSessionId = button.dataset.sessionId;
|
|
617
|
+
renderSessions();
|
|
618
|
+
renderTerminal();
|
|
619
|
+
});
|
|
620
|
+
});
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
function renderTerminal() {
|
|
624
|
+
const session = state.sessions.find((item) => item.id === state.selectedSessionId);
|
|
625
|
+
if (!session) {
|
|
626
|
+
ui.terminalStatus.textContent = "Lista";
|
|
627
|
+
ui.terminalOutput.textContent = "Selecciona o ejecuta un comando para ver la salida.";
|
|
628
|
+
return;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
ui.terminalStatus.textContent = session.status === "running" ? "Ejecutando" : session.exitCode === 0 ? "Completado" : `Exit ${session.exitCode ?? "?"}`;
|
|
632
|
+
ui.terminalOutput.textContent = session.output || "Sin salida todavia.";
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
async function registerProject(event) {
|
|
636
|
+
event.preventDefault();
|
|
637
|
+
try {
|
|
638
|
+
const result = await api("/api/projects/register", {
|
|
639
|
+
method: "POST",
|
|
640
|
+
projectAware: false,
|
|
641
|
+
body: JSON.stringify({ root: ui.registerRootInput.value.trim() }),
|
|
642
|
+
});
|
|
643
|
+
ui.registerProjectForm.reset();
|
|
644
|
+
state.projects = result.projects || state.projects;
|
|
645
|
+
state.currentProjectId = result.project.id;
|
|
646
|
+
localStorage.setItem("ops-dashboard-project", state.currentProjectId);
|
|
647
|
+
renderProjectSelector();
|
|
648
|
+
flash("Proyecto registrado.");
|
|
649
|
+
await refreshState({ preserveSelection: false });
|
|
650
|
+
} catch (error) {
|
|
651
|
+
flash(error.message, true);
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
async function installProject(event) {
|
|
656
|
+
event.preventDefault();
|
|
657
|
+
try {
|
|
658
|
+
const result = await api("/api/projects/install", {
|
|
659
|
+
method: "POST",
|
|
660
|
+
projectAware: false,
|
|
661
|
+
body: JSON.stringify({ root: ui.installRootInput.value.trim() }),
|
|
662
|
+
});
|
|
663
|
+
ui.installProjectForm.reset();
|
|
664
|
+
state.projects = result.projects || state.projects;
|
|
665
|
+
state.currentProjectId = result.project.id;
|
|
666
|
+
localStorage.setItem("ops-dashboard-project", state.currentProjectId);
|
|
667
|
+
renderProjectSelector();
|
|
668
|
+
flash("Sistema ops instalado y registrado.");
|
|
669
|
+
await refreshState({ preserveSelection: false });
|
|
670
|
+
} catch (error) {
|
|
671
|
+
flash(error.message, true);
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
async function saveTask(event) {
|
|
676
|
+
event.preventDefault();
|
|
677
|
+
const payload = {
|
|
678
|
+
projectId: state.currentProjectId,
|
|
679
|
+
title: ui.fields.title.value.trim(),
|
|
680
|
+
phase: ui.fields.phase.value,
|
|
681
|
+
priority: ui.fields.priority.value,
|
|
682
|
+
status: ui.fields.status.value,
|
|
683
|
+
stream: ui.fields.stream.value.trim(),
|
|
684
|
+
required: ui.fields.required.checked,
|
|
685
|
+
summary: ui.fields.summary.value.trim(),
|
|
686
|
+
dependsOn: split(ui.fields.dependsOn.value),
|
|
687
|
+
acceptance: split(ui.fields.acceptance.value),
|
|
688
|
+
blocker: ui.fields.blocker.value.trim(),
|
|
689
|
+
note: ui.fields.note.value.trim(),
|
|
690
|
+
};
|
|
691
|
+
|
|
692
|
+
try {
|
|
693
|
+
if (!payload.title) throw new Error("El titulo es obligatorio.");
|
|
694
|
+
if (state.selectedTaskId) {
|
|
695
|
+
await api(`/api/tasks/${encodeURIComponent(state.selectedTaskId)}`, { method: "PUT", body: JSON.stringify(payload) });
|
|
696
|
+
flash("Tarea actualizada.");
|
|
697
|
+
} else {
|
|
698
|
+
const result = await api("/api/tasks", { method: "POST", body: JSON.stringify(payload) });
|
|
699
|
+
state.selectedTaskId = result.task.id;
|
|
700
|
+
flash("Tarea creada.");
|
|
701
|
+
}
|
|
702
|
+
await refreshState({ preserveSelection: true });
|
|
703
|
+
} catch (error) {
|
|
704
|
+
flash(error.message, true);
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
async function handleTaskAction(event) {
|
|
709
|
+
const button = event.target.closest("[data-task-action]");
|
|
710
|
+
if (!button) return;
|
|
711
|
+
if (!state.selectedTaskId) {
|
|
712
|
+
flash("Selecciona una tarea primero.", true);
|
|
713
|
+
return;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
try {
|
|
717
|
+
const action = button.dataset.taskAction;
|
|
718
|
+
const note = ui.fields.note.value.trim() || `Cambio a ${STATUS[toStatus(action)].label} desde dashboard.`;
|
|
719
|
+
await api(`/api/tasks/${encodeURIComponent(state.selectedTaskId)}/action`, {
|
|
720
|
+
method: "POST",
|
|
721
|
+
body: JSON.stringify({ projectId: state.currentProjectId, action, note }),
|
|
722
|
+
});
|
|
723
|
+
flash("Estado actualizado.");
|
|
724
|
+
await refreshState({ preserveSelection: true });
|
|
725
|
+
} catch (error) {
|
|
726
|
+
flash(error.message, true);
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
async function moveTask(taskId, status) {
|
|
731
|
+
const task = findTask(taskId);
|
|
732
|
+
if (!task || task.status === status) return;
|
|
733
|
+
|
|
734
|
+
try {
|
|
735
|
+
await api(`/api/tasks/${encodeURIComponent(taskId)}/action`, {
|
|
736
|
+
method: "POST",
|
|
737
|
+
body: JSON.stringify({
|
|
738
|
+
projectId: state.currentProjectId,
|
|
739
|
+
action: STATUS[status].action,
|
|
740
|
+
note: `Movida a ${STATUS[status].label} desde el tablero.`,
|
|
741
|
+
}),
|
|
742
|
+
});
|
|
743
|
+
flash(`Tarea movida a ${STATUS[status].label}.`);
|
|
744
|
+
await refreshState({ preserveSelection: true });
|
|
745
|
+
} catch (error) {
|
|
746
|
+
flash(error.message, true);
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
function resetForm() {
|
|
751
|
+
state.selectedTaskId = null;
|
|
752
|
+
renderEditor();
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
function duplicateTask() {
|
|
756
|
+
const task = selectedTask();
|
|
757
|
+
if (!task) {
|
|
758
|
+
flash("Selecciona una tarea para duplicarla.", true);
|
|
759
|
+
return;
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
state.selectedTaskId = null;
|
|
763
|
+
renderEditor();
|
|
764
|
+
ui.fields.title.value = `${task.title} (copia)`;
|
|
765
|
+
ui.fields.phase.value = task.phase || PHASES[0]?.id || "";
|
|
766
|
+
ui.fields.priority.value = task.priority || "P1";
|
|
767
|
+
ui.fields.status.value = "pending";
|
|
768
|
+
ui.fields.stream.value = task.stream || "";
|
|
769
|
+
ui.fields.required.checked = task.required !== false;
|
|
770
|
+
ui.fields.summary.value = task.summary || "";
|
|
771
|
+
ui.fields.dependsOn.value = (task.dependsOn || []).join("\n");
|
|
772
|
+
ui.fields.acceptance.value = (task.acceptance || []).join("\n");
|
|
773
|
+
ui.fields.note.value = "Duplicada desde el dashboard.";
|
|
774
|
+
ui.editorTitle.textContent = "Nueva tarea desde base";
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
async function syncDocs() {
|
|
778
|
+
try {
|
|
779
|
+
await api("/api/sync", { method: "POST", body: JSON.stringify({ projectId: state.currentProjectId }) });
|
|
780
|
+
flash("Documentacion sincronizada.");
|
|
781
|
+
await refreshState({ preserveSelection: true });
|
|
782
|
+
} catch (error) {
|
|
783
|
+
flash(error.message, true);
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
async function runCommand(event) {
|
|
788
|
+
event.preventDefault();
|
|
789
|
+
const command = ui.commandInput.value.trim();
|
|
790
|
+
if (!command) {
|
|
791
|
+
flash("Introduce un comando.", true);
|
|
792
|
+
return;
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
try {
|
|
796
|
+
const result = await api("/api/commands", {
|
|
797
|
+
method: "POST",
|
|
798
|
+
body: JSON.stringify({ projectId: state.currentProjectId, command }),
|
|
799
|
+
});
|
|
800
|
+
const session = {
|
|
801
|
+
id: result.session.id,
|
|
802
|
+
command,
|
|
803
|
+
projectId: result.session.projectId,
|
|
804
|
+
projectName: result.session.projectName,
|
|
805
|
+
status: "running",
|
|
806
|
+
exitCode: null,
|
|
807
|
+
output: "",
|
|
808
|
+
};
|
|
809
|
+
state.sessions.unshift(session);
|
|
810
|
+
state.selectedSessionId = session.id;
|
|
811
|
+
ui.commandForm.reset();
|
|
812
|
+
setActiveTab("execution");
|
|
813
|
+
renderSessions();
|
|
814
|
+
renderTerminal();
|
|
815
|
+
streamSession(session.id);
|
|
816
|
+
} catch (error) {
|
|
817
|
+
flash(error.message, true);
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
function streamSession(sessionId) {
|
|
822
|
+
if (state.stream) state.stream.close();
|
|
823
|
+
const source = new EventSource(`/api/commands/${encodeURIComponent(sessionId)}/stream`);
|
|
824
|
+
state.stream = source;
|
|
825
|
+
|
|
826
|
+
source.onmessage = (event) => {
|
|
827
|
+
const data = JSON.parse(event.data);
|
|
828
|
+
const session = upsertSession(sessionId);
|
|
829
|
+
|
|
830
|
+
if (data.type === "snapshot") {
|
|
831
|
+
session.command = data.command || session.command;
|
|
832
|
+
session.status = data.status;
|
|
833
|
+
session.exitCode = data.exitCode ?? session.exitCode;
|
|
834
|
+
session.output = data.output || "";
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
if (data.type === "stdout" || data.type === "stderr") {
|
|
838
|
+
session.output += data.chunk || "";
|
|
839
|
+
session.status = data.status || session.status;
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
if (data.type === "done") {
|
|
843
|
+
session.status = data.status || "completed";
|
|
844
|
+
session.exitCode = data.exitCode;
|
|
845
|
+
session.output = data.output || session.output;
|
|
846
|
+
source.close();
|
|
847
|
+
state.stream = null;
|
|
848
|
+
refreshState({ preserveSelection: true });
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
renderSessions();
|
|
852
|
+
renderTerminal();
|
|
853
|
+
};
|
|
854
|
+
|
|
855
|
+
source.onerror = () => {
|
|
856
|
+
source.close();
|
|
857
|
+
if (state.stream === source) state.stream = null;
|
|
858
|
+
};
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
function upsertSession(id) {
|
|
862
|
+
let session = state.sessions.find((item) => item.id === id);
|
|
863
|
+
if (!session) {
|
|
864
|
+
session = { id, command: id, status: "running", exitCode: null, output: "" };
|
|
865
|
+
state.sessions.unshift(session);
|
|
866
|
+
}
|
|
867
|
+
state.selectedSessionId = id;
|
|
868
|
+
return session;
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
function setActiveTab(tabId) {
|
|
872
|
+
state.activeTab = tabId;
|
|
873
|
+
ui.tabButtons.forEach((button) => button.classList.toggle("is-active", button.dataset.tab === tabId));
|
|
874
|
+
ui.tabPanels.forEach((panel) => {
|
|
875
|
+
const active = panel.id === `tab-${tabId}`;
|
|
876
|
+
panel.hidden = !active;
|
|
877
|
+
panel.classList.toggle("is-active", active);
|
|
878
|
+
});
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
function selectedTask() {
|
|
882
|
+
return findTask(state.selectedTaskId);
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
function findTask(id) {
|
|
886
|
+
return state.payload?.derived.tasks.find((task) => task.id === id) || null;
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
function history(tasks) {
|
|
890
|
+
return tasks
|
|
891
|
+
.flatMap((task) => (task.history || []).map((entry) => ({ ...entry, taskTitle: task.title })))
|
|
892
|
+
.sort((left, right) => (left.at < right.at ? 1 : -1));
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
function split(value) {
|
|
896
|
+
return String(value || "")
|
|
897
|
+
.split(/\r?\n|,/)
|
|
898
|
+
.map((item) => item.trim())
|
|
899
|
+
.filter(Boolean);
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
function toStatus(action) {
|
|
903
|
+
const match = Object.entries(STATUS).find(([, meta]) => meta.action === action);
|
|
904
|
+
return match ? match[0] : action;
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
function taskSnippet(task) {
|
|
908
|
+
return `
|
|
909
|
+
<article class="task-snippet">
|
|
910
|
+
<p class="label">${esc(task.phase)} · ${esc(PHASE[task.phase] || task.phase)} · ${esc(task.priority)}</p>
|
|
911
|
+
<p class="value-text"><strong>${esc(task.title)}</strong></p>
|
|
912
|
+
<p class="meta-text">${esc(task.summary || "Sin resumen operativo.")}</p>
|
|
913
|
+
</article>
|
|
914
|
+
`;
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
function lastDays(count) {
|
|
918
|
+
return Array.from({ length: count }, (_, index) => {
|
|
919
|
+
const date = new Date();
|
|
920
|
+
date.setDate(date.getDate() - (count - index - 1));
|
|
921
|
+
return date.toISOString().slice(0, 10);
|
|
922
|
+
});
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
function formatDate(value) {
|
|
926
|
+
try {
|
|
927
|
+
const locale = LOCALE === "en" ? "en-US" : `${LOCALE}-${LOCALE.toUpperCase()}`;
|
|
928
|
+
return new Intl.DateTimeFormat(locale, { dateStyle: "medium", timeStyle: "short" }).format(new Date(value));
|
|
929
|
+
} catch (_error) {
|
|
930
|
+
return value;
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
function flash(message, isError = false) {
|
|
935
|
+
ui.flash.textContent = message;
|
|
936
|
+
ui.flash.className = `flash is-visible${isError ? " is-error" : ""}`;
|
|
937
|
+
clearTimeout(state.flashTimer);
|
|
938
|
+
state.flashTimer = window.setTimeout(() => {
|
|
939
|
+
ui.flash.className = "flash";
|
|
940
|
+
}, 3200);
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
function esc(value) {
|
|
944
|
+
return String(value ?? "")
|
|
945
|
+
.replace(/&/g, "&")
|
|
946
|
+
.replace(/</g, "<")
|
|
947
|
+
.replace(/>/g, ">")
|
|
948
|
+
.replace(/"/g, """)
|
|
949
|
+
.replace(/'/g, "'");
|
|
950
|
+
}
|