stackhelx 1.0.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.
stackhelx/web/app.js ADDED
@@ -0,0 +1,2425 @@
1
+ /* Interfaz de StackHelx. Sin framework, sin build: la pagina la sirve el
2
+ * propio CLI y la CSP es default-src 'self'. Nada de estilos inline, todo por
3
+ * clases y atributos. */
4
+
5
+ const POLL_MS = 2500;
6
+
7
+ const ui = {
8
+ projects: document.getElementById("projects"),
9
+ empty: document.getElementById("empty"),
10
+ connection: document.getElementById("connection"),
11
+ flash: document.getElementById("flash"),
12
+ enroll: document.getElementById("enroll"),
13
+ path: document.getElementById("path"),
14
+ browse: document.getElementById("browse"),
15
+ find: document.getElementById("find"),
16
+ search: document.getElementById("search"),
17
+ count: document.getElementById("count"),
18
+ pager: document.getElementById("pager"),
19
+ pagerAt: document.getElementById("pager-at"),
20
+ picker: document.getElementById("picker"),
21
+ pickerPath: document.getElementById("picker-path"),
22
+ pickerFrequent: document.getElementById("picker-frequent"),
23
+ pickerFrequentChips: document.getElementById("picker-frequent-chips"),
24
+ pickerList: document.getElementById("picker-list"),
25
+ pickerNote: document.getElementById("picker-note"),
26
+ tplProject: document.getElementById("tpl-project"),
27
+ tplService: document.getElementById("tpl-service"),
28
+ tunnels: document.getElementById("tunnels"),
29
+ tunnelsList: document.getElementById("tunnels-list"),
30
+ tunnelsHeading: document.getElementById("tunnels-heading"),
31
+ orphans: document.getElementById("orphans"),
32
+ orphansList: document.getElementById("orphans-list"),
33
+ orphansHeading: document.getElementById("orphans-heading"),
34
+ orphansKillAll: document.getElementById("orphans-kill-all"),
35
+ health: document.getElementById("health"),
36
+ notify: document.getElementById("notify"),
37
+ pathSuggestions: document.getElementById("path-suggestions"),
38
+ dockerState: document.getElementById("docker-state"),
39
+ btnDocker: document.getElementById("btn-docker"),
40
+ btnDockerClean: document.getElementById("btn-docker-clean"),
41
+ cleanModal: document.getElementById("clean-modal"),
42
+ cleanUsage: document.getElementById("clean-usage"),
43
+ cleanTargets: document.getElementById("clean-targets"),
44
+ cleanRun: document.getElementById("clean-run"),
45
+ cleanWarn: document.getElementById("clean-warn"),
46
+ btnPortsModal: document.getElementById("btn-ports-modal"),
47
+ portsModal: document.getElementById("ports-modal"),
48
+ portsModalList: document.getElementById("ports-modal-list"),
49
+ btnMcpModal: document.getElementById("btn-mcp-modal"),
50
+ mcpModal: document.getElementById("mcp-modal"),
51
+ mcpTotalCalls: document.getElementById("mcp-total-calls"),
52
+ mcpQuotaUsed: document.getElementById("mcp-quota-used"),
53
+ mcpBreakdown: document.getElementById("mcp-breakdown"),
54
+ mcpTbody: document.getElementById("mcp-tbody"),
55
+ };
56
+
57
+ const TITLE = document.title;
58
+
59
+ const cards = new Map(); // id -> {root, logSeq, logsOpen}
60
+ let flashTimer = null;
61
+ let latestOrphansList = [];
62
+ // Lo pone `render`, lo usa `refreshOrphans`: los dos sondeos son distintos y el
63
+ // de intrusos no recibe el total de proyectos registrados.
64
+ let hayProyectos = false;
65
+
66
+ let query = "";
67
+ let statusFilter = "";
68
+ let page = 1;
69
+ let cachedEditors = null;
70
+
71
+ async function loadAvailableEditors() {
72
+ if (cachedEditors !== null) return cachedEditors;
73
+ try {
74
+ const data = await api("/api/editors");
75
+ cachedEditors = data && data.editors ? data.editors : [];
76
+ } catch {
77
+ cachedEditors = [];
78
+ }
79
+ return cachedEditors;
80
+ }
81
+
82
+
83
+ /* token ------------------------------------------------------------------- */
84
+
85
+ function getCookieToken() {
86
+ const match = document.cookie.match(/(?:^|; )stackhelx_token=([^;]*)/);
87
+ return match ? decodeURIComponent(match[1]) : "";
88
+ }
89
+
90
+ function readToken() {
91
+ const url = new URL(window.location.href);
92
+ const fromUrl = url.searchParams.get("token");
93
+ if (fromUrl) {
94
+ localStorage.setItem("stackhelx.token", fromUrl);
95
+ sessionStorage.setItem("stackhelx.token", fromUrl);
96
+ url.searchParams.delete("token");
97
+ // Sacarlo de la barra: no tiene por que quedar en el historial.
98
+ window.history.replaceState({}, "", url.pathname + url.search + url.hash);
99
+ return fromUrl;
100
+ }
101
+ return (
102
+ localStorage.getItem("stackhelx.token") ||
103
+ sessionStorage.getItem("stackhelx.token") ||
104
+ localStorage.getItem("portmaster.token") ||
105
+ sessionStorage.getItem("portmaster.token") ||
106
+ getCookieToken() ||
107
+ ""
108
+ );
109
+ }
110
+
111
+ let token = readToken();
112
+
113
+ async function api(path, options = {}) {
114
+ const response = await fetch(path, {
115
+ ...options,
116
+ headers: {
117
+ Authorization: `Bearer ${token}`,
118
+ ...(options.body ? { "Content-Type": "application/json" } : {}),
119
+ },
120
+ });
121
+
122
+ if (response.status === 401) {
123
+ localStorage.removeItem("stackhelx.token");
124
+ sessionStorage.removeItem("stackhelx.token");
125
+ token = "";
126
+ promptAuthModal();
127
+ }
128
+
129
+ if (!response.ok) {
130
+ let detail = `error ${response.status}`;
131
+ try {
132
+ const body = await response.json();
133
+ if (body && body.detail) detail = body.detail;
134
+ } catch {
135
+ /* respuesta sin cuerpo JSON */
136
+ }
137
+ throw new Error(detail);
138
+ }
139
+ return response.json();
140
+ }
141
+
142
+ function promptAuthModal() {
143
+ const modal = document.getElementById("auth-modal");
144
+ const input = document.getElementById("auth-token-input");
145
+ const saveBtn = document.getElementById("auth-token-save");
146
+ if (!modal || modal.open) return;
147
+ modal.showModal();
148
+ saveBtn.onclick = () => {
149
+ const val = input.value.trim();
150
+ if (!val) return;
151
+ localStorage.setItem("stackhelx.token", val);
152
+ sessionStorage.setItem("stackhelx.token", val);
153
+ token = val;
154
+ modal.close();
155
+ refresh();
156
+ };
157
+ }
158
+
159
+ /* avisos ------------------------------------------------------------------ */
160
+
161
+ function flash(message, tone) {
162
+ ui.flash.textContent = message;
163
+ ui.flash.dataset.tone = tone || "";
164
+ ui.flash.hidden = false;
165
+ clearTimeout(flashTimer);
166
+ flashTimer = setTimeout(() => {
167
+ ui.flash.hidden = true;
168
+ }, 4500);
169
+ }
170
+
171
+ async function act(button, work) {
172
+ if (button.dataset.busy === "true") return;
173
+ button.setAttribute("aria-disabled", "true");
174
+ button.dataset.busy = "true";
175
+ try {
176
+ await work();
177
+ await refresh();
178
+ } catch (error) {
179
+ flash(error.message, "bad");
180
+ } finally {
181
+ button.removeAttribute("aria-disabled");
182
+ delete button.dataset.busy;
183
+ }
184
+ }
185
+
186
+ /* etiquetas --------------------------------------------------------------- */
187
+
188
+ const PROJECT_LABELS = {
189
+ stopped: ["detenido", ""],
190
+ starting: ["arrancando", "starting"],
191
+ running: ["corriendo", "ready"],
192
+ stopping: ["apagando", "starting"],
193
+ error: ["con error", "bad"],
194
+ invalid: ["config invalida", "bad"],
195
+ };
196
+
197
+ const SERVICE_LABELS = {
198
+ stopped: ["detenido", ""],
199
+ starting: ["arrancando", "starting"],
200
+ ready: ["listo", "ready"],
201
+ };
202
+
203
+ /* render ------------------------------------------------------------------ */
204
+
205
+ const SVG = "http://www.w3.org/2000/svg";
206
+
207
+ /* Dos iconos, uno por cada cosa que el servidor sabe con certeza: contenedor o
208
+ * proceso local. Trazos a 16px, sin dependencias ni webfonts. */
209
+ const ICONS = {
210
+ container: "M2.5 5.5h11v3.5h-11zM2.5 10h11v3.5h-11zM4.5 3.5h7",
211
+ local: "M2.5 3.5h11v9h-11zM5 7l1.75 1.75L5 10.5M8.75 10.5h3",
212
+ };
213
+
214
+ function kindIcon(kind) {
215
+ const svg = document.createElementNS(SVG, "svg");
216
+ svg.setAttribute("viewBox", "0 0 16 16");
217
+ svg.setAttribute("class", "service__icon");
218
+ svg.setAttribute("aria-hidden", "true");
219
+ const path = document.createElementNS(SVG, "path");
220
+ path.setAttribute("d", ICONS[kind] || ICONS.local);
221
+ svg.append(path);
222
+ return svg;
223
+ }
224
+
225
+ /* Adonde lleva "Abrir". El `url:` del stack.yaml gana cuando existe: la raiz
226
+ del puerto no siempre es la entrada, y hay apps que piden un token en la
227
+ query o viven en un path. El servidor solo lo manda para servicios ya
228
+ abribles, asi que aca no hay que chequear estado otra vez. */
229
+ function abrirUrl(service) {
230
+ return service.url || `http://localhost:${service.port}`;
231
+ }
232
+
233
+ function escapeXml(str) {
234
+ return String(str)
235
+ .replace(/&/g, "&")
236
+ .replace(/</g, "&lt;")
237
+ .replace(/>/g, "&gt;")
238
+ .replace(/"/g, "&quot;")
239
+ .replace(/'/g, "&apos;");
240
+ }
241
+
242
+ function createAltPortButton(port) {
243
+ const copyAlt = document.createElement("button");
244
+ copyAlt.type = "button";
245
+ copyAlt.className = "btn btn--quiet btn--alt-port";
246
+ copyAlt.textContent = `:${port}`;
247
+ copyAlt.title = `Copiar puerto alternativo libre :${port}`;
248
+ copyAlt.addEventListener("click", () => {
249
+ if (navigator.clipboard) {
250
+ navigator.clipboard.writeText(String(port));
251
+ flash(`Puerto :${port} copiado al portapapeles`, "good");
252
+ }
253
+ });
254
+ return copyAlt;
255
+ }
256
+
257
+ function renderService(service, projectId) {
258
+ const node = ui.tplService.content.firstElementChild.cloneNode(true);
259
+ node.querySelector(".service__name").prepend(kindIcon(service.kind));
260
+ node.querySelector(".service__name").title =
261
+ service.kind === "container" ? "contenedor" : "proceso local";
262
+
263
+ const portCell = node.querySelector(".service__port");
264
+ portCell.textContent = service.port ? String(service.port) : "—";
265
+
266
+ // Otro proyecto registrado declara este mismo puerto. No es un error todavia,
267
+ // por eso es una marca al lado del numero y no un estado: conviven mientras
268
+ // no corran a la vez.
269
+ const shared = service.shared_with || [];
270
+ if (shared.length) {
271
+ const mark = document.createElement("span");
272
+ mark.className = "service__shared";
273
+ mark.textContent = "△";
274
+ const aviso = `El puerto ${service.port} tambien lo declara ${shared.join(", ")}`;
275
+ mark.title = aviso;
276
+ mark.setAttribute("aria-label", aviso);
277
+ mark.setAttribute("role", "img");
278
+ portCell.append(" ", mark);
279
+ }
280
+
281
+ // El puerto ya estaba ocupado cuando arrancamos, asi que el verde puede ser
282
+ // de otro proceso. Marca y no estado: con un compose ya arriba es lo normal.
283
+ if (service.port_taken) {
284
+ const mark = document.createElement("span");
285
+ mark.className = "service__taken";
286
+ mark.textContent = "?";
287
+ const aviso =
288
+ `El puerto ${service.port} ya estaba ocupado antes de arrancar: ` +
289
+ "el listo puede ser de otro proceso";
290
+ mark.title = aviso;
291
+ mark.setAttribute("aria-label", aviso);
292
+ mark.setAttribute("role", "img");
293
+ portCell.append(" ", mark);
294
+ }
295
+
296
+ // El boton de abrir sale solo cuando el puerto contesto HTTP. Un postgres
297
+ // listo tiene puerto y abrirlo en el navegador no lleva a ningun lado.
298
+ if (service.openable && service.port) {
299
+ const destino = abrirUrl(service);
300
+ const link = document.createElement("a");
301
+ link.href = destino;
302
+ link.target = "_blank";
303
+ link.rel = "noopener noreferrer";
304
+ link.className = "btn btn--open";
305
+ link.title = `Abrir ${destino}`;
306
+ link.textContent = "Abrir ↗";
307
+ node.querySelector(".service__act").append(link);
308
+
309
+ // Abierto o cerrado, el mismo boton: dos controles para un estado que solo
310
+ // puede estar de una de las dos formas se pisan y confunden.
311
+ const abierto = tunnelPorts.has(service.port);
312
+ const shareBtn = document.createElement("button");
313
+ shareBtn.type = "button";
314
+ shareBtn.className = "btn btn--quiet";
315
+ shareBtn.textContent = abierto ? "Cerrar túnel" : "Túnel";
316
+ shareBtn.title = abierto
317
+ ? `El puerto ${service.port} está expuesto a internet. Cerrar el túnel.`
318
+ : "Compartir este puerto con un túnel público seguro";
319
+ shareBtn.addEventListener("click", () => {
320
+ act(shareBtn, async () => {
321
+ if (abierto) {
322
+ await api(`/api/share/${service.port}`, { method: "DELETE" });
323
+ flash(`Túnel del puerto ${service.port} cerrado`, "good");
324
+ return;
325
+ }
326
+ const res = await api(`/api/share?port=${service.port}`, { method: "POST" });
327
+ if (res.ok && res.url) {
328
+ // El aviso de "copiado" iba antes de copiar, y sin esperar: si el
329
+ // navegador negaba el permiso, decia que estaba en el portapapeles y
330
+ // no estaba. La URL va en el mensaje igual, que es lo unico que no
331
+ // puede fallar.
332
+ let copiado = false;
333
+ if (navigator.clipboard) {
334
+ try {
335
+ await navigator.clipboard.writeText(res.url);
336
+ copiado = true;
337
+ } catch {
338
+ copiado = false;
339
+ }
340
+ }
341
+ // El `open` va despues de un `await`, asi que el navegador ya no lo
342
+ // cuenta como gesto del usuario y el bloqueador de popups se lo come
343
+ // sin avisar. Mismo criterio que el portapapeles de arriba: no decir
344
+ // que paso algo que no paso. La URL viaja en el mensaje igual, que es
345
+ // lo unico que no puede fallar.
346
+ const pestaña = window.open(res.url, "_blank");
347
+ const detalle = [
348
+ copiado ? "copiado al portapapeles" : null,
349
+ pestaña ? null : "el navegador bloqueó la pestaña nueva",
350
+ ].filter(Boolean);
351
+ flash(
352
+ `Túnel activo: ${res.url}${detalle.length ? ` (${detalle.join("; ")})` : ""}`,
353
+ "good",
354
+ );
355
+ } else {
356
+ flash(res.detail || "Error al iniciar túnel", "bad");
357
+ }
358
+ });
359
+ });
360
+ node.querySelector(".service__act").append(shareBtn);
361
+ }
362
+
363
+ node.querySelector(".service__label").textContent = service.name;
364
+
365
+ const stateCell = node.querySelector(".service__state");
366
+ const text = stateCell.querySelector("span:last-child");
367
+
368
+ if (service.occupant && service.occupant.proxy) {
369
+ // Detras del proxy hay un contenedor, no un proceso que cerrar: el pid es
370
+ // el del motor. Quien lo publica sale en la lista de proyectos que comparten
371
+ // el puerto, que ya esta al lado.
372
+ const extra = service.suggested_port ? ` · Libre: :${service.suggested_port}` : "";
373
+ text.textContent = `contenedor publicado por ${service.occupant.proxy}${extra}`;
374
+ stateCell.dataset.tone = "warn";
375
+ if (service.suggested_port) {
376
+ node.querySelector(".service__act").append(createAltPortButton(service.suggested_port));
377
+ }
378
+ } else if (service.occupant) {
379
+ const who = service.occupant;
380
+ const extra = service.suggested_port ? ` · Libre: :${service.suggested_port}` : "";
381
+ text.textContent = `ocupado por ${who.name}${who.pid ? ` (${who.pid})` : ""}${extra}`;
382
+ stateCell.dataset.tone = "bad";
383
+
384
+ const kill = document.createElement("button");
385
+ kill.type = "button";
386
+ kill.className = "btn btn--kill";
387
+ kill.textContent = "Liberar";
388
+ kill.addEventListener("click", () =>
389
+ act(kill, () => api(`/api/ports/${service.port}/kill`, { method: "POST" })),
390
+ );
391
+ node.querySelector(".service__act").append(kill);
392
+
393
+ if (service.suggested_port) {
394
+ node.querySelector(".service__act").append(createAltPortButton(service.suggested_port));
395
+ }
396
+ } else {
397
+ const [label, tone] = SERVICE_LABELS[service.state] || SERVICE_LABELS.stopped;
398
+ text.textContent = label;
399
+ stateCell.dataset.tone = tone;
400
+ }
401
+
402
+ // Solo hay algo que reiniciar si el stack lo arranco esta interfaz. El estado
403
+ // no alcanza para saberlo: un contenedor levantado por afuera tambien se ve
404
+ // "listo", y el boton contestaba 404. `managed` es lo que lo dice.
405
+ if (service.managed && (service.state === "ready" || service.state === "starting")) {
406
+ const again = document.createElement("button");
407
+ again.type = "button";
408
+ again.className = "btn btn--quiet";
409
+ again.textContent = "Reiniciar";
410
+ again.title = `Reiniciar ${service.name} sin tocar el resto del stack`;
411
+ again.addEventListener("click", () =>
412
+ act(again, () =>
413
+ api(`/api/projects/${projectId}/services/${encodeURIComponent(service.name)}/restart`, {
414
+ method: "POST",
415
+ }),
416
+ ),
417
+ );
418
+ node.querySelector(".service__act").append(again);
419
+ }
420
+
421
+ node.dataset.project = projectId;
422
+ return node;
423
+ }
424
+
425
+ function buildCard(project) {
426
+ const root = ui.tplProject.content.firstElementChild.cloneNode(true);
427
+ const entry = { root, logSeq: 0, logsOpen: false, expanded: false, userToggled: false, lastState: project.state };
428
+ const logs = root.querySelector(".logs");
429
+
430
+ const toggleBtn = root.querySelector(".project__toggle");
431
+ const detailsEl = root.querySelector(".project__details");
432
+ if (detailsEl) detailsEl.id = `details-${project.id}`;
433
+ if (toggleBtn) {
434
+ if (detailsEl) toggleBtn.setAttribute("aria-controls", `details-${project.id}`);
435
+ toggleBtn.addEventListener("click", () => {
436
+ entry.userToggled = true;
437
+ entry.expanded = !entry.expanded;
438
+ root.setAttribute("data-expanded", String(entry.expanded));
439
+ toggleBtn.setAttribute("aria-expanded", String(entry.expanded));
440
+ });
441
+ }
442
+
443
+ root.querySelector('[data-act="up"]').addEventListener("click", (event) => {
444
+ const profile = root.querySelector(".profile__select").value || null;
445
+ act(event.currentTarget, () =>
446
+ api(`/api/projects/${project.id}/up`, {
447
+ method: "POST",
448
+ body: JSON.stringify({ profile }),
449
+ }),
450
+ );
451
+ });
452
+
453
+ const profileSelect = root.querySelector(".profile__select");
454
+ if (profileSelect) {
455
+ profileSelect.addEventListener("change", () => {
456
+ const live = entry.lastState === "starting" || entry.lastState === "running";
457
+ const newProfile = profileSelect.value || null;
458
+ if (live) {
459
+ flash(`Conmutando ${project.name} al perfil "${newProfile || 'por defecto'}"...`, "neutral");
460
+ }
461
+ api(`/api/projects/${project.id}/switch-profile`, {
462
+ method: "POST",
463
+ body: JSON.stringify({ profile: newProfile }),
464
+ })
465
+ .then(() => {
466
+ if (live) pull();
467
+ })
468
+ .catch((err) => {
469
+ if (live) flash(`Fallo al conmutar perfil: ${err.message}`, "bad");
470
+ });
471
+ });
472
+ }
473
+
474
+ root.querySelector('[data-act="down"]').addEventListener("click", (event) => {
475
+ act(event.currentTarget, () =>
476
+ api(`/api/projects/${project.id}/down`, { method: "POST" }),
477
+ );
478
+ });
479
+
480
+ root.querySelector('[data-act="drop"]').addEventListener("click", (event) => {
481
+ act(event.currentTarget, () =>
482
+ api(`/api/projects/${project.id}`, { method: "DELETE" }),
483
+ );
484
+ });
485
+
486
+ // Congelar escribe en el disco del usuario, asi que pide confirmacion. Dos
487
+ // pasos sobre el mismo boton en vez de un dialogo: la interfaz no tiene
488
+ // primitiva de confirmacion y `window.confirm` rompe el registro visual.
489
+ const freezeButton = root.querySelector('[data-act="freeze"]');
490
+ freezeButton.addEventListener("click", (event) => {
491
+ const button = event.currentTarget;
492
+ if (button.dataset.armed !== "true") {
493
+ button.dataset.armed = "true";
494
+ button.textContent = `Escribir en ${project.path}\\stack.yaml?`;
495
+ setTimeout(() => disarmFreeze(button), 6000);
496
+ return;
497
+ }
498
+ disarmFreeze(button);
499
+ act(button, async () => {
500
+ const hecho = await api(`/api/projects/${project.id}/freeze`, { method: "POST" });
501
+ flash(`Escrito ${hecho.path}. Revisalo antes de confiar en el.`, "good");
502
+ });
503
+ });
504
+
505
+ const logsBox = root.querySelector(".logs__box");
506
+ if (logsBox) logsBox.id = `logs-${project.id}`;
507
+ const logsFilter = root.querySelector(".logs__filter");
508
+ if (logsFilter) {
509
+ logsFilter.addEventListener("input", () => {
510
+ renderLogsText(entry);
511
+ });
512
+ }
513
+
514
+ const copyLogsBtn = root.querySelector('[data-act="copy-logs"]');
515
+ if (copyLogsBtn) {
516
+ copyLogsBtn.setAttribute("aria-label", `Copiar logs de ${project.name}`);
517
+ copyLogsBtn.addEventListener("click", async () => {
518
+ if (!entry.rawLogs) {
519
+ flash("No hay logs disponibles para copiar", "neutral");
520
+ return;
521
+ }
522
+ try {
523
+ await navigator.clipboard.writeText(entry.rawLogs);
524
+ flash("Logs copiados al portapapeles", "good");
525
+ } catch {
526
+ flash("No se pudo acceder al portapapeles", "bad");
527
+ }
528
+ });
529
+ }
530
+
531
+ const clearLogsBtn = root.querySelector('[data-act="clear-logs"]');
532
+ if (clearLogsBtn) {
533
+ clearLogsBtn.setAttribute("aria-label", `Limpiar logs de ${project.name}`);
534
+ clearLogsBtn.addEventListener("click", () => {
535
+ entry.rawLogs = "";
536
+ renderLogsText(entry);
537
+ });
538
+ }
539
+
540
+ const logsButton = root.querySelector('[data-act="logs"]');
541
+ if (logsButton && logsBox) logsButton.setAttribute("aria-controls", `logs-${project.id}`);
542
+ logsButton.addEventListener("click", () => {
543
+ entry.logsOpen = !entry.logsOpen;
544
+ if (logsBox) logsBox.hidden = !entry.logsOpen;
545
+ logsButton.setAttribute("aria-expanded", String(entry.logsOpen));
546
+ if (entry.logsOpen) {
547
+ entry.historyOpen = false;
548
+ entry.graphOpen = false;
549
+ entry.envOpen = false;
550
+ const hb = root.querySelector(".history__box");
551
+ if (hb) hb.hidden = true;
552
+ const gb = root.querySelector(".graph__box");
553
+ if (gb) gb.hidden = true;
554
+ const eb = root.querySelector(".env__box");
555
+ if (eb) eb.hidden = true;
556
+ const hBtn = root.querySelector('[data-act="history"]');
557
+ if (hBtn) hBtn.setAttribute("aria-expanded", "false");
558
+ const gBtn = root.querySelector('[data-act="graph"]');
559
+ if (gBtn) gBtn.setAttribute("aria-expanded", "false");
560
+ const eBtn = root.querySelector('[data-act="env-audit"]');
561
+ if (eBtn) eBtn.setAttribute("aria-expanded", "false");
562
+ startLogsStream(project.id, entry);
563
+ } else {
564
+ stopLogsStream(entry);
565
+ }
566
+ });
567
+
568
+ const historyBox = root.querySelector(".history__box");
569
+ if (historyBox) historyBox.id = `history-${project.id}`;
570
+ const historyButton = root.querySelector('[data-act="history"]');
571
+ if (historyButton && historyBox) historyButton.setAttribute("aria-controls", `history-${project.id}`);
572
+ historyButton.addEventListener("click", () => {
573
+ entry.historyOpen = !entry.historyOpen;
574
+ if (historyBox) historyBox.hidden = !entry.historyOpen;
575
+ historyButton.setAttribute("aria-expanded", String(entry.historyOpen));
576
+ if (entry.historyOpen) {
577
+ entry.logsOpen = false;
578
+ entry.graphOpen = false;
579
+ entry.envOpen = false;
580
+ stopLogsStream(entry);
581
+ if (logsBox) logsBox.hidden = true;
582
+ logsButton.setAttribute("aria-expanded", "false");
583
+ const gb = root.querySelector(".graph__box");
584
+ if (gb) gb.hidden = true;
585
+ const gBtn = root.querySelector('[data-act="graph"]');
586
+ if (gBtn) gBtn.setAttribute("aria-expanded", "false");
587
+ const eb = root.querySelector(".env__box");
588
+ if (eb) eb.hidden = true;
589
+ const eBtn = root.querySelector('[data-act="env-audit"]');
590
+ if (eBtn) eBtn.setAttribute("aria-expanded", "false");
591
+ entry.lastHistoryState = project.state;
592
+ pullHistory(project.id, entry);
593
+ }
594
+ });
595
+
596
+ const graphBox = root.querySelector(".graph__box");
597
+ if (graphBox) graphBox.id = `graph-${project.id}`;
598
+ const graphButton = root.querySelector('[data-act="graph"]');
599
+ if (graphButton && graphBox) graphButton.setAttribute("aria-controls", `graph-${project.id}`);
600
+ if (graphButton) {
601
+ graphButton.addEventListener("click", () => {
602
+ entry.graphOpen = !entry.graphOpen;
603
+ if (graphBox) graphBox.hidden = !entry.graphOpen;
604
+ graphButton.setAttribute("aria-expanded", String(entry.graphOpen));
605
+ if (entry.graphOpen) {
606
+ entry.logsOpen = false;
607
+ entry.historyOpen = false;
608
+ entry.envOpen = false;
609
+ stopLogsStream(entry);
610
+ if (logsBox) logsBox.hidden = true;
611
+ logsButton.setAttribute("aria-expanded", "false");
612
+ if (historyBox) historyBox.hidden = true;
613
+ historyButton.setAttribute("aria-expanded", "false");
614
+ const eb = root.querySelector(".env__box");
615
+ if (eb) eb.hidden = true;
616
+ const eBtn = root.querySelector('[data-act="env-audit"]');
617
+ if (eBtn) eBtn.setAttribute("aria-expanded", "false");
618
+ renderGraph(project, entry);
619
+ }
620
+ });
621
+ }
622
+
623
+ const envBox = root.querySelector(".env__box");
624
+ if (envBox) envBox.id = `env-${project.id}`;
625
+ const envButton = root.querySelector('[data-act="env-audit"]');
626
+ if (envButton && envBox) envButton.setAttribute("aria-controls", `env-${project.id}`);
627
+ if (envButton) {
628
+ envButton.addEventListener("click", () => {
629
+ entry.envOpen = !entry.envOpen;
630
+ if (envBox) envBox.hidden = !entry.envOpen;
631
+ envButton.setAttribute("aria-expanded", String(entry.envOpen));
632
+ if (entry.envOpen) {
633
+ entry.logsOpen = false;
634
+ entry.historyOpen = false;
635
+ entry.graphOpen = false;
636
+ stopLogsStream(entry);
637
+ if (logsBox) logsBox.hidden = true;
638
+ logsButton.setAttribute("aria-expanded", "false");
639
+ if (historyBox) historyBox.hidden = true;
640
+ historyButton.setAttribute("aria-expanded", "false");
641
+ if (graphBox) graphBox.hidden = true;
642
+ if (graphButton) graphButton.setAttribute("aria-expanded", "false");
643
+ pullEnvAudit(project.id, entry);
644
+ }
645
+ });
646
+ }
647
+
648
+ // Copiar ruta del proyecto con transicion de texto sobria
649
+ const copyBtn = root.querySelector(".project__path-copy");
650
+ if (copyBtn) {
651
+ copyBtn.addEventListener("click", async () => {
652
+ try {
653
+ await navigator.clipboard.writeText(project.path);
654
+ const prevText = copyBtn.textContent;
655
+ copyBtn.textContent = "Copiado";
656
+ copyBtn.disabled = true;
657
+ setTimeout(() => {
658
+ copyBtn.textContent = prevText;
659
+ copyBtn.disabled = false;
660
+ }, 1500);
661
+ } catch {
662
+ flash("No se pudo copiar la ruta", "bad");
663
+ }
664
+ });
665
+ }
666
+
667
+ // Abrir carpeta en explorador nativo
668
+ const openFolderBtn = root.querySelector('[data-act="open-folder"]');
669
+ if (openFolderBtn) {
670
+ openFolderBtn.addEventListener("click", (event) => {
671
+ act(event.currentTarget, async () => {
672
+ try {
673
+ await api("/api/open-folder", {
674
+ method: "POST",
675
+ body: JSON.stringify({ path: project.path }),
676
+ });
677
+ flash(`Explorador abierto en ${project.name}`, "neutral");
678
+ } catch (err) {
679
+ flash(`Error al abrir explorador: ${err.message}`, "bad");
680
+ }
681
+ });
682
+ });
683
+ }
684
+
685
+ // Cuadro deslizable para elegir y abrir editor (VS Code / Cursor / etc.)
686
+ const editorSelect = root.querySelector('[data-act="select-editor"]');
687
+ if (editorSelect) {
688
+ editorSelect.addEventListener("change", async (event) => {
689
+ const chosenEditor = editorSelect.value;
690
+ if (!chosenEditor) return;
691
+ // Regresar el select a su valor inicial para permitir re-selección
692
+ editorSelect.value = "";
693
+ try {
694
+ const res = await api("/api/open-editor", {
695
+ method: "POST",
696
+ body: JSON.stringify({ path: project.path, editor: chosenEditor }),
697
+ });
698
+ flash(`Abriendo ${project.name} en ${res.editor || chosenEditor}...`, "neutral");
699
+ } catch (err) {
700
+ flash(err.message, "warn");
701
+ }
702
+ });
703
+ }
704
+
705
+ cards.set(project.id, entry);
706
+ return entry;
707
+ }
708
+
709
+ function disarmFreeze(button) {
710
+ delete button.dataset.armed;
711
+ button.textContent = "Congelar a stack.yaml";
712
+ }
713
+
714
+ function disarmDocker(button) {
715
+ delete button.dataset.armed;
716
+ button.textContent = "Reiniciar Docker";
717
+ }
718
+
719
+ ui.btnDocker.addEventListener("click", (event) => {
720
+ const button = event.currentTarget;
721
+ const action = button.dataset.action;
722
+
723
+ // Abrir no pide confirmacion: no hay nada que perder. Reiniciar si, y en dos
724
+ // pasos como Congelar, porque se lleva puestos todos los contenedores que
725
+ // esten corriendo, incluidos los de proyectos que no estas mirando.
726
+ if (action === "restart" && button.dataset.armed !== "true") {
727
+ button.dataset.armed = "true";
728
+ button.textContent = "Reiniciar y bajar los contenedores?";
729
+ setTimeout(() => disarmDocker(button), 6000);
730
+ // Cuales, si el motor contesta a tiempo. "los contenedores" no dice si son
731
+ // los dos de este proyecto o los nueve de la maquina, y reiniciar el motor
732
+ // se los lleva a todos. La respuesta llega despues del primer texto porque
733
+ // el boton no puede quedarse esperando a docker para armarse.
734
+ api("/api/docker/containers")
735
+ .then((res) => {
736
+ const nombres = res.running || [];
737
+ if (!nombres.length || button.dataset.armed !== "true") return;
738
+ button.textContent = `Reiniciar y bajar ${nombres.length}: ${nombres.join(", ")}?`;
739
+ })
740
+ .catch(() => {
741
+ /* se queda con la frase generica, que ya es una advertencia */
742
+ });
743
+ return;
744
+ }
745
+ if (action === "restart") disarmDocker(button);
746
+
747
+ act(button, async () => {
748
+ const res = await api(`/api/docker/${action}`, { method: "POST" });
749
+ // El motor tarda medio minuto. El boton cambia de texto solo, cuando la
750
+ // vista de estado deja de reportar docker_down.
751
+ flash(res.detail, res.ok ? "good" : "bad");
752
+ });
753
+ });
754
+
755
+ /* Que se puede limpiar, en el orden en que conviene mirarlo: primero lo que se
756
+ * regenera solo, ultimo lo que tiene datos adentro. Los tres primeros vienen
757
+ * tildados porque son la limpieza de siempre; los volumenes nunca. */
758
+ const CLEAN_TARGETS = [
759
+ { id: "cache", label: "Caché de build", nota: "se regenera al volver a construir", on: true },
760
+ { id: "containers", label: "Contenedores parados", nota: "no los que están corriendo", on: true },
761
+ { id: "networks", label: "Redes sin usar", nota: "las que no tienen contenedores", on: true },
762
+ { id: "images", label: "Imágenes sin tag", nota: "hay que volver a bajarlas", on: true },
763
+ {
764
+ id: "volumes",
765
+ label: "Volúmenes anónimos",
766
+ nota: "tienen datos adentro y no se regeneran",
767
+ on: false,
768
+ riesgo: true,
769
+ },
770
+ ];
771
+
772
+ function renderCleanTargets() {
773
+ ui.cleanTargets.replaceChildren(
774
+ ...CLEAN_TARGETS.map((target) => {
775
+ const li = document.createElement("li");
776
+ const row = document.createElement("label");
777
+ row.className = "clean__row";
778
+ if (target.riesgo) row.dataset.riesgo = "true";
779
+
780
+ const box = document.createElement("input");
781
+ box.type = "checkbox";
782
+ box.value = target.id;
783
+ box.checked = target.on;
784
+ box.addEventListener("change", refreshCleanButton);
785
+
786
+ const texto = document.createElement("span");
787
+ texto.textContent = `${target.label} · ${target.nota}`;
788
+
789
+ row.append(box, texto);
790
+ li.append(row);
791
+ return li;
792
+ }),
793
+ );
794
+ refreshCleanButton();
795
+ }
796
+
797
+ function cleanPicks() {
798
+ return [...ui.cleanTargets.querySelectorAll("input:checked")].map((b) => b.value);
799
+ }
800
+
801
+ function refreshCleanButton() {
802
+ const elegidos = cleanPicks();
803
+ // Desarmar junto con la etiqueta. Sin esto, armar el boton y cerrar el
804
+ // dialogo antes de que venzan los 6s dejaba el `armed` puesto: al reabrirlo,
805
+ // el primer click borraba sin el paso de confirmacion que el boton promete.
806
+ delete ui.cleanRun.dataset.armed;
807
+ ui.cleanRun.disabled = elegidos.length === 0;
808
+ ui.cleanRun.textContent = elegidos.length ? `Limpiar ${elegidos.length}` : "Elegí algo";
809
+ ui.cleanWarn.textContent = elegidos.includes("volumes")
810
+ ? "Los volúmenes no se pueden recuperar."
811
+ : "";
812
+ }
813
+
814
+ ui.btnDockerClean.addEventListener("click", () => {
815
+ renderCleanTargets();
816
+ ui.cleanUsage.textContent = "Consultando a Docker…";
817
+ ui.cleanModal.showModal();
818
+ api("/api/docker/usage")
819
+ .then((res) => {
820
+ // Sin la tabla igual se puede elegir: es contexto, no un requisito.
821
+ ui.cleanUsage.textContent = res.table || "Docker no informó cuánto ocupa.";
822
+ })
823
+ .catch(() => {
824
+ ui.cleanUsage.textContent = "No se pudo consultar cuánto ocupa Docker.";
825
+ });
826
+ });
827
+
828
+ ui.cleanModal.querySelector('[data-clean="close"]').addEventListener("click", () => {
829
+ ui.cleanModal.close();
830
+ });
831
+
832
+ ui.cleanRun.addEventListener("click", (event) => {
833
+ const button = event.currentTarget;
834
+ const targets = cleanPicks();
835
+ if (targets.length === 0) return;
836
+
837
+ // Dos pasos sobre el mismo boton, como Congelar y como Liberar todos: el
838
+ // segundo nombra lo que se va a borrar antes de borrarlo.
839
+ if (button.dataset.armed !== "true") {
840
+ button.dataset.armed = "true";
841
+ button.textContent = `Borrar ${targets.join(", ")}?`;
842
+ setTimeout(() => {
843
+ delete button.dataset.armed;
844
+ refreshCleanButton();
845
+ }, 6000);
846
+ return;
847
+ }
848
+ delete button.dataset.armed;
849
+
850
+ act(button, async () => {
851
+ const res = await api("/api/docker/clean", {
852
+ method: "POST",
853
+ body: JSON.stringify({ targets }),
854
+ });
855
+ ui.cleanModal.close();
856
+ flash(res.detail, res.ok ? "good" : "bad");
857
+ });
858
+ });
859
+
860
+ let killAllTimer = null;
861
+ let killAllSnapshot = null;
862
+ // Los puertos tildados. Vacio quiere decir "todos", que era el unico
863
+ // comportamiento posible hasta ahora: el boton no puede quedarse sin efecto por
864
+ // no haber tildado nada.
865
+ let orphanPicks = new Set();
866
+
867
+ function refreshKillAllLabel() {
868
+ if (ui.orphansKillAll.dataset.armed === "true") return;
869
+ const elegidos = orphanPicks.size;
870
+ ui.orphansKillAll.textContent = elegidos ? `Cerrar ${elegidos}` : "Liberar todos";
871
+ }
872
+
873
+ function disarmKillAll() {
874
+ if (killAllTimer !== null) {
875
+ clearTimeout(killAllTimer);
876
+ killAllTimer = null;
877
+ }
878
+ killAllSnapshot = null;
879
+ delete ui.orphansKillAll.dataset.armed;
880
+ refreshKillAllLabel();
881
+ }
882
+
883
+ // Dos pasos sobre el mismo boton, igual que Congelar: cerrar varios procesos de
884
+ // un click es lo mas destructivo de la interfaz, asi que el segundo paso nombra
885
+ // cuales antes de hacerlo.
886
+ ui.orphansKillAll.addEventListener("click", (event) => {
887
+ const button = event.currentTarget;
888
+
889
+ if (button.dataset.armed !== "true") {
890
+ const elegidos = latestOrphansList.filter((o) => orphanPicks.has(o.port));
891
+ killAllSnapshot = elegidos.length ? elegidos : [...latestOrphansList];
892
+ if (killAllSnapshot.length === 0) return;
893
+
894
+ button.dataset.armed = "true";
895
+ const detalle = killAllSnapshot.map((o) => `:${o.port} (${o.name})`).join(", ");
896
+ button.textContent = `Cerrar ${killAllSnapshot.length}: ${detalle}?`;
897
+
898
+ if (killAllTimer !== null) clearTimeout(killAllTimer);
899
+ killAllTimer = setTimeout(() => disarmKillAll(), 6000);
900
+ return;
901
+ }
902
+
903
+ const victimas = killAllSnapshot || [];
904
+ disarmKillAll();
905
+ if (victimas.length === 0) return;
906
+
907
+ act(button, async () => {
908
+ // Los puertos que se mostraron en el armado, y solo esos. El servidor vuelve a
909
+ // calcular quien los ocupa: nunca le mandamos un PID desde aca.
910
+ const res = await api("/api/ports/kill-all", {
911
+ method: "POST",
912
+ body: JSON.stringify({ ports: victimas.map((o) => o.port) }),
913
+ });
914
+ delete ui.orphansList.dataset.ids;
915
+ orphanPicks.clear();
916
+ if (res.failed.length) {
917
+ const errores = res.failed.map((f) => `:${f.port} (${f.reason})`).join(", ");
918
+ flash(`Cerrados ${res.killed.length} de ${victimas.length}. Fallaron: ${errores}`, "warn");
919
+ } else {
920
+ flash(`Cerrados ${res.killed.length} procesos`, "good");
921
+ }
922
+ await refreshOrphans();
923
+ });
924
+ });
925
+
926
+ function updateCard(entry, project) {
927
+ const { root } = entry;
928
+ entry.lastState = project.state;
929
+ root.querySelector(".project__name").textContent = project.name;
930
+ root.querySelector(".project__path").textContent = project.path;
931
+
932
+ if (!entry.userToggled) {
933
+ entry.expanded = project.state === "running" || project.state === "starting" || Boolean(project.error);
934
+ root.setAttribute("data-expanded", String(entry.expanded));
935
+ const toggleBtn = root.querySelector(".project__toggle");
936
+ if (toggleBtn) toggleBtn.setAttribute("aria-expanded", String(entry.expanded));
937
+ }
938
+
939
+ const [label, tone] = PROJECT_LABELS[project.state] || PROJECT_LABELS.stopped;
940
+ root.querySelector(".state").dataset.tone = tone;
941
+ root.querySelector(".state__text").textContent = label;
942
+
943
+ // El ultimo abrible, no el primero: el orden de arranque va de los
944
+ // contenedores al frontend, y lo que uno quiere mirar es el final.
945
+ const abrible = [...project.services].reverse().find((s) => s.openable && s.port);
946
+ const open = root.querySelector(".project__open");
947
+ open.hidden = !abrible;
948
+ if (abrible) {
949
+ open.href = abrirUrl(abrible);
950
+ open.title = `Abrir ${abrible.name} en ${abrirUrl(abrible)}`;
951
+ }
952
+
953
+ // Solo lo detectado se puede congelar: lo que ya tiene archivo, no.
954
+ const freeze = root.querySelector('[data-act="freeze"]');
955
+ freeze.hidden = !project.detected;
956
+ if (freeze.hidden) disarmFreeze(freeze);
957
+
958
+ const error = root.querySelector(".project__error");
959
+ error.textContent = project.error || "";
960
+ error.hidden = !project.error;
961
+
962
+ const dockerWarn = root.querySelector(".project__docker-warning");
963
+ if (dockerWarn) {
964
+ dockerWarn.textContent = project.docker_down
965
+ ? "Docker Desktop está cerrado — abrilo para arrancar los contenedores"
966
+ : "";
967
+ dockerWarn.hidden = !project.docker_down;
968
+ }
969
+
970
+ // Solo reconstruir la lista de servicios si algo cambio. La huella
971
+ // serializa todo lo que afecta al render: estado, puerto, occupant,
972
+ // botones, marcas. En estado estable (90% del polling) esto evita
973
+ // destruir y reconstruir el DOM cada 2.5s, preservando la seleccion de
974
+ // texto, el foco del teclado y reduciendo GC.
975
+ const services = root.querySelector(".services");
976
+ const fingerprint = JSON.stringify(
977
+ project.services.map((s) => [s.name, s.state, s.port, s.openable, s.managed,
978
+ s.port_taken, s.shared_with, s.occupant, tunnelPorts.has(s.port)]),
979
+ );
980
+ if (services.dataset.fingerprint !== fingerprint) {
981
+ services.dataset.fingerprint = fingerprint;
982
+ services.replaceChildren(
983
+ ...project.services.map((service) => renderService(service, project.id)),
984
+ );
985
+ }
986
+
987
+ const metrics = project.metrics || {};
988
+ const items = services.querySelectorAll(".service");
989
+ project.services.forEach((s, idx) => {
990
+ const item = items[idx];
991
+ if (!item) return;
992
+ const badge = item.querySelector(".service__metrics");
993
+ if (badge && metrics[s.name]) {
994
+ const m = metrics[s.name];
995
+ if (m.memory_mb > 0 || m.cpu_percent > 0) {
996
+ badge.textContent = `${m.cpu_percent}% · ${m.memory_mb} MB`;
997
+ badge.setAttribute("aria-label", `CPU: ${m.cpu_percent}%, Memoria: ${m.memory_mb} MB`);
998
+ badge.hidden = false;
999
+ } else {
1000
+ badge.hidden = true;
1001
+ }
1002
+ } else if (badge) {
1003
+ badge.hidden = true;
1004
+ }
1005
+ });
1006
+
1007
+ const select = root.querySelector(".profile__select");
1008
+ const wanted = [project.default.join(","), ...project.profiles].join("|");
1009
+ if (select.dataset.options !== wanted) {
1010
+ select.dataset.options = wanted;
1011
+ const all = document.createElement("option");
1012
+ all.value = "";
1013
+ // "todo" mentia cuando el stack declara `default:`: Arrancar levantaba solo
1014
+ // esos, y el resto de la lista quedaba abajo en gris sin explicacion.
1015
+ all.textContent = project.default.length ? `por defecto (${project.default.join(", ")})` : "todo";
1016
+ select.replaceChildren(
1017
+ all,
1018
+ ...project.profiles.map((name) => {
1019
+ const option = document.createElement("option");
1020
+ option.value = name;
1021
+ option.textContent = name;
1022
+ return option;
1023
+ }),
1024
+ );
1025
+ }
1026
+ root.querySelector(".profile").hidden = project.profiles.length === 0;
1027
+ if (document.activeElement !== select) {
1028
+ select.value = project.profile || "";
1029
+ }
1030
+
1031
+ // Poblar select de editores disponibles de forma no intrusiva
1032
+ const editorSelect = root.querySelector('[data-act="select-editor"]');
1033
+ if (editorSelect && editorSelect.dataset.loaded !== "true") {
1034
+ loadAvailableEditors().then((editors) => {
1035
+ editorSelect.dataset.loaded = "true";
1036
+ if (!editors || editors.length === 0) {
1037
+ editorSelect.closest(".editor-select-wrap").hidden = true;
1038
+ return;
1039
+ }
1040
+ editorSelect.closest(".editor-select-wrap").hidden = false;
1041
+ const placeholder = document.createElement("option");
1042
+ placeholder.value = "";
1043
+ placeholder.disabled = true;
1044
+ placeholder.selected = true;
1045
+ placeholder.textContent = "Editor…";
1046
+ editorSelect.replaceChildren(
1047
+ placeholder,
1048
+ ...editors.map((ed) => {
1049
+ const opt = document.createElement("option");
1050
+ opt.value = ed.id;
1051
+ opt.textContent = ed.name;
1052
+ return opt;
1053
+ }),
1054
+ );
1055
+ });
1056
+ }
1057
+
1058
+
1059
+ const live = project.state === "starting" || project.state === "running";
1060
+ const stopping = project.state === "stopping";
1061
+ // Hay algo que apagar aunque no lo hayamos arrancado nosotros: un contenedor
1062
+ // levantado desde la terminal publica su puerto y se ve "listo".
1063
+ const algoVivo = live || project.services.some((s) => s.state !== "stopped");
1064
+ root.querySelector('[data-act="up"]').disabled =
1065
+ live || stopping || project.state === "invalid";
1066
+ root.querySelector('[data-act="down"]').disabled = !algoVivo || stopping;
1067
+
1068
+ if (entry.logsOpen) {
1069
+ if (live && !entry.eventSource) {
1070
+ startLogsStream(project.id, entry);
1071
+ } else if (!live && entry.eventSource) {
1072
+ stopLogsStream(entry);
1073
+ }
1074
+ } else {
1075
+ stopLogsStream(entry);
1076
+ }
1077
+
1078
+ if (entry.historyOpen && entry.lastHistoryState !== project.state) {
1079
+ entry.lastHistoryState = project.state;
1080
+ pullHistory(project.id, entry);
1081
+ }
1082
+
1083
+ const graphButton = root.querySelector('[data-act="graph"]');
1084
+ const hasGraph = project.graph && project.graph.nodes && project.graph.nodes.length > 1;
1085
+ if (graphButton) {
1086
+ graphButton.hidden = !hasGraph;
1087
+ }
1088
+ if (entry.graphOpen && hasGraph) {
1089
+ renderGraph(project, entry);
1090
+ }
1091
+ if (entry.envOpen) {
1092
+ pullEnvAudit(project.id, entry);
1093
+ }
1094
+ }
1095
+
1096
+ function renderGraph(project, entry) {
1097
+ const svg = entry.root.querySelector(".graph__svg");
1098
+ if (!svg || !project.graph || !project.graph.nodes || !project.graph.nodes.length) return;
1099
+
1100
+ const { levels, nodes, edges } = project.graph;
1101
+ const nodeMap = new Map();
1102
+ const serviceStateMap = new Map((project.services || []).map((s) => [s.name, s.state]));
1103
+
1104
+ const nodeWidth = 130;
1105
+ const nodeHeight = 36;
1106
+ const colGap = 60;
1107
+ const rowGap = 16;
1108
+ const padding = 20;
1109
+
1110
+ const maxRows = Math.max(...levels.map((l) => l.length), 1);
1111
+ const totalWidth = Math.max(340, padding * 2 + levels.length * nodeWidth + Math.max(0, levels.length - 1) * colGap);
1112
+ const totalHeight = padding * 2 + maxRows * nodeHeight + Math.max(0, maxRows - 1) * rowGap;
1113
+
1114
+ levels.forEach((level, colIdx) => {
1115
+ const x = padding + colIdx * (nodeWidth + colGap);
1116
+ const colHeight = level.length * nodeHeight + (level.length - 1) * rowGap;
1117
+ const startY = padding + (totalHeight - 2 * padding - colHeight) / 2;
1118
+
1119
+ level.forEach((name, rowIdx) => {
1120
+ const y = startY + rowIdx * (nodeHeight + rowGap);
1121
+ const nodeData = nodes.find((n) => n.name === name) || { name, port: null };
1122
+ nodeMap.set(name, {
1123
+ x,
1124
+ y,
1125
+ name,
1126
+ port: nodeData.port,
1127
+ state: serviceStateMap.get(name) || "stopped",
1128
+ });
1129
+ });
1130
+ });
1131
+
1132
+ const arrowId = `arrow-${project.id}`;
1133
+ let html = `
1134
+ <defs>
1135
+ <marker id="${arrowId}" markerWidth="6" markerHeight="6" refX="5" refY="3" orient="auto">
1136
+ <path d="M0,0 L0,6 L6,3 z" fill="var(--color-rule-strong)" />
1137
+ </marker>
1138
+ </defs>
1139
+ `;
1140
+
1141
+ (edges || []).forEach((edge) => {
1142
+ const from = nodeMap.get(edge.from);
1143
+ const to = nodeMap.get(edge.to);
1144
+ if (!from || !to) return;
1145
+
1146
+ const startX = from.x + nodeWidth;
1147
+ const startY = from.y + nodeHeight / 2;
1148
+ const endX = to.x;
1149
+ const endY = to.y + nodeHeight / 2;
1150
+ const c1X = startX + (endX - startX) / 2;
1151
+ const c2X = c1X;
1152
+
1153
+ html += `
1154
+ <path d="M ${startX} ${startY} C ${c1X} ${startY}, ${c2X} ${endY}, ${endX} ${endY}"
1155
+ stroke="var(--color-rule-strong)" stroke-width="1.5" fill="none"
1156
+ marker-end="url(#${arrowId})" />
1157
+ `;
1158
+ });
1159
+
1160
+ nodeMap.forEach((node) => {
1161
+ let dotColor = "var(--color-ink-4)";
1162
+ if (node.state === "ready") dotColor = "var(--color-good)";
1163
+ else if (node.state === "starting") dotColor = "var(--color-warn)";
1164
+ else if (node.state === "error") dotColor = "var(--color-bad)";
1165
+
1166
+ const label = node.name.length > 12 ? node.name.slice(0, 11) + "…" : node.name;
1167
+ const portText = node.port ? `:${node.port}` : "";
1168
+
1169
+ html += `
1170
+ <g class="graph__node" transform="translate(${node.x}, ${node.y})">
1171
+ <rect width="${nodeWidth}" height="${nodeHeight}" rx="6"
1172
+ fill="var(--color-paper)" stroke="var(--color-rule-strong)" stroke-width="1" />
1173
+ <circle cx="12" cy="${nodeHeight / 2}" r="4" fill="${dotColor}" />
1174
+ <text x="24" y="${nodeHeight / 2 + (portText ? -2 : 4)}"
1175
+ fill="var(--color-ink)" font-family="var(--font-mono)" font-size="11" font-weight="600">
1176
+ ${escapeXml(label)}
1177
+ </text>
1178
+ ${
1179
+ portText
1180
+ ? `<text x="24" y="${nodeHeight / 2 + 10}" fill="var(--color-ink-3)" font-family="var(--font-mono)" font-size="9">${escapeXml(portText)}</text>`
1181
+ : ""
1182
+ }
1183
+ </g>
1184
+ `;
1185
+ });
1186
+
1187
+ svg.setAttribute("viewBox", `0 0 ${totalWidth} ${totalHeight}`);
1188
+ svg.setAttribute("width", String(totalWidth));
1189
+ svg.setAttribute("height", String(totalHeight));
1190
+ svg.innerHTML = html;
1191
+ }
1192
+
1193
+ function startLogsStream(id, entry) {
1194
+ if (entry.eventSource) return;
1195
+ const liveBadge = entry.root.querySelector(".logs__live");
1196
+ const isRunning = entry.lastState === "running" || entry.lastState === "starting";
1197
+ if (!window.EventSource || !isRunning) {
1198
+ if (liveBadge) liveBadge.hidden = true;
1199
+ pullLogs(id, entry);
1200
+ return;
1201
+ }
1202
+ const url = `/api/projects/${id}/logs/stream?since=${entry.logSeq}&token=${encodeURIComponent(token)}`;
1203
+ try {
1204
+ const es = new EventSource(url);
1205
+ entry.eventSource = es;
1206
+
1207
+ es.onopen = () => {
1208
+ if (liveBadge) liveBadge.hidden = false;
1209
+ };
1210
+
1211
+ es.onmessage = (event) => {
1212
+ try {
1213
+ const item = JSON.parse(event.data);
1214
+ if (item.seq > entry.logSeq) {
1215
+ entry.logSeq = item.seq;
1216
+ entry.rawLogs = (entry.rawLogs || "") + item.text + "\n";
1217
+ renderLogsText(entry);
1218
+ }
1219
+ } catch {
1220
+ /* noop */
1221
+ }
1222
+ };
1223
+
1224
+ es.onerror = () => {
1225
+ stopLogsStream(entry);
1226
+ pullLogs(id, entry);
1227
+ };
1228
+ } catch {
1229
+ stopLogsStream(entry);
1230
+ pullLogs(id, entry);
1231
+ }
1232
+ }
1233
+
1234
+ function stopLogsStream(entry) {
1235
+ if (entry.eventSource) {
1236
+ entry.eventSource.close();
1237
+ entry.eventSource = null;
1238
+ }
1239
+ const liveBadge = entry.root.querySelector(".logs__live");
1240
+ if (liveBadge) liveBadge.hidden = true;
1241
+ }
1242
+
1243
+ async function pullLogs(id, entry) {
1244
+ try {
1245
+ const data = await api(`/api/projects/${id}/logs?since=${entry.logSeq}`);
1246
+ if (data.lines.length) {
1247
+ entry.logSeq = data.lines[data.lines.length - 1].seq;
1248
+ entry.rawLogs = (entry.rawLogs || "") + data.lines.map((l) => l.text).join("\n") + "\n";
1249
+ renderLogsText(entry);
1250
+ } else if (!entry.rawLogs) {
1251
+ // Sin logs todavia: hay que pintar igual, que es donde va el cartel. Solo
1252
+ // mientras este vacio, y no en cada sondeo: reescribir el contenido cada
1253
+ // 2.5s le borraria la seleccion a quien este copiando una linea.
1254
+ renderLogsText(entry);
1255
+ }
1256
+ } catch {
1257
+ /* el proximo ciclo reintenta */
1258
+ }
1259
+ }
1260
+
1261
+ function renderLogsText(entry) {
1262
+ const logsEl = entry.root.querySelector(".logs");
1263
+ const filterInput = entry.root.querySelector(".logs__filter");
1264
+ if (!logsEl) return;
1265
+ const atBottom = logsEl.scrollHeight - logsEl.scrollTop - logsEl.clientHeight < 40;
1266
+ const raw = entry.rawLogs || "";
1267
+ const filter = (filterInput ? filterInput.value : "").trim().toLowerCase();
1268
+ if (!raw) {
1269
+ // Una caja en blanco no distingue "no arrancaste nada" de "esto se rompio".
1270
+ // El servidor devuelve {lines: [], seq: 0} para un proyecto sin sesion, que
1271
+ // es correcto, y `pullLogs` cortaba sin escribir nada en la pantalla.
1272
+ logsEl.textContent =
1273
+ "Todavía no hay logs. Solo se registran los de un stack arrancado desde acá.";
1274
+ } else if (!filter) {
1275
+ logsEl.textContent = raw;
1276
+ } else {
1277
+ const lines = raw.split("\n");
1278
+ const encontrados = lines.filter((l) => l.toLowerCase().includes(filter));
1279
+ logsEl.textContent = encontrados.length
1280
+ ? encontrados.join("\n")
1281
+ : `Ningún renglón contiene "${filter}".`;
1282
+ }
1283
+ if (atBottom) logsEl.scrollTop = logsEl.scrollHeight;
1284
+ }
1285
+
1286
+ async function pullHistory(id, entry, retries = 3) {
1287
+ try {
1288
+ const data = await api(`/api/projects/${id}/history`);
1289
+ renderHistoryTable(entry, data.history || []);
1290
+ } catch {
1291
+ if (retries > 0) {
1292
+ setTimeout(() => pullHistory(id, entry, retries - 1), 1000);
1293
+ }
1294
+ }
1295
+ }
1296
+
1297
+ function renderHistoryTable(entry, runs) {
1298
+ const tbody = entry.root.querySelector(".history__tbody");
1299
+ if (!tbody) return;
1300
+
1301
+ if (runs.length === 0) {
1302
+ tbody.innerHTML = '<tr><td colspan="4">No hay historial de arranques todavía.</td></tr>';
1303
+ return;
1304
+ }
1305
+
1306
+ tbody.replaceChildren(
1307
+ ...[...runs].reverse().map((r) => {
1308
+ const tr = document.createElement("tr");
1309
+
1310
+ const tdFecha = document.createElement("td");
1311
+ let fechaTexto = "";
1312
+ if (r.timestamp) {
1313
+ try {
1314
+ const raw = r.timestamp.endsWith("Z") || r.timestamp.includes("+") ? r.timestamp : `${r.timestamp}Z`;
1315
+ const d = new Date(raw);
1316
+ if (!isNaN(d.getTime())) {
1317
+ const pad = (n) => String(n).padStart(2, "0");
1318
+ const yyyy = d.getFullYear();
1319
+ const mm = pad(d.getMonth() + 1);
1320
+ const dd = pad(d.getDate());
1321
+ const hh = pad(d.getHours());
1322
+ const min = pad(d.getMinutes());
1323
+ fechaTexto = `${yyyy}-${mm}-${dd} ${hh}:${min}`;
1324
+ } else {
1325
+ fechaTexto = r.timestamp.replace("T", " ").substring(0, 16);
1326
+ }
1327
+ } catch (_) {
1328
+ fechaTexto = r.timestamp.replace("T", " ").substring(0, 16);
1329
+ }
1330
+ }
1331
+ tdFecha.textContent = fechaTexto;
1332
+ tr.appendChild(tdFecha);
1333
+
1334
+ const tdPerfil = document.createElement("td");
1335
+ tdPerfil.textContent = r.profile || "-";
1336
+ tr.appendChild(tdPerfil);
1337
+
1338
+ const tdDur = document.createElement("td");
1339
+ tdDur.textContent = r.duration_s != null ? `${r.duration_s}s` : "-";
1340
+ tr.appendChild(tdDur);
1341
+
1342
+ const tdRes = document.createElement("td");
1343
+ let resText = r.result || "unknown";
1344
+ if (resText === "error" && r.error) {
1345
+ resText += `\n${r.error}`;
1346
+ }
1347
+ tdRes.textContent = resText;
1348
+ if (r.result === "running") {
1349
+ tdRes.style.color = "var(--color-good)";
1350
+ } else if (r.result === "error") {
1351
+ tdRes.style.color = "var(--color-bad)";
1352
+ }
1353
+ tr.appendChild(tdRes);
1354
+
1355
+ return tr;
1356
+ })
1357
+ );
1358
+ }
1359
+
1360
+ async function pullEnvAudit(id, entry) {
1361
+ const envBox = entry.root.querySelector(".env__box");
1362
+ if (!envBox) return;
1363
+
1364
+ const badge = envBox.querySelector(".env__status-badge");
1365
+ const summary = envBox.querySelector(".env__summary");
1366
+ const missingSec = envBox.querySelector(".env__section--missing");
1367
+ const missingList = envBox.querySelector(".env__list--missing");
1368
+ const placeSec = envBox.querySelector(".env__section--placeholders");
1369
+ const placeList = envBox.querySelector(".env__list--placeholders");
1370
+ const emptySec = envBox.querySelector(".env__section--empty");
1371
+ const emptyList = envBox.querySelector(".env__list--empty");
1372
+
1373
+ try {
1374
+ const data = await api(`/api/projects/${id}/env-audit`);
1375
+ if (data.ok) {
1376
+ badge.textContent = "OK";
1377
+ badge.className = "env__status-badge env__status-badge--ok";
1378
+ if (data.has_example) {
1379
+ summary.textContent = `.env sincronizado con ${data.example_file} (sin secretos por defecto).`;
1380
+ } else if (data.has_env) {
1381
+ summary.textContent = ".env local detectado sin placeholders inseguros.";
1382
+ } else {
1383
+ summary.textContent = "Sin archivos de entorno (.env ni .env.example).";
1384
+ }
1385
+ } else {
1386
+ badge.textContent = "ADVERTENCIA";
1387
+ badge.className = "env__status-badge env__status-badge--warn";
1388
+ const motivos = [];
1389
+ if (!data.has_env && data.has_example) {
1390
+ motivos.push(`falta .env local (existe ${data.example_file})`);
1391
+ }
1392
+ if (data.missing_keys && data.missing_keys.length > 0) {
1393
+ motivos.push(`${data.missing_keys.length} clave(s) faltante(s)`);
1394
+ }
1395
+ if (data.placeholder_keys && data.placeholder_keys.length > 0) {
1396
+ motivos.push(`${data.placeholder_keys.length} clave(s) con valores placeholder`);
1397
+ }
1398
+ summary.textContent = motivos.join(" · ") || "Revisa la configuración de entorno.";
1399
+ }
1400
+
1401
+ function renderKeys(sec, list, items, pillClass) {
1402
+ if (!sec || !list) return;
1403
+ if (items && items.length > 0) {
1404
+ sec.hidden = false;
1405
+ list.replaceChildren(
1406
+ ...items.map((k) => {
1407
+ const li = document.createElement("li");
1408
+ const span = document.createElement("span");
1409
+ span.className = `env__key-pill ${pillClass}`.trim();
1410
+ span.textContent = k;
1411
+ li.appendChild(span);
1412
+ return li;
1413
+ })
1414
+ );
1415
+ } else {
1416
+ sec.hidden = true;
1417
+ list.replaceChildren();
1418
+ }
1419
+ }
1420
+
1421
+ renderKeys(missingSec, missingList, data.missing_keys, "env__key-pill--missing");
1422
+ renderKeys(placeSec, placeList, data.placeholder_keys, "env__key-pill--placeholder");
1423
+ renderKeys(emptySec, emptyList, data.empty_keys, "");
1424
+ } catch (err) {
1425
+ if (summary) summary.textContent = `Error al verificar entorno: ${err.message}`;
1426
+ }
1427
+ }
1428
+
1429
+ function render(projects, data) {
1430
+ // Sin resultados con un filtro puesto no es lo mismo que no tener proyectos:
1431
+ // el cartel de "registrá el primero" ahi seria mentira.
1432
+ ui.empty.hidden = projects.length > 0 || Boolean(query) || Boolean(statusFilter);
1433
+
1434
+ // El buscador y los chips se muestran siempre que haya al menos un proyecto registrado.
1435
+ ui.find.hidden = data.registered === 0;
1436
+ ui.count.textContent = query || statusFilter
1437
+ ? `${data.total} ${data.total === 1 ? "coincidencia" : "coincidencias"}`
1438
+ : "";
1439
+
1440
+ ui.pager.hidden = data.pages <= 1;
1441
+ ui.pagerAt.textContent = `${data.page} de ${data.pages}`;
1442
+ ui.pager.querySelector('[data-page="prev"]').disabled = data.page <= 1;
1443
+ ui.pager.querySelector('[data-page="next"]').disabled = data.page >= data.pages;
1444
+ page = data.page;
1445
+
1446
+ const seen = new Set();
1447
+ for (const project of projects) {
1448
+ seen.add(project.id);
1449
+ let entry = cards.get(project.id);
1450
+ if (!entry) entry = buildCard(project);
1451
+ updateCard(entry, project);
1452
+ }
1453
+
1454
+ for (const [id, entry] of cards) {
1455
+ if (!seen.has(id)) {
1456
+ entry.root.remove();
1457
+ cards.delete(id);
1458
+ }
1459
+ }
1460
+
1461
+ ui.projects.replaceChildren(
1462
+ ...projects.map((project) => cards.get(project.id).root),
1463
+ );
1464
+ ui.projects.setAttribute("aria-busy", "false");
1465
+ hayProyectos = data.registered > 0;
1466
+ updateDocker(data.docker || { needed: false, down: false });
1467
+ updateFavicon(projects, data);
1468
+ }
1469
+
1470
+ /* Estado y accion siempre que alguno de los proyectos de la pagina use Docker,
1471
+ * aunque este todo bien: un control que solo aparece cuando algo falla no
1472
+ * distingue "esta todo en orden" de "esto no funciona". Con el motor arriba el
1473
+ * boton no se esconde, cambia de trabajo: reiniciar Docker es lo que uno quiere
1474
+ * cuando los contenedores empiezan a portarse raro. */
1475
+ function updateDocker(docker) {
1476
+ // Del estado global y no de los proyectos de la pagina: colgado de la pagina,
1477
+ // apretar "Siguiente" apagaba la fila entera cuando ahi no habia ninguno con
1478
+ // contenedores, y una fila que desaparece no distingue "esta en orden" de
1479
+ // "esto dejo de funcionar".
1480
+ const usan = docker.needed;
1481
+ const caido = docker.down;
1482
+
1483
+ ui.dockerState.hidden = !usan;
1484
+ ui.dockerState.textContent = caido ? "Docker cerrado" : "Docker corriendo";
1485
+ ui.dockerState.dataset.tone = caido ? "bad" : "ready";
1486
+
1487
+ ui.btnDocker.hidden = !usan;
1488
+ ui.btnDocker.dataset.action = caido ? "start" : "restart";
1489
+ ui.btnDockerClean.hidden = !usan || caido;
1490
+ // El sondeo pasa cada 2.5s y el armado dura 6: sin esto le pisaria la
1491
+ // pregunta al usuario mientras la esta leyendo.
1492
+ if (ui.btnDocker.dataset.armed !== "true") {
1493
+ ui.btnDocker.textContent = caido ? "Abrir Docker" : "Reiniciar Docker";
1494
+ }
1495
+ }
1496
+
1497
+ function updateFavicon(projects, data) {
1498
+ const link = document.getElementById("favicon");
1499
+ if (!link) return;
1500
+ const fallenCount = data && data.fallen ? data.fallen.length : 0;
1501
+ const hasError = projects.some((p) => p.state === "error" || p.state === "invalid");
1502
+ const hasRunning = projects.some((p) => p.state === "running" || p.state === "starting");
1503
+
1504
+ let color = "%2364748b";
1505
+ if (fallenCount > 0 || hasError) {
1506
+ color = "%23ef4444";
1507
+ } else if (hasRunning) {
1508
+ color = "%2322c55e";
1509
+ }
1510
+
1511
+ link.href = `data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Ccircle cx='50' cy='50' r='40' fill='${color}'/%3E%3C/svg%3E`;
1512
+ }
1513
+
1514
+ /* ciclo ------------------------------------------------------------------- */
1515
+
1516
+ /* Los puertos abiertos a internet, sacados de /api/state para no sumar un
1517
+ * sondeo mas. Se pinta con lo que ya llego, sin pedir nada. */
1518
+ let tunnelPorts = new Set();
1519
+
1520
+ function renderTunnels(list) {
1521
+ tunnelPorts = new Set(list.map((t) => t.port));
1522
+ ui.tunnels.hidden = list.length === 0;
1523
+ if (list.length === 0) {
1524
+ ui.tunnelsList.replaceChildren();
1525
+ // Y la firma: sin borrarla, cerrar el ultimo tunel y volver a abrir el
1526
+ // mismo puerto daba la misma cadena, el return temprano se saltaba el
1527
+ // repintado y la lista quedaba vacia con un tunel abierto.
1528
+ delete ui.tunnelsList.dataset.firma;
1529
+ return;
1530
+ }
1531
+
1532
+ ui.tunnelsHeading.textContent = `Túneles abiertos (${list.length})`;
1533
+
1534
+ // El proveedor entra en la firma: es un dato que se muestra, y si cambia sin
1535
+ // cambiar puerto ni URL la fila seguiria diciendo el anterior.
1536
+ const firma = list.map((t) => `${t.port}:${t.provider}:${t.url}`).join(",");
1537
+ if (ui.tunnelsList.dataset.firma === firma) return;
1538
+ ui.tunnelsList.dataset.firma = firma;
1539
+
1540
+ ui.tunnelsList.replaceChildren(
1541
+ ...list.map((tun) => {
1542
+ const li = document.createElement("li");
1543
+ li.className = "orphan";
1544
+
1545
+ const portTag = document.createElement("span");
1546
+ portTag.className = "orphan__port";
1547
+ portTag.textContent = `:${tun.port}`;
1548
+
1549
+ const info = document.createElement("div");
1550
+ info.className = "orphan__info";
1551
+
1552
+ const enlace = document.createElement("a");
1553
+ enlace.className = "orphan__name";
1554
+ enlace.href = tun.url;
1555
+ enlace.target = "_blank";
1556
+ enlace.rel = "noopener noreferrer";
1557
+ enlace.textContent = tun.url;
1558
+
1559
+ const meta = document.createElement("div");
1560
+ meta.className = "orphan__meta";
1561
+ meta.textContent = `via ${tun.provider}`;
1562
+
1563
+ info.append(enlace, meta);
1564
+
1565
+ const cerrar = document.createElement("button");
1566
+ cerrar.className = "orphan__kill";
1567
+ cerrar.type = "button";
1568
+ cerrar.textContent = "Cerrar";
1569
+ cerrar.addEventListener("click", () => {
1570
+ act(cerrar, async () => {
1571
+ await api(`/api/share/${tun.port}`, { method: "DELETE" });
1572
+ // Sin `refresh()` propio: `act` ya lo llama al terminar el trabajo.
1573
+ // Borrar la firma antes alcanza para forzar el repintado, y el que
1574
+ // habia aca duplicaba /api/state y refreshHealth en cada cierre.
1575
+ delete ui.tunnelsList.dataset.firma;
1576
+ });
1577
+ });
1578
+
1579
+ li.append(portTag, info, cerrar);
1580
+ return li;
1581
+ }),
1582
+ );
1583
+ }
1584
+
1585
+ async function refreshOrphans() {
1586
+ try {
1587
+ const data = await api("/api/ports/orphans");
1588
+ const list = data.orphans || [];
1589
+
1590
+ // Visible aunque no haya ninguno, mientras haya algun proyecto registrado:
1591
+ // una seccion que desaparece no distingue "no hay intrusos" de "esto dejo
1592
+ // de funcionar". Con cero proyectos si se esconde, porque ahi la pagina
1593
+ // entera es el cartel de registrar el primero.
1594
+ ui.orphans.hidden = !hayProyectos;
1595
+
1596
+ // Rojo solo cuando hay algo. La seccion se ve igual estando vacia, para
1597
+ // informar que el chequeo corrio, pero vestida de alarma decia lo contrario
1598
+ // de lo que su propio texto dice.
1599
+ ui.orphans.dataset.tone = list.length ? "bad" : "";
1600
+
1601
+ // Con uno solo no aporta nada: la fila ya trae su propio boton Cerrar.
1602
+ const varios = list.length >= 2;
1603
+ ui.orphansKillAll.hidden = !varios;
1604
+ latestOrphansList = list;
1605
+
1606
+ // Lo tildado que ya no esta en la lista deja de contar: si no, el boton
1607
+ // diria "Cerrar 3" con dos filas en pantalla.
1608
+ const vigentes = new Set(list.map((o) => o.port));
1609
+ for (const port of [...orphanPicks]) {
1610
+ if (!vigentes.has(port)) orphanPicks.delete(port);
1611
+ }
1612
+ refreshKillAllLabel();
1613
+
1614
+ const nextIds = list.map((o) => o.port).join(",") || "__empty__";
1615
+ if (ui.orphansList.dataset.ids === nextIds) return;
1616
+ ui.orphansList.dataset.ids = nextIds;
1617
+
1618
+ if (list.length === 0) {
1619
+ ui.orphansHeading.textContent = "Procesos intrusos";
1620
+ const limpio = document.createElement("li");
1621
+ limpio.className = "orphan orphan--empty";
1622
+ // "estan libres" era mentira con un contenedor publicando el puerto: la
1623
+ // tarjeta lo mostraba ocupado y este cartel decia lo contrario.
1624
+ limpio.textContent =
1625
+ "Ninguno. Lo que ocupa tus puertos lo arrancaste vos o es un contenedor de Docker.";
1626
+ ui.orphansList.replaceChildren(limpio);
1627
+ return;
1628
+ }
1629
+
1630
+ ui.orphansHeading.textContent = `Procesos intrusos (${list.length})`;
1631
+
1632
+ ui.orphansList.replaceChildren(
1633
+ ...list.map((orphan) => {
1634
+ const li = document.createElement("li");
1635
+ li.className = "orphan";
1636
+
1637
+ // La casilla solo con dos o mas: con una sola fila, elegirla y despues
1638
+ // apretar un boton es un paso de mas para lo que ya hace su Cerrar.
1639
+ let pick = null;
1640
+ if (varios) {
1641
+ pick = document.createElement("input");
1642
+ pick.type = "checkbox";
1643
+ pick.className = "orphan__pick";
1644
+ pick.checked = orphanPicks.has(orphan.port);
1645
+ pick.setAttribute(
1646
+ "aria-label",
1647
+ `Elegir el puerto ${orphan.port}, ocupado por ${orphan.name}`,
1648
+ );
1649
+ pick.addEventListener("change", () => {
1650
+ if (pick.checked) orphanPicks.add(orphan.port);
1651
+ else orphanPicks.delete(orphan.port);
1652
+ refreshKillAllLabel();
1653
+ });
1654
+ }
1655
+
1656
+ const portTag = document.createElement("span");
1657
+ portTag.className = "orphan__port";
1658
+ portTag.textContent = `:${orphan.port}`;
1659
+
1660
+ const info = document.createElement("div");
1661
+ info.className = "orphan__info";
1662
+
1663
+ // Tres renglones y no dos campos pegados con un punto. `node.exe ·
1664
+ // Decepticon` se lee como "este proceso es de Decepticon", y es al
1665
+ // reves: el proceso es un desconocido y Decepticon es quien reclama el
1666
+ // puerto. Son dos hechos distintos y ahora ocupan lugares distintos.
1667
+ const name = document.createElement("div");
1668
+ name.className = "orphan__name";
1669
+ name.textContent = `${orphan.name} · pid ${orphan.pid}`;
1670
+
1671
+ const claim = document.createElement("div");
1672
+ claim.className = "orphan__claim";
1673
+ const reclaman = orphan.projects || [];
1674
+ claim.textContent =
1675
+ reclaman.length > 1
1676
+ ? `ocupa un puerto que declaran ${reclaman.join(" y ")}`
1677
+ : `ocupa un puerto que declara ${reclaman[0] || "un proyecto registrado"}`;
1678
+
1679
+ const meta = document.createElement("div");
1680
+ meta.className = "orphan__meta";
1681
+ // La linea de comando es lo que deja decidir si cerrarlo: sale entera
1682
+ // en el title, porque en la fila entra recortada.
1683
+ meta.textContent = orphan.cmd || "sin linea de comando visible";
1684
+ if (orphan.cmd) meta.title = orphan.cmd;
1685
+
1686
+ info.append(name, claim, meta);
1687
+
1688
+ const kill = document.createElement("button");
1689
+ kill.className = "orphan__kill";
1690
+ kill.textContent = "Cerrar";
1691
+ kill.type = "button";
1692
+ kill.addEventListener("click", () => {
1693
+ act(kill, async () => {
1694
+ await api(`/api/ports/${orphan.port}/kill`, { method: "POST" });
1695
+ delete ui.orphansList.dataset.ids;
1696
+ await refreshOrphans();
1697
+ });
1698
+ });
1699
+
1700
+ if (pick) li.append(pick);
1701
+ li.append(portTag, info, kill);
1702
+ return li;
1703
+ }),
1704
+ );
1705
+ } catch {
1706
+ // Fallo silencioso: la seccion de intrusos no es critica.
1707
+ }
1708
+ }
1709
+
1710
+
1711
+ /* salud ------------------------------------------------------------------- */
1712
+
1713
+ /* Un servicio que se muere cambia un punto de color y nada mas. Si la pestaña
1714
+ * esta de fondo, que es donde vive esta herramienta, no te enteras hasta que el
1715
+ * navegador te tira un ERR_CONNECTION_REFUSED diez minutos despues.
1716
+ *
1717
+ * `/api/health` mira todas las sesiones y no la pagina actual: con mas de
1718
+ * cuatro proyectos, alimentar esto de `/api/state` seria una mentira
1719
+ * silenciosa. */
1720
+
1721
+ // Servicios caidos en el sondeo anterior, para avisar solo de los nuevos: sin
1722
+ // esto, uno caido notifica 24 veces por minuto.
1723
+ let fallen = new Set();
1724
+ // El primer sondeo no notifica. Al cargar la pagina con algo ya caido, la
1725
+ // noticia es vieja y el usuario no la pidio.
1726
+ let healthKnown = false;
1727
+
1728
+ async function refreshHealth() {
1729
+ let data;
1730
+ try {
1731
+ data = await api("/api/health");
1732
+ } catch {
1733
+ return; // sin conexion ya lo dice el masthead
1734
+ }
1735
+
1736
+ // `service: null` es el stack entero, no un servicio suelto.
1737
+ const clave = (f) => `${f.project}/${f.service ?? ""}`;
1738
+ const ahora = new Set(data.fallen.map(clave));
1739
+ const nuevos = data.fallen.filter((f) => !fallen.has(clave(f)));
1740
+ fallen = ahora;
1741
+
1742
+ document.title = ahora.size ? `(${ahora.size}) ${TITLE}` : TITLE;
1743
+ ui.health.hidden = !ahora.size;
1744
+ ui.health.textContent = ahora.size === 1 ? "1 caído" : `${ahora.size} caídos`;
1745
+
1746
+ const puedePedirse = "Notification" in window && Notification.permission === "default";
1747
+ ui.notify.hidden = !ahora.size || !puedePedirse;
1748
+ if (!ui.notify.hidden) {
1749
+ ui.notify.textContent = "Avisarme al caer";
1750
+ ui.notify.title = "Activar notificaciones de escritorio para servicios caídos";
1751
+ }
1752
+
1753
+ if (healthKnown && nuevos.length && window.Notification?.permission === "granted") {
1754
+ for (const caido of nuevos) {
1755
+ const que = caido.service ? `${caido.stack}: ${caido.service}` : caido.stack;
1756
+ new Notification(`${que} se cayó`, {
1757
+ body: "StackHelx no lo apagó, se murió solo.",
1758
+ tag: clave(caido), // el navegador tambien deduplica
1759
+ });
1760
+ }
1761
+ }
1762
+ healthKnown = true;
1763
+ }
1764
+
1765
+ ui.notify.addEventListener("click", async () => {
1766
+ // El permiso se pide con un click y nunca al cargar: un pedido de
1767
+ // notificaciones que aparece solo es lo que hace que la gente lo deniegue
1768
+ // para siempre.
1769
+ if (!("Notification" in window)) {
1770
+ flash("Tu navegador no soporta notificaciones de escritorio");
1771
+ return;
1772
+ }
1773
+ const perm = await Notification.requestPermission();
1774
+ if (perm === "granted") {
1775
+ flash("Notificaciones de escritorio activadas");
1776
+ ui.notify.hidden = true;
1777
+ } else if (perm === "denied") {
1778
+ flash("Permiso de notificaciones denegado en el navegador");
1779
+ ui.notify.hidden = true;
1780
+ }
1781
+ });
1782
+
1783
+ const ORPHAN_EVERY = 4; // cada N ciclos de POLL_MS
1784
+ let orphanTick = 0;
1785
+ let refreshAbortController = null;
1786
+
1787
+ async function refresh() {
1788
+ if (refreshAbortController) {
1789
+ refreshAbortController.abort();
1790
+ }
1791
+ refreshAbortController = new AbortController();
1792
+ const signal = refreshAbortController.signal;
1793
+ try {
1794
+ const params = new URLSearchParams({ page: String(page) });
1795
+ if (query) params.set("q", query);
1796
+ if (statusFilter) params.set("status", statusFilter);
1797
+ const data = await api(`/api/state?${params}`, { signal });
1798
+ renderTunnels(data.tunnels || []);
1799
+ render(data.projects, data);
1800
+ const n = data.registered;
1801
+ ui.connection.textContent = `${n} ${n === 1 ? "proyecto" : "proyectos"}`;
1802
+ ui.connection.dataset.down = "false";
1803
+ } catch (error) {
1804
+ if (error.name === "AbortError") return;
1805
+ ui.connection.textContent = `sin conexión · ${error.message}`;
1806
+ ui.connection.dataset.down = "true";
1807
+ }
1808
+ await refreshHealth();
1809
+ orphanTick++;
1810
+ if (orphanTick % ORPHAN_EVERY === 1) await refreshOrphans();
1811
+ }
1812
+
1813
+ /* explorador de carpetas -------------------------------------------------- */
1814
+
1815
+ /* La ruta absoluta la pone el servidor: el navegador no la conoce y no la puede
1816
+ * conocer. Cada click pide el listado de una carpeta y nada mas. */
1817
+
1818
+ let here = { path: "", parent: null, markers: [] };
1819
+ let historyStack = [];
1820
+
1821
+ async function browseTo(path, isHistoryAction = false) {
1822
+ const from = here.path;
1823
+
1824
+ let data;
1825
+ try {
1826
+ data = await api(`/api/browse?path=${encodeURIComponent(path)}`);
1827
+ } catch (error) {
1828
+ // Una ruta mala escrita a mano no puede dejar el dialogo en blanco: se cae
1829
+ // a las raices y recien despues se muestra el aviso, que si no lo tapa. El
1830
+ // salto a las raices es del historial: si no, Volver traeria de vuelta la
1831
+ // ruta que acaba de fallar.
1832
+ if (path) await browseTo("", true);
1833
+ ui.pickerNote.textContent = error.message;
1834
+ ui.pickerNote.hidden = false;
1835
+ return;
1836
+ }
1837
+
1838
+ // El historial se anota recien cuando la navegacion salio bien, y contra la
1839
+ // ruta que devolvio el servidor: es la normalizada, la que Volver puede
1840
+ // pedir de nuevo.
1841
+ if (!isHistoryAction && from !== data.path) {
1842
+ historyStack.push(from);
1843
+ }
1844
+
1845
+ here = data;
1846
+ ui.pickerPath.textContent = data.path || "Elegí dónde empezar";
1847
+ ui.pickerNote.hidden = !data.truncated;
1848
+ if (data.truncated) {
1849
+ ui.pickerNote.textContent = `Se muestran las primeras ${data.entries.length} carpetas.`;
1850
+ }
1851
+
1852
+ const backBtn = ui.picker.querySelector('[data-picker="back"]');
1853
+ if (backBtn) backBtn.disabled = historyStack.length === 0;
1854
+
1855
+ ui.picker.querySelector('[data-picker="up"]').disabled = data.parent === null;
1856
+ ui.picker.querySelector('[data-picker="pick"]').disabled = !data.path;
1857
+
1858
+ ui.pickerList.replaceChildren(...data.entries.map(entryRow));
1859
+ if (!data.entries.length) {
1860
+ const empty = document.createElement("li");
1861
+ empty.className = "picker__empty";
1862
+ empty.textContent = "Sin subcarpetas visibles.";
1863
+ ui.pickerList.append(empty);
1864
+ }
1865
+ ui.pickerList.scrollTop = 0;
1866
+ }
1867
+
1868
+ function entryRow(entry) {
1869
+ const item = document.createElement("li");
1870
+ const row = document.createElement("button");
1871
+ row.type = "button";
1872
+ row.className = "picker__row";
1873
+ row.dataset.path = entry.path;
1874
+
1875
+ const name = document.createElement("span");
1876
+ name.className = "picker__name";
1877
+ name.textContent = entry.name;
1878
+ row.append(name);
1879
+
1880
+ if (entry.markers.length) {
1881
+ const tag = document.createElement("span");
1882
+ tag.className = "picker__markers";
1883
+ tag.textContent = entry.markers.join(" · ");
1884
+ row.append(tag);
1885
+ }
1886
+
1887
+ row.addEventListener("click", () => browseTo(entry.path));
1888
+ item.append(row);
1889
+ return item;
1890
+ }
1891
+
1892
+ /* buscador y paginado ----------------------------------------------------- */
1893
+
1894
+ let searchTimer = null;
1895
+
1896
+ ui.search.addEventListener("input", () => {
1897
+ // Sin esperar, cada tecla dispara un escaneo de puertos en el servidor.
1898
+ clearTimeout(searchTimer);
1899
+ searchTimer = setTimeout(() => {
1900
+ query = ui.search.value.trim();
1901
+ page = 1;
1902
+ refresh();
1903
+ }, 200);
1904
+ });
1905
+
1906
+ ui.pager.addEventListener("click", (event) => {
1907
+ const move = event.target.dataset.page;
1908
+ if (!move) return;
1909
+ page = move === "next" ? page + 1 : Math.max(1, page - 1);
1910
+ refresh();
1911
+ });
1912
+
1913
+ const filterChips = document.getElementById("filter-chips");
1914
+ if (filterChips) {
1915
+ filterChips.addEventListener("click", (event) => {
1916
+ const btn = event.target.closest("button[data-status]");
1917
+ if (!btn) return;
1918
+ statusFilter = btn.dataset.status;
1919
+ for (const chip of filterChips.querySelectorAll(".chip")) {
1920
+ chip.classList.toggle("chip--active", chip === btn);
1921
+ }
1922
+ page = 1;
1923
+ refresh();
1924
+ });
1925
+ }
1926
+
1927
+ document.addEventListener("keydown", (event) => {
1928
+ const tag = document.activeElement ? document.activeElement.tagName : "";
1929
+ const isInput = ["INPUT", "TEXTAREA", "SELECT"].includes(tag);
1930
+ if (event.key === "/" && !isInput) {
1931
+ event.preventDefault();
1932
+ ui.search.focus();
1933
+ ui.search.select();
1934
+ } else if (event.key === "Escape" && document.activeElement === ui.search) {
1935
+ ui.search.blur();
1936
+ } else if (!isInput && (event.key === "ArrowLeft" || event.key === "ArrowRight")) {
1937
+ if (ui.pager.hidden) return;
1938
+ if (event.key === "ArrowLeft" && page > 1) {
1939
+ page--;
1940
+ refresh();
1941
+ } else if (event.key === "ArrowRight") {
1942
+ page++;
1943
+ refresh();
1944
+ }
1945
+ }
1946
+ });
1947
+
1948
+ ui.browse.addEventListener("click", () => {
1949
+ historyStack = [];
1950
+ const backBtn = ui.picker.querySelector('[data-picker="back"]');
1951
+ if (backBtn) backBtn.disabled = true;
1952
+ ui.picker.showModal();
1953
+ loadFrequentRoots();
1954
+ browseTo(ui.path.value.trim() || here.path, true);
1955
+ });
1956
+
1957
+ async function loadFrequentRoots() {
1958
+ if (!ui.pickerFrequent || !ui.pickerFrequentChips) return;
1959
+ try {
1960
+ const data = await api("/api/browse/frecuentes");
1961
+ if (data && data.roots && data.roots.length > 0) {
1962
+ ui.pickerFrequentChips.replaceChildren(
1963
+ ...data.roots.map((rootPath) => {
1964
+ const chip = document.createElement("button");
1965
+ chip.type = "button";
1966
+ chip.className = "picker__chip";
1967
+ chip.textContent = rootPath;
1968
+ chip.title = `Ir a ${rootPath}`;
1969
+ chip.addEventListener("click", () => browseTo(rootPath));
1970
+ return chip;
1971
+ })
1972
+ );
1973
+ ui.pickerFrequent.hidden = false;
1974
+ } else {
1975
+ ui.pickerFrequent.hidden = true;
1976
+ }
1977
+ } catch {
1978
+ ui.pickerFrequent.hidden = true;
1979
+ }
1980
+ }
1981
+
1982
+ ui.picker.querySelector('[data-picker="close"]').addEventListener("click", () => {
1983
+ ui.picker.close();
1984
+ });
1985
+
1986
+ const osFolderBtn = ui.picker.querySelector('[data-picker="os-folder"]');
1987
+ if (osFolderBtn) {
1988
+ osFolderBtn.addEventListener("click", (event) => {
1989
+ const targetPath = here.path || ui.path.value.trim();
1990
+ if (!targetPath) {
1991
+ flash("No hay una carpeta seleccionada para abrir", "neutral");
1992
+ return;
1993
+ }
1994
+ act(event.currentTarget, async () => {
1995
+ try {
1996
+ await api("/api/open-folder", {
1997
+ method: "POST",
1998
+ body: JSON.stringify({ path: targetPath }),
1999
+ });
2000
+ flash(`Explorador abierto en ${targetPath}`, "neutral");
2001
+ } catch (err) {
2002
+ flash(`Error al abrir explorador: ${err.message}`, "bad");
2003
+ }
2004
+ });
2005
+ });
2006
+ }
2007
+
2008
+ const backBtn = ui.picker.querySelector('[data-picker="back"]');
2009
+ if (backBtn) {
2010
+ backBtn.addEventListener("click", () => {
2011
+ if (historyStack.length > 0) {
2012
+ const prevPath = historyStack.pop();
2013
+ browseTo(prevPath, true);
2014
+ }
2015
+ });
2016
+ }
2017
+
2018
+ ui.picker.querySelector('[data-picker="up"]').addEventListener("click", () => {
2019
+ if (here.parent !== null) browseTo(here.parent);
2020
+ });
2021
+
2022
+ ui.picker.querySelector('[data-picker="pick"]').addEventListener("click", (event) => {
2023
+ const chosen = here.path;
2024
+ if (!chosen) return;
2025
+ act(event.currentTarget, async () => {
2026
+ await api("/api/projects", { method: "POST", body: JSON.stringify({ path: chosen }) });
2027
+ ui.picker.close();
2028
+ ui.path.value = "";
2029
+ });
2030
+ });
2031
+
2032
+ /* drag and drop en zona de registro ---------------------------------------- */
2033
+
2034
+ if (ui.enroll) {
2035
+ ui.enroll.addEventListener("dragover", (e) => {
2036
+ e.preventDefault();
2037
+ ui.enroll.classList.add("enroll--dragover");
2038
+ });
2039
+
2040
+ ui.enroll.addEventListener("dragleave", (e) => {
2041
+ if (!ui.enroll.contains(e.relatedTarget)) {
2042
+ ui.enroll.classList.remove("enroll--dragover");
2043
+ }
2044
+ });
2045
+
2046
+ ui.enroll.addEventListener("drop", async (e) => {
2047
+ e.preventDefault();
2048
+ ui.enroll.classList.remove("enroll--dragover");
2049
+
2050
+ const items = e.dataTransfer.items;
2051
+ let droppedName = "";
2052
+ if (items && items.length > 0) {
2053
+ const item = items[0];
2054
+ const entry = item.webkitGetAsEntry ? item.webkitGetAsEntry() : null;
2055
+ if (entry) {
2056
+ droppedName = entry.name;
2057
+ }
2058
+ }
2059
+ if (!droppedName && e.dataTransfer.files.length > 0) {
2060
+ droppedName = e.dataTransfer.files[0].name;
2061
+ }
2062
+
2063
+ if (droppedName) {
2064
+ // Comparar contra rutas frecuentes
2065
+ try {
2066
+ const freq = await api("/api/browse/frecuentes");
2067
+ if (freq && freq.roots) {
2068
+ for (const r of freq.roots) {
2069
+ const sep = r.includes("\\") ? "\\" : "/";
2070
+ const candidate = `${r.replace(/[\\/]+$/, "")}${sep}${droppedName}`;
2071
+ try {
2072
+ const test = await api(`/api/browse?path=${encodeURIComponent(candidate)}`);
2073
+ if (test && test.path) {
2074
+ ui.path.value = test.path;
2075
+ flash(`Ruta asignada: ${test.path}`, "good");
2076
+ return;
2077
+ }
2078
+ } catch {
2079
+ // No era esta raiz, probar siguiente
2080
+ }
2081
+ }
2082
+ }
2083
+ } catch {
2084
+ // Continuar a fallback
2085
+ }
2086
+
2087
+ // Si no se resolvio con raices frecuentes, abrir picker y sugerir nombre
2088
+ ui.picker.showModal();
2089
+ loadFrequentRoots();
2090
+ browseTo("", true);
2091
+ flash(`Arrastraste "${droppedName}". Selecciona su carpeta padre.`, "neutral");
2092
+ }
2093
+ });
2094
+ }
2095
+
2096
+
2097
+ /* autocompletado no intrusivo en el registro ----------------------------- */
2098
+
2099
+ let pathDebounceTimer = null;
2100
+ if (ui.path && ui.pathSuggestions) {
2101
+ ui.path.addEventListener("input", () => {
2102
+ clearTimeout(pathDebounceTimer);
2103
+ const val = ui.path.value.trim();
2104
+ if (val.length < 3) {
2105
+ ui.pathSuggestions.replaceChildren();
2106
+ return;
2107
+ }
2108
+
2109
+ pathDebounceTimer = setTimeout(async () => {
2110
+ const sep = val.includes("\\") ? "\\" : "/";
2111
+ const lastSepIdx = val.lastIndexOf(sep);
2112
+ if (lastSepIdx === -1) return;
2113
+
2114
+ const parentDir = val.slice(0, lastSepIdx) || sep;
2115
+ const prefix = val.slice(lastSepIdx + 1).toLowerCase();
2116
+
2117
+ try {
2118
+ const data = await api(`/api/browse?path=${encodeURIComponent(parentDir)}`);
2119
+ if (!data || !data.entries) return;
2120
+
2121
+ const matches = data.entries.filter((entry) =>
2122
+ entry.name.toLowerCase().startsWith(prefix)
2123
+ );
2124
+
2125
+ ui.pathSuggestions.replaceChildren(
2126
+ ...matches.map((m) => {
2127
+ const opt = document.createElement("option");
2128
+ opt.value = m.path;
2129
+ return opt;
2130
+ })
2131
+ );
2132
+ } catch {
2133
+ // Silencioso: mientras se tipea una ruta parcial es normal que no exista aun
2134
+ }
2135
+ }, 150);
2136
+ });
2137
+ }
2138
+
2139
+ /* importacion de proyectos en JSON --------------------------------------- */
2140
+
2141
+ const btnImport = document.getElementById("btn-import");
2142
+ const fileImport = document.getElementById("file-import");
2143
+
2144
+ if (btnImport && fileImport) {
2145
+ btnImport.addEventListener("click", () => {
2146
+ fileImport.click();
2147
+ });
2148
+ fileImport.addEventListener("change", async (e) => {
2149
+ const file = e.target.files[0];
2150
+ if (!file) return;
2151
+ try {
2152
+ const text = await file.text();
2153
+ const pathsList = JSON.parse(text);
2154
+ if (!Array.isArray(pathsList)) throw new Error("El archivo debe ser una lista JSON de rutas");
2155
+ const res = await api("/api/projects/import", {
2156
+ method: "POST",
2157
+ body: JSON.stringify(pathsList),
2158
+ });
2159
+ flash(`Importados ${res.count} proyectos.`, "good");
2160
+ refresh();
2161
+ } catch (err) {
2162
+ flash(`Fallo al importar: ${err.message}`, "bad");
2163
+ } finally {
2164
+ fileImport.value = "";
2165
+ }
2166
+ });
2167
+ }
2168
+
2169
+ ui.enroll.addEventListener("submit", (event) => {
2170
+ event.preventDefault();
2171
+ const button = ui.enroll.querySelector("button");
2172
+ const path = ui.path.value.trim();
2173
+ if (!path) return;
2174
+ act(button, async () => {
2175
+ await api("/api/projects", {
2176
+ method: "POST",
2177
+ body: JSON.stringify({ path }),
2178
+ });
2179
+ ui.path.value = "";
2180
+ if (ui.pathSuggestions) ui.pathSuggestions.replaceChildren();
2181
+ });
2182
+ });
2183
+
2184
+ /* mapa de puertos modal --------------------------------------------------- */
2185
+
2186
+ if (ui.btnPortsModal && ui.portsModal) {
2187
+ ui.btnPortsModal.addEventListener("click", () => {
2188
+ ui.portsModal.showModal();
2189
+ refreshPortsModal();
2190
+ });
2191
+ const closeBtn = ui.portsModal.querySelector('[data-ports-modal="close"]');
2192
+ if (closeBtn) {
2193
+ closeBtn.addEventListener("click", () => {
2194
+ ui.portsModal.close();
2195
+ });
2196
+ }
2197
+ }
2198
+
2199
+ async function refreshPortsModal() {
2200
+ if (!ui.portsModalList) return;
2201
+ try {
2202
+ const [stateData, orphansData] = await Promise.all([
2203
+ api("/api/state?size=50"),
2204
+ api("/api/ports/orphans"),
2205
+ ]);
2206
+
2207
+ const items = [];
2208
+ for (const project of stateData.projects || []) {
2209
+ for (const service of project.services || []) {
2210
+ if (service.port) {
2211
+ items.push({
2212
+ port: service.port,
2213
+ label: `${project.name} · ${service.name}`,
2214
+ kind: service.state === "ready" ? "corriendo" : "detenido",
2215
+ openable: service.openable,
2216
+ url: service.url,
2217
+ });
2218
+ }
2219
+ }
2220
+ }
2221
+
2222
+ for (const orphan of orphansData.orphans || []) {
2223
+ items.push({
2224
+ port: orphan.port,
2225
+ // El mismo fallback que la lista de intrusos: sin esto, un intruso cuyo
2226
+ // puerto no reclama nadie quedaba en "ocupa el puerto de " y se cortaba.
2227
+ label: `${orphan.name} ocupa el puerto de ${
2228
+ (orphan.projects || []).join(" y ") || "un proyecto registrado"
2229
+ }`,
2230
+ kind: "intruso",
2231
+ isOrphan: true,
2232
+ });
2233
+ }
2234
+
2235
+ items.sort((a, b) => a.port - b.port);
2236
+
2237
+ if (items.length === 0) {
2238
+ const empty = document.createElement("li");
2239
+ empty.className = "orphan orphan--empty";
2240
+ empty.textContent = "Sin puertos asignados ni intrusos";
2241
+ ui.portsModalList.replaceChildren(empty);
2242
+ return;
2243
+ }
2244
+
2245
+ ui.portsModalList.replaceChildren(
2246
+ ...items.map((item) => {
2247
+ const li = document.createElement("li");
2248
+ li.className = "orphan";
2249
+
2250
+ const portTag = document.createElement("span");
2251
+ portTag.className = "orphan__port";
2252
+ portTag.textContent = `:${item.port}`;
2253
+
2254
+ const info = document.createElement("div");
2255
+ info.className = "orphan__info";
2256
+
2257
+ const name = document.createElement("div");
2258
+ name.className = "orphan__name";
2259
+ name.textContent = item.label;
2260
+
2261
+ const meta = document.createElement("div");
2262
+ meta.className = "orphan__meta";
2263
+ meta.textContent = `Estado: ${item.kind}`;
2264
+
2265
+ info.append(name, meta);
2266
+
2267
+ if (item.openable) {
2268
+ const actLink = document.createElement("a");
2269
+ actLink.className = "btn btn--open";
2270
+ actLink.target = "_blank";
2271
+ actLink.rel = "noopener noreferrer";
2272
+ actLink.href = abrirUrl(item);
2273
+ actLink.textContent = "Abrir ↗";
2274
+ li.append(portTag, info, actLink);
2275
+ } else if (item.isOrphan) {
2276
+ const killBtn = document.createElement("button");
2277
+ killBtn.className = "orphan__kill";
2278
+ killBtn.textContent = "Cerrar";
2279
+ killBtn.type = "button";
2280
+ killBtn.addEventListener("click", () => {
2281
+ act(killBtn, async () => {
2282
+ await api(`/api/ports/${item.port}/kill`, { method: "POST" });
2283
+ await refreshPortsModal();
2284
+ });
2285
+ });
2286
+ li.append(portTag, info, killBtn);
2287
+ } else {
2288
+ li.append(portTag, info);
2289
+ }
2290
+
2291
+ return li;
2292
+ }),
2293
+ );
2294
+ } catch {
2295
+ // Silencioso
2296
+ }
2297
+ }
2298
+
2299
+ /* mcp modal ----------------------------------------------------------------- */
2300
+
2301
+ if (ui.btnMcpModal && ui.mcpModal) {
2302
+ ui.btnMcpModal.addEventListener("click", () => {
2303
+ ui.mcpModal.showModal();
2304
+ refreshMcpModal();
2305
+ });
2306
+ const closeBtn = ui.mcpModal.querySelector('[data-mcp-modal="close"]');
2307
+ if (closeBtn) {
2308
+ closeBtn.addEventListener("click", () => {
2309
+ ui.mcpModal.close();
2310
+ });
2311
+ }
2312
+ }
2313
+
2314
+ async function refreshMcpModal() {
2315
+ if (!ui.mcpModal) return;
2316
+ try {
2317
+ const data = await api("/api/mcp/activity");
2318
+ if (ui.mcpTotalCalls) ui.mcpTotalCalls.textContent = String(data.total_calls || 0);
2319
+ if (ui.mcpQuotaUsed) {
2320
+ ui.mcpQuotaUsed.textContent = `${data.active_rate_per_min || 0}/${data.rate_limit_max || 30}`;
2321
+ }
2322
+
2323
+ if (ui.mcpBreakdown) {
2324
+ ui.mcpBreakdown.replaceChildren();
2325
+ const byTool = data.by_tool || {};
2326
+ const entries = Object.entries(byTool).sort((a, b) => b[1] - a[1]);
2327
+ for (const [tool, count] of entries) {
2328
+ const pill = document.createElement("span");
2329
+ pill.className = "mcp__pill";
2330
+ pill.textContent = `${tool}: ${count}`;
2331
+ ui.mcpBreakdown.append(pill);
2332
+ }
2333
+ }
2334
+
2335
+ if (ui.mcpTbody) {
2336
+ ui.mcpTbody.replaceChildren();
2337
+ const events = data.recent_events || [];
2338
+ if (events.length === 0) {
2339
+ const tr = document.createElement("tr");
2340
+ const td = document.createElement("td");
2341
+ td.colSpan = 4;
2342
+ td.style.textAlign = "center";
2343
+ td.style.color = "var(--color-ink-3)";
2344
+ td.textContent = "Sin llamadas registradas aún";
2345
+ tr.append(td);
2346
+ ui.mcpTbody.append(tr);
2347
+ return;
2348
+ }
2349
+ for (const ev of events) {
2350
+ const tr = document.createElement("tr");
2351
+
2352
+ const tdTime = document.createElement("td");
2353
+ const ts = (ev.timestamp || "").replace("T", " ").substring(11, 19);
2354
+ tdTime.textContent = ts;
2355
+
2356
+ const tdTool = document.createElement("td");
2357
+ tdTool.className = "mcp__tool-name";
2358
+ tdTool.textContent = ev.tool || "-";
2359
+
2360
+ const tdDur = document.createElement("td");
2361
+ tdDur.textContent = `${ev.duration_ms || 0} ms`;
2362
+
2363
+ const tdStatus = document.createElement("td");
2364
+ const badge = document.createElement("span");
2365
+ badge.className = `mcp__status-badge mcp__status-badge--${ev.status || "ok"}`;
2366
+ badge.textContent = ev.status || "ok";
2367
+ tdStatus.append(badge);
2368
+
2369
+ tr.append(tdTime, tdTool, tdDur, tdStatus);
2370
+ ui.mcpTbody.append(tr);
2371
+ }
2372
+ }
2373
+ } catch (err) {
2374
+ console.error("Error al cargar telemetría MCP:", err);
2375
+ }
2376
+ }
2377
+
2378
+ /* arranque ---------------------------------------------------------------- */
2379
+
2380
+ // Que build sirve el servidor, en el pie. Una pagina vieja servida de la cache
2381
+ // del navegador deja este hueco vacio, que es la unica senal a simple vista de
2382
+ // que lo que estas mirando no es lo que corre.
2383
+ async function showBuild() {
2384
+ const slot = document.getElementById("build");
2385
+ if (!slot) return;
2386
+ try {
2387
+ const data = await api("/api/version");
2388
+ slot.textContent = `v${data.version} · ${data.assets}`;
2389
+ } catch {
2390
+ /* sin token todavia: lo intenta el proximo arranque */
2391
+ }
2392
+ }
2393
+ showBuild();
2394
+
2395
+ // Sin esto la pagina carga en blanco y solo se puebla cuando tocas algo, porque
2396
+ // todas las demas llamadas a `refresh` viven adentro de un handler. Se perdio en
2397
+ // a252013 al reescribir el final del archivo.
2398
+ refresh();
2399
+
2400
+ // Sondear solo con la pestana a la vista. `setInterval(refresh, POLL_MS)` a
2401
+ // secas corria igual con la pestana oculta, minimizada o detras de otra
2402
+ // ventana, y ahi esta el grueso del trabajo al pedo: una pestana abierta ocho
2403
+ // horas hacia 11.520 sondeos, casi todos sin nadie mirando. Cada uno le pide al
2404
+ // servidor que resuelva cada proyecto registrado, o sea disco.
2405
+ //
2406
+ // `visibilityState` es API nativa del navegador y no hace falta nada mas: no
2407
+ // hay boton que apretar ni preferencia que guardar, y el que deja la pestana
2408
+ // abierta de fondo no tiene que acordarse de nada.
2409
+ //
2410
+ // Al volver se refresca en el acto, antes de esperar el intervalo: si no, la
2411
+ // pagina mostraba el estado de hace horas durante los primeros 2.5 segundos, y
2412
+ // eso en una herramienta que dice que esta corriendo ahora es peor que nada.
2413
+ let pollTimer = setInterval(() => {
2414
+ if (document.visibilityState === "visible") refresh();
2415
+ }, POLL_MS);
2416
+
2417
+ document.addEventListener("visibilitychange", () => {
2418
+ if (document.visibilityState === "visible") {
2419
+ clearInterval(pollTimer);
2420
+ refresh();
2421
+ pollTimer = setInterval(() => {
2422
+ if (document.visibilityState === "visible") refresh();
2423
+ }, POLL_MS);
2424
+ }
2425
+ });