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/__init__.py +2 -0
- stackhelx/__main__.py +4 -0
- stackhelx/browse.py +160 -0
- stackhelx/cli.py +1165 -0
- stackhelx/config.py +448 -0
- stackhelx/detect.py +1053 -0
- stackhelx/docker.py +202 -0
- stackhelx/doctor.py +356 -0
- stackhelx/guardrails.py +42 -0
- stackhelx/history.py +86 -0
- stackhelx/mcp.py +467 -0
- stackhelx/ports.py +392 -0
- stackhelx/registry.py +356 -0
- stackhelx/runner.py +877 -0
- stackhelx/scripts.py +123 -0
- stackhelx/server.py +1786 -0
- stackhelx/tunnel.py +207 -0
- stackhelx/web/app.css +1729 -0
- stackhelx/web/app.js +2425 -0
- stackhelx/web/index.html +378 -0
- stackhelx/web/tokens.css +104 -0
- stackhelx-1.0.0.dist-info/METADATA +323 -0
- stackhelx-1.0.0.dist-info/RECORD +26 -0
- stackhelx-1.0.0.dist-info/WHEEL +4 -0
- stackhelx-1.0.0.dist-info/entry_points.txt +3 -0
- stackhelx-1.0.0.dist-info/licenses/LICENSE +21 -0
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, "<")
|
|
237
|
+
.replace(/>/g, ">")
|
|
238
|
+
.replace(/"/g, """)
|
|
239
|
+
.replace(/'/g, "'");
|
|
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
|
+
});
|