@kwirthmagnify/kwirth-plugin-status 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/back.js +68 -6
  2. package/front.js +363 -22
  3. package/package.json +1 -1
package/back.js CHANGED
@@ -45,6 +45,18 @@ __export(index_exports, {
45
45
  });
46
46
  module.exports = __toCommonJS(index_exports);
47
47
  var import_kwirth_common_back = __toESM(require_kwirth_common_back(), 1);
48
+ var statsOf = (p) => {
49
+ if (!p.getStats) return void 0;
50
+ try {
51
+ const s = p.getStats();
52
+ return {
53
+ subscribers: typeof s?.subscribers === "number" ? s.subscribers : void 0,
54
+ events: typeof s?.events === "number" ? s.events : void 0
55
+ };
56
+ } catch {
57
+ return void 0;
58
+ }
59
+ };
48
60
  var StatusChannel = class {
49
61
  constructor(clusterInfo, backChannelObject) {
50
62
  this.channelId = "status";
@@ -146,8 +158,22 @@ var StatusChannel = class {
146
158
  cuántos suscriptores tiene, y ese contrato todavía no existe (llega en S2). Inventar el dato sería
147
159
  peor que no darlo — un administrador que lea "ocioso" va a ir a desinstalar algo.
148
160
  */
149
- this.healthOfProvider = (p) => {
161
+ /**
162
+ * Cuántos consumidores tiene, o undefined si no lo dice.
163
+ *
164
+ * Se protege con try/catch porque esto es código de una extensión de terceros: un provider que
165
+ * reviente al preguntarle no puede llevarse por delante la pantalla entera. Si falla, no informa.
166
+ */
167
+ this.subscribersOf = (p) => statsOf(p)?.subscribers;
168
+ this.eventsOf = (p) => statsOf(p)?.events;
169
+ this.healthOfProvider = (p, subscribers) => {
150
170
  if (p.started !== true) {
171
+ if (subscribers !== void 0 && subscribers > 0) {
172
+ return {
173
+ health: "not-instantiated" /* NOT_INSTANTIATED */,
174
+ reason: `${subscribers} subscriber${subscribers > 1 ? "s" : ""} waiting for data that will never arrive: no channel declares it in its requirements`
175
+ };
176
+ }
151
177
  return {
152
178
  health: "not-instantiated" /* NOT_INSTANTIATED */,
153
179
  // El core solo instancia los providers que algún canal declara en sus requirements.
@@ -160,20 +186,43 @@ var StatusChannel = class {
160
186
  reason: "Its configuration endpoint is not mounted \u2014 the server has not been restarted since it was installed"
161
187
  };
162
188
  }
163
- return { health: "instantiated" /* INSTANTIATED */ };
189
+ if (subscribers === void 0) return { health: "instantiated" /* INSTANTIATED */ };
190
+ if (subscribers > 0) return { health: "active" /* ACTIVE */ };
191
+ return {
192
+ health: "idle" /* IDLE */,
193
+ reason: "Running, but nothing is consuming it right now"
194
+ };
164
195
  };
165
196
  this.buildInventory = () => {
166
197
  const components = [];
198
+ const edges = this.subscriptionsOf();
199
+ const conocidos = /* @__PURE__ */ new Map();
200
+ for (const e of edges) conocidos.set(e.providerId, (conocidos.get(e.providerId) ?? 0) + 1);
167
201
  for (const p of this.clusterInfo.providers ?? []) {
168
- const { health, reason } = this.healthOfProvider(p);
169
- components.push({ kind: "provider" /* PROVIDER */, id: p.id, displayName: p.id, health, ...reason ? { reason } : {} });
202
+ const subscribers = this.subscribersOf(p);
203
+ const eventos = this.eventsOf(p);
204
+ const { health, reason } = this.healthOfProvider(p, subscribers);
205
+ components.push({
206
+ kind: "provider" /* PROVIDER */,
207
+ id: p.id,
208
+ displayName: p.id,
209
+ health,
210
+ ...reason ? { reason } : {},
211
+ ...subscribers === void 0 ? {} : { subscribers },
212
+ ...eventos === void 0 ? {} : { events: eventos },
213
+ knownConsumers: conocidos.get(p.id) ?? 0
214
+ });
170
215
  }
171
216
  for (const pluviderId of (this.clusterInfo.pluviders ?? /* @__PURE__ */ new Map()).keys()) {
217
+ const suyas = conocidos.get(pluviderId) ?? 0;
172
218
  components.push({
173
219
  kind: "pluvider" /* PLUVIDER */,
174
220
  id: pluviderId,
175
221
  displayName: pluviderId,
176
- health: "instantiated" /* INSTANTIATED */
222
+ health: suyas > 0 ? "active" /* ACTIVE */ : "idle" /* IDLE */,
223
+ ...suyas > 0 ? {} : { reason: "Running, but nothing is consuming it right now" },
224
+ subscribers: suyas,
225
+ knownConsumers: suyas
177
226
  });
178
227
  }
179
228
  for (const s of this.clusterInfo.senders?.listSenders() ?? []) {
@@ -197,9 +246,22 @@ var StatusChannel = class {
197
246
  return {
198
247
  cluster: this.clusterInfo.name ?? "",
199
248
  takenAt: Date.now(),
200
- components
249
+ components,
250
+ edges
201
251
  };
202
252
  };
253
+ /**
254
+ * Las aristas que el core conoce. Protegido igual que getStats: si el core es anterior a esto o
255
+ * revienta, se devuelve vacío y la pantalla enseña el inventario sin grafo.
256
+ */
257
+ this.subscriptionsOf = () => {
258
+ if (!this.clusterInfo.getSubscriptions) return [];
259
+ try {
260
+ return this.clusterInfo.getSubscriptions() ?? [];
261
+ } catch {
262
+ return [];
263
+ }
264
+ };
203
265
  this.sendInventory = (socket, instanceId) => {
204
266
  const msg = {
205
267
  msgtype: "statusmessageresponse",
package/front.js CHANGED
@@ -76,8 +76,18 @@
76
76
  }
77
77
  });
78
78
 
79
+ // kwirth-globals:@xyflow/react
80
+ var require_react2 = __commonJS({
81
+ "kwirth-globals:@xyflow/react"(exports, module) {
82
+ var _m = window.__kwirth__.reactFlow;
83
+ var _d = _m != null && "default" in Object(_m) ? _m.default : _m;
84
+ if (typeof _d !== "function" && _d != null && typeof _d.default !== "undefined") _d = _d.default;
85
+ module.exports = Object.assign({}, typeof _m === "object" && _m !== null ? _m : {}, { default: _d, __esModule: true });
86
+ }
87
+ });
88
+
79
89
  // src/front/StatusChannel.ts
80
- var import_react3 = __toESM(require_react(), 1);
90
+ var import_react5 = __toESM(require_react(), 1);
81
91
  var import_kwirth_common2 = __toESM(require_kwirth_common(), 1);
82
92
  var import_kwirth_common_front = __toESM(require_kwirth_common_front(), 1);
83
93
 
@@ -89,6 +99,9 @@
89
99
  // src/front/StatusData.ts
90
100
  var StatusData = class {
91
101
  constructor() {
102
+ this.view = "table";
103
+ this.filter = "";
104
+ this.autoRefresh = 0;
92
105
  this.signals = [];
93
106
  this.configAccepted = false;
94
107
  this.started = false;
@@ -96,11 +109,231 @@
96
109
  };
97
110
 
98
111
  // src/front/StatusTabContent.tsx
99
- var import_react2 = __toESM(require_react(), 1);
100
- var import_material2 = __toESM(require_material(), 1);
112
+ var import_react4 = __toESM(require_react(), 1);
113
+ var import_material3 = __toESM(require_material(), 1);
101
114
  var import_icons = __toESM(require_icons(), 1);
102
115
  var import_kwirth_common = __toESM(require_kwirth_common(), 1);
116
+
117
+ // src/front/StatusDiagram.tsx
118
+ var import_react2 = __toESM(require_react(), 1);
119
+ var import_material2 = __toESM(require_material(), 1);
120
+ var import_react3 = __toESM(require_react2(), 1);
121
+ var COLOR = {
122
+ ["active" /* ACTIVE */]: "#2e7d32",
123
+ ["idle" /* IDLE */]: "#616161",
124
+ ["instantiated" /* INSTANTIATED */]: "#2e7d32",
125
+ ["not-instantiated" /* NOT_INSTANTIATED */]: "#ed6c02",
126
+ ["pending-restart" /* PENDING_RESTART */]: "#ed6c02",
127
+ ["failed" /* FAILED */]: "#d32f2f",
128
+ ["unknown" /* UNKNOWN */]: "#616161"
129
+ };
130
+ var colocar = async (nodos, aristas) => {
131
+ const filas = () => {
132
+ const pos = {};
133
+ const productores = nodos.filter((n) => n.data.esProductor);
134
+ const consumidores = nodos.filter((n) => !n.data.esProductor);
135
+ productores.forEach((n, i) => {
136
+ pos[n.id] = { x: i * 260, y: 0 };
137
+ });
138
+ consumidores.forEach((n, i) => {
139
+ pos[n.id] = { x: i * 260, y: 220 };
140
+ });
141
+ return pos;
142
+ };
143
+ const loadElk = window.__kwirth__?.loadElk;
144
+ if (!loadElk) return filas();
145
+ try {
146
+ const ELK = await loadElk();
147
+ const elk = new ELK();
148
+ const g = await elk.layout({
149
+ id: "root",
150
+ layoutOptions: {
151
+ "elk.algorithm": "layered",
152
+ /*
153
+ De arriba abajo: los productores en la capa de arriba y los consumidores debajo.
154
+ Se lee como un diagrama de flujo —el dato cae— y aprovecha el ancho de la pantalla,
155
+ que es donde sobra sitio cuando hay muchos nodos.
156
+ */
157
+ "elk.direction": "DOWN",
158
+ "elk.spacing.nodeNode": "40",
159
+ "elk.layered.spacing.nodeNodeBetweenLayers": "110"
160
+ },
161
+ children: nodos.map((n) => ({ id: n.id, width: 230, height: 56 })),
162
+ edges: aristas.map((e) => ({ id: e.id, sources: [e.source], targets: [e.target] }))
163
+ });
164
+ const pos = {};
165
+ for (const c of g.children ?? []) pos[c.id] = { x: c.x, y: c.y };
166
+ return Object.keys(pos).length === nodos.length ? pos : filas();
167
+ } catch {
168
+ return filas();
169
+ }
170
+ };
171
+ var StatusDiagram = ({ inventory, active }) => {
172
+ const theme = (0, import_material2.useTheme)();
173
+ const [posiciones, setPosiciones] = import_react2.default.useState(void 0);
174
+ const [seleccionado, setSeleccionado] = import_react2.default.useState(void 0);
175
+ const colores = {
176
+ fondoNodo: theme.palette.background.paper,
177
+ fondoCanal: theme.palette.mode === "dark" ? "#12233a" : "#e8f0fb",
178
+ texto: theme.palette.text.primary,
179
+ bordeCanal: theme.palette.primary.main
180
+ };
181
+ const { nodos, aristas, canalesSueltos } = import_react2.default.useMemo(() => {
182
+ const productores = inventory.components.filter((c) => c.kind === "provider" /* PROVIDER */ || c.kind === "pluvider" /* PLUVIDER */);
183
+ const idsProductores = new Set(productores.map((p) => p.id));
184
+ const canales = [...new Set(inventory.edges.map((e) => e.channelId))];
185
+ const vecinos = /* @__PURE__ */ new Set();
186
+ if (seleccionado) {
187
+ vecinos.add(seleccionado);
188
+ for (const e of inventory.edges) {
189
+ const origen = e.providerId;
190
+ const destino = `channel:${e.channelId}`;
191
+ if (origen === seleccionado) vecinos.add(destino);
192
+ if (destino === seleccionado) vecinos.add(origen);
193
+ }
194
+ }
195
+ const apagado = (id) => !seleccionado || vecinos.has(id) ? 1 : 0.25;
196
+ const nodos2 = [
197
+ ...productores.map((p) => ({
198
+ id: p.id,
199
+ position: { x: 0, y: 0 },
200
+ data: { label: p.displayName, esProductor: true, health: p.health, subscribers: p.subscribers, known: p.knownConsumers },
201
+ // Con el grafo en vertical, la arista tiene que salir por ABAJO y entrar por ARRIBA; si
202
+ // no, React Flow las saca por los lados y los cables dan un rodeo absurdo.
203
+ sourcePosition: import_react3.Position.Bottom,
204
+ targetPosition: import_react3.Position.Top,
205
+ style: {
206
+ background: colores.fondoNodo,
207
+ color: colores.texto,
208
+ border: `${seleccionado === p.id ? 3 : 2}px solid ${COLOR[p.health]}`,
209
+ borderRadius: 8,
210
+ width: 230,
211
+ fontSize: 12,
212
+ padding: 8,
213
+ opacity: apagado(p.id),
214
+ boxShadow: seleccionado === p.id ? `0 0 10px ${COLOR[p.health]}` : void 0
215
+ }
216
+ })),
217
+ ...canales.map((id) => ({
218
+ id: `channel:${id}`,
219
+ position: { x: 0, y: 0 },
220
+ data: { label: id, esProductor: false },
221
+ sourcePosition: import_react3.Position.Bottom,
222
+ targetPosition: import_react3.Position.Top,
223
+ style: {
224
+ background: colores.fondoCanal,
225
+ color: colores.texto,
226
+ border: `${seleccionado === `channel:${id}` ? 3 : 2}px solid ${colores.bordeCanal}`,
227
+ borderRadius: 8,
228
+ width: 230,
229
+ fontSize: 12,
230
+ padding: 8,
231
+ opacity: apagado(`channel:${id}`),
232
+ boxShadow: seleccionado === `channel:${id}` ? `0 0 10px ${colores.bordeCanal}` : void 0
233
+ }
234
+ }))
235
+ ];
236
+ const aristas2 = inventory.edges.map((e) => ({
237
+ id: `${e.providerId}->${e.channelId}`,
238
+ source: e.providerId,
239
+ target: `channel:${e.channelId}`,
240
+ /*
241
+ QUIETA a propósito, aunque React Flow sepa animarlas.
242
+
243
+ Una línea en movimiento se lee como "por aquí está pasando algo ahora mismo", y eso no
244
+ se sabe: lo único que dice esta arista es que la suscripción existe. Animarla sería el
245
+ mismo error que poner un 0 donde no hay dato — parecería información y sería decoración.
246
+
247
+ Cuando los contadores de S4 midan caudal de verdad, el movimiento (o el grosor) podrá
248
+ significar algo, y entonces se pone.
249
+ */
250
+ ...(() => {
251
+ const tocaAlSeleccionado = Boolean(seleccionado) && (e.providerId === seleccionado || `channel:${e.channelId}` === seleccionado);
252
+ const viva = active.has(e.providerId);
253
+ const color = tocaAlSeleccionado ? "#7fd8b0" : viva ? "#5fc79a" : "#4a8";
254
+ const ancho = tocaAlSeleccionado ? 3 : viva ? 2 : 1;
255
+ const punta = 16 / ancho;
256
+ return {
257
+ // Mismo realce que el mapa de Iter: mas grosor, sombra y por delante de las demás.
258
+ style: tocaAlSeleccionado ? { stroke: color, strokeWidth: ancho, filter: `drop-shadow(0 0 3px ${color})`, opacity: 1 } : { stroke: color, strokeWidth: ancho, opacity: seleccionado ? 0.2 : 1 },
259
+ animated: viva,
260
+ zIndex: tocaAlSeleccionado ? 1e3 : viva ? 500 : 0,
261
+ markerEnd: { type: import_react3.MarkerType.ArrowClosed, color, width: punta, height: punta }
262
+ };
263
+ })()
264
+ })).filter((e) => idsProductores.has(e.source));
265
+ const canalesSueltos2 = inventory.edges.length - aristas2.length;
266
+ return { nodos: nodos2, aristas: aristas2, canalesSueltos: canalesSueltos2 };
267
+ }, [inventory, active, seleccionado, colores.fondoNodo, colores.fondoCanal, colores.texto, colores.bordeCanal]);
268
+ const firmaGrafo = nodos.map((n) => n.id).join("|") + "#" + aristas.map((a) => a.id).join("|");
269
+ import_react2.default.useEffect(() => {
270
+ let vigente = true;
271
+ colocar(nodos, aristas).then((pos) => {
272
+ if (vigente) setPosiciones(pos);
273
+ });
274
+ return () => {
275
+ vigente = false;
276
+ };
277
+ }, [firmaGrafo]);
278
+ if (inventory.edges.length === 0) {
279
+ return /* @__PURE__ */ import_react2.default.createElement(import_material2.Box, { sx: { p: 3 } }, /* @__PURE__ */ import_react2.default.createElement(import_material2.Typography, { variant: "body2", color: "text.secondary" }, "Nothing is subscribed to anything right now, so there is no graph to draw."));
280
+ }
281
+ if (!posiciones) {
282
+ return /* @__PURE__ */ import_react2.default.createElement(import_material2.Box, { sx: { p: 3 } }, /* @__PURE__ */ import_react2.default.createElement(import_material2.Typography, { variant: "body2", color: "text.secondary" }, "Laying out the graph\u2026"));
283
+ }
284
+ const colocados = nodos.map((n) => ({ ...n, position: posiciones[n.id] ?? { x: 0, y: 0 } }));
285
+ const anonimos = inventory.components.reduce((n, c) => {
286
+ if (c.subscribers === void 0 || c.knownConsumers === void 0) return n;
287
+ return n + Math.max(0, c.subscribers - c.knownConsumers);
288
+ }, 0);
289
+ return /* @__PURE__ */ import_react2.default.createElement(import_material2.Box, { sx: { height: "100%", display: "flex", flexDirection: "column", minHeight: 0 } }, /* @__PURE__ */ import_react2.default.createElement(import_material2.Stack, { direction: "row", spacing: 1, alignItems: "center", sx: { mb: 1 } }, /* @__PURE__ */ import_react2.default.createElement(import_material2.Typography, { variant: "caption", color: "text.secondary" }, "A line means ", /* @__PURE__ */ import_react2.default.createElement("b", null, "an active subscription"), ". A ", /* @__PURE__ */ import_react2.default.createElement("b", null, "moving line"), " means its producer is delivering right now \u2014 but not how much goes to each consumer: that is measured per component, not per line.", " ", "Click a node to highlight what it is connected to; click the background to clear.")), (anonimos > 0 || canalesSueltos > 0) && /* @__PURE__ */ import_react2.default.createElement(import_material2.Stack, { direction: "row", spacing: 1, sx: { mb: 1 } }, anonimos > 0 && /* @__PURE__ */ import_react2.default.createElement(
290
+ import_material2.Chip,
291
+ {
292
+ size: "small",
293
+ variant: "outlined",
294
+ color: "default",
295
+ label: `${anonimos} consumer${anonimos > 1 ? "s" : ""} not shown \u2014 they subscribed without going through the core`
296
+ }
297
+ ), canalesSueltos > 0 && /* @__PURE__ */ import_react2.default.createElement(import_material2.Chip, { size: "small", variant: "outlined", label: `${canalesSueltos} subscription${canalesSueltos > 1 ? "s" : ""} to something no longer installed` })), /* @__PURE__ */ import_react2.default.createElement(import_material2.Box, { sx: {
298
+ flex: 1,
299
+ minHeight: 0,
300
+ border: 1,
301
+ borderColor: "divider",
302
+ borderRadius: 1,
303
+ "& .react-flow__controls": { boxShadow: "none" },
304
+ "& .react-flow__controls-button": {
305
+ background: theme.palette.background.paper,
306
+ borderBottom: `1px solid ${theme.palette.divider}`,
307
+ fill: theme.palette.text.primary
308
+ },
309
+ "& .react-flow__controls-button:hover": { background: theme.palette.action.hover },
310
+ "& .react-flow__controls-button svg": { fill: theme.palette.text.primary },
311
+ "& .react-flow__attribution": { display: "none" }
312
+ } }, /* @__PURE__ */ import_react2.default.createElement(
313
+ import_react3.ReactFlow,
314
+ {
315
+ nodes: colocados,
316
+ edges: aristas,
317
+ fitView: true,
318
+ proOptions: { hideAttribution: true },
319
+ nodesConnectable: false,
320
+ edgesReconnectable: false,
321
+ connectOnClick: false,
322
+ deleteKeyCode: null,
323
+ onNodeClick: (_e, nodo) => setSeleccionado(nodo.id),
324
+ onPaneClick: () => setSeleccionado(void 0)
325
+ },
326
+ /* @__PURE__ */ import_react2.default.createElement(import_react3.Background, null),
327
+ /* @__PURE__ */ import_react2.default.createElement(import_react3.Controls, { showInteractive: false })
328
+ )));
329
+ };
330
+
331
+ // src/front/StatusTabContent.tsx
103
332
  var HEALTH_LABEL = {
333
+ ["active" /* ACTIVE */]: { label: "Active", color: "success" },
334
+ // Ocioso NO es un error, es información: funciona, pero no le sirve a nadie. De ahí 'default' y no
335
+ // 'warning' — quien mire tiene que poder distinguir "hay que arreglar esto" de "esto sobra".
336
+ ["idle" /* IDLE */]: { label: "Idle", color: "default" },
104
337
  ["instantiated" /* INSTANTIATED */]: { label: "Running", color: "success" },
105
338
  ["not-instantiated" /* NOT_INSTANTIATED */]: { label: "Not started", color: "warning" },
106
339
  ["pending-restart" /* PENDING_RESTART */]: { label: "Needs restart", color: "warning" },
@@ -114,12 +347,42 @@
114
347
  ["webhook" /* WEBHOOK */]: "Webhook",
115
348
  ["channel" /* CHANNEL */]: "Channel"
116
349
  };
350
+ var EmptyState = ({ title, detail }) => {
351
+ const ref = import_react4.default.useRef(null);
352
+ const [top, setTop] = import_react4.default.useState(0);
353
+ import_react4.default.useEffect(() => {
354
+ if (ref.current) setTop(ref.current.getBoundingClientRect().top);
355
+ });
356
+ return /* @__PURE__ */ import_react4.default.createElement(
357
+ import_material3.Stack,
358
+ {
359
+ ref,
360
+ alignItems: "center",
361
+ justifyContent: "center",
362
+ spacing: 1,
363
+ sx: { flex: 1, width: "100%", minHeight: `calc(100vh - ${top}px - 8px)`, px: 4, textAlign: "center" }
364
+ },
365
+ /* @__PURE__ */ import_react4.default.createElement(import_material3.Typography, { variant: "h6", color: "text.secondary" }, title),
366
+ /* @__PURE__ */ import_react4.default.createElement(import_material3.Typography, { variant: "body2", color: "text.secondary" }, detail)
367
+ );
368
+ };
117
369
  var StatusTabContent = (props) => {
118
370
  const data = props.channelObject.data;
119
- const [filter, setFilter] = import_react2.default.useState("");
120
- const boxRef = import_react2.default.useRef(null);
121
- const [boxTop, setBoxTop] = import_react2.default.useState(0);
122
- import_react2.default.useEffect(() => {
371
+ const [, forzarRender] = import_react4.default.useState(0);
372
+ const repintar = () => forzarRender((n) => n + 1);
373
+ const filter = data.filter;
374
+ const setFilter = (v) => {
375
+ data.filter = v;
376
+ repintar();
377
+ };
378
+ const vista = data.view;
379
+ const setVista = (v) => {
380
+ data.view = v;
381
+ repintar();
382
+ };
383
+ const boxRef = import_react4.default.useRef(null);
384
+ const [boxTop, setBoxTop] = import_react4.default.useState(0);
385
+ import_react4.default.useEffect(() => {
123
386
  if (boxRef.current) setBoxTop(boxRef.current.getBoundingClientRect().top);
124
387
  });
125
388
  const refresh = () => {
@@ -134,36 +397,93 @@
134
397
  command: "refresh" /* REFRESH */
135
398
  }));
136
399
  };
400
+ import_react4.default.useEffect(() => {
401
+ if (!data.autoRefresh) return;
402
+ const id = setInterval(() => refresh(), data.autoRefresh * 1e3);
403
+ return () => clearInterval(id);
404
+ }, [data.autoRefresh, props.channelObject.instanceId]);
137
405
  const inventory = data.inventory;
406
+ const tasaDe = (id, ahora) => {
407
+ if (ahora === void 0 || !data.previous || !inventory) return void 0;
408
+ const antes = data.previous.components.find((c) => c.id === id)?.events;
409
+ if (antes === void 0 || ahora < antes) return void 0;
410
+ const segundos = (inventory.takenAt - data.previous.takenAt) / 1e3;
411
+ if (segundos <= 0) return void 0;
412
+ return (ahora - antes) / segundos;
413
+ };
138
414
  const componentes = (inventory?.components ?? []).filter((c) => {
139
415
  if (!filter) return true;
140
416
  const f = filter.toLowerCase();
141
417
  return c.id.toLowerCase().includes(f) || KIND_LABEL[c.kind].toLowerCase().includes(f);
142
418
  });
143
- const ORDEN = [
144
- "failed" /* FAILED */,
145
- "pending-restart" /* PENDING_RESTART */,
146
- "not-instantiated" /* NOT_INSTANTIATED */,
147
- "unknown" /* UNKNOWN */,
148
- "instantiated" /* INSTANTIATED */
149
- ];
419
+ const activos = /* @__PURE__ */ new Set();
420
+ for (const c of inventory?.components ?? []) {
421
+ if (c.events === void 0 || !data.previous) continue;
422
+ const antes = data.previous.components.find((p) => p.id === c.id)?.events;
423
+ if (antes !== void 0 && c.events !== antes) activos.add(c.id);
424
+ }
425
+ const ORDEN = {
426
+ ["failed" /* FAILED */]: 0,
427
+ ["pending-restart" /* PENDING_RESTART */]: 1,
428
+ ["not-instantiated" /* NOT_INSTANTIATED */]: 2,
429
+ ["idle" /* IDLE */]: 3,
430
+ ["unknown" /* UNKNOWN */]: 4,
431
+ ["instantiated" /* INSTANTIATED */]: 5,
432
+ ["active" /* ACTIVE */]: 6
433
+ };
150
434
  componentes.sort((a, b) => {
151
- const d = ORDEN.indexOf(a.health) - ORDEN.indexOf(b.health);
435
+ const d = ORDEN[a.health] - ORDEN[b.health];
152
436
  if (d !== 0) return d;
153
437
  return a.kind === b.kind ? a.id.localeCompare(b.id) : a.kind.localeCompare(b.kind);
154
438
  });
155
439
  const fila = (c) => {
156
440
  const estado = HEALTH_LABEL[c.health];
157
- return /* @__PURE__ */ import_react2.default.createElement(import_material2.TableRow, { key: `${c.kind}-${c.id}` }, /* @__PURE__ */ import_react2.default.createElement(import_material2.TableCell, { sx: { whiteSpace: "nowrap" } }, KIND_LABEL[c.kind]), /* @__PURE__ */ import_react2.default.createElement(import_material2.TableCell, null, /* @__PURE__ */ import_react2.default.createElement(import_material2.Typography, { variant: "body2", sx: { fontWeight: 500 } }, c.displayName)), /* @__PURE__ */ import_react2.default.createElement(import_material2.TableCell, null, /* @__PURE__ */ import_react2.default.createElement(import_material2.Chip, { size: "small", label: estado.label, color: estado.color, variant: c.health === "instantiated" /* INSTANTIATED */ ? "outlined" : "filled" })), /* @__PURE__ */ import_react2.default.createElement(import_material2.TableCell, null, /* @__PURE__ */ import_react2.default.createElement(import_material2.Typography, { variant: "body2", color: "text.secondary" }, c.reason ?? "")));
441
+ return /* @__PURE__ */ import_react4.default.createElement(import_material3.TableRow, { key: `${c.kind}-${c.id}` }, /* @__PURE__ */ import_react4.default.createElement(import_material3.TableCell, { sx: { whiteSpace: "nowrap" } }, KIND_LABEL[c.kind]), /* @__PURE__ */ import_react4.default.createElement(import_material3.TableCell, null, /* @__PURE__ */ import_react4.default.createElement(import_material3.Typography, { variant: "body2", sx: { fontWeight: 500 } }, c.displayName)), /* @__PURE__ */ import_react4.default.createElement(import_material3.TableCell, null, /* @__PURE__ */ import_react4.default.createElement(import_material3.Chip, { size: "small", label: estado.label, color: estado.color, variant: c.health === "active" /* ACTIVE */ ? "outlined" : "filled" })), /* @__PURE__ */ import_react4.default.createElement(import_material3.TableCell, { align: "right" }, /* @__PURE__ */ import_react4.default.createElement(import_material3.Typography, { variant: "body2", sx: { fontVariantNumeric: "tabular-nums" }, color: c.subscribers === void 0 ? "text.disabled" : "text.primary" }, c.subscribers === void 0 ? "\u2014" : c.subscribers)), /* @__PURE__ */ import_react4.default.createElement(import_material3.TableCell, { align: "right" }, /* @__PURE__ */ import_react4.default.createElement(import_material3.Typography, { variant: "body2", sx: { fontVariantNumeric: "tabular-nums" }, color: c.events === void 0 ? "text.disabled" : "text.primary" }, c.events === void 0 ? "\u2014" : c.events.toLocaleString()), (() => {
442
+ const t = tasaDe(c.id, c.events);
443
+ if (t === void 0) return null;
444
+ return /* @__PURE__ */ import_react4.default.createElement(import_material3.Typography, { variant: "caption", color: "text.secondary", display: "block" }, t < 1 && t > 0 ? t.toFixed(2) : Math.round(t), "/s");
445
+ })()), /* @__PURE__ */ import_react4.default.createElement(import_material3.TableCell, null, /* @__PURE__ */ import_react4.default.createElement(import_material3.Typography, { variant: "body2", color: "text.secondary" }, c.reason ?? "")));
158
446
  };
447
+ if (!data.started) {
448
+ return /* @__PURE__ */ import_react4.default.createElement(
449
+ EmptyState,
450
+ {
451
+ title: "Kwirth Status not started",
452
+ detail: "Start the channel (tab settings \u2699 \u2192 Start) to see what this Kwirth has inside."
453
+ }
454
+ );
455
+ }
159
456
  if (!inventory) {
160
- return /* @__PURE__ */ import_react2.default.createElement(import_material2.Box, { sx: { p: 2 } }, /* @__PURE__ */ import_react2.default.createElement(import_material2.Typography, { variant: "body2", color: "text.secondary" }, "Waiting for the inventory\u2026"));
457
+ return /* @__PURE__ */ import_react4.default.createElement(
458
+ EmptyState,
459
+ {
460
+ title: "Waiting for the first snapshot",
461
+ detail: "The channel is running; the inventory should appear in a moment."
462
+ }
463
+ );
161
464
  }
162
- return /* @__PURE__ */ import_react2.default.createElement(import_material2.Box, { sx: { p: 2, display: "flex", flexDirection: "column", minHeight: 0 } }, /* @__PURE__ */ import_react2.default.createElement(import_material2.Stack, { direction: "row", alignItems: "center", spacing: 1, sx: { mb: 1 } }, /* @__PURE__ */ import_react2.default.createElement(import_material2.Typography, { variant: "subtitle2" }, "What this Kwirth has inside"), /* @__PURE__ */ import_react2.default.createElement(import_material2.Chip, { size: "small", variant: "outlined", label: `${inventory.components.length} components` }), /* @__PURE__ */ import_react2.default.createElement(import_material2.Box, { sx: { flexGrow: 1 } }), /* @__PURE__ */ import_react2.default.createElement(import_material2.TextField, { size: "small", placeholder: "Filter\u2026", value: filter, onChange: (e) => setFilter(e.target.value), sx: { width: 220 } }), /* @__PURE__ */ import_react2.default.createElement(import_material2.Tooltip, { title: "Take a new snapshot" }, /* @__PURE__ */ import_react2.default.createElement(import_material2.IconButton, { size: "small", onClick: refresh }, /* @__PURE__ */ import_react2.default.createElement(import_icons.Refresh, { fontSize: "small" })))), /* @__PURE__ */ import_react2.default.createElement(import_material2.Typography, { variant: "caption", color: "text.secondary", sx: { mb: 1 } }, "Snapshot taken at ", new Date(inventory.takenAt).toLocaleTimeString(), " \u2014 it does not refresh on its own"), /* @__PURE__ */ import_react2.default.createElement(import_material2.Box, { ref: boxRef, sx: { display: "flex", flexDirection: "column", overflowY: "auto", overflowX: "hidden", width: "100%", flexGrow: 1, height: `calc(100vh - ${boxTop}px - 35px)` } }, /* @__PURE__ */ import_react2.default.createElement(import_material2.Table, { size: "small", stickyHeader: true }, /* @__PURE__ */ import_react2.default.createElement(import_material2.TableHead, null, /* @__PURE__ */ import_react2.default.createElement(import_material2.TableRow, null, /* @__PURE__ */ import_react2.default.createElement(import_material2.TableCell, null, "Kind"), /* @__PURE__ */ import_react2.default.createElement(import_material2.TableCell, null, "Name"), /* @__PURE__ */ import_react2.default.createElement(import_material2.TableCell, null, "State"), /* @__PURE__ */ import_react2.default.createElement(import_material2.TableCell, null, "Why"))), /* @__PURE__ */ import_react2.default.createElement(import_material2.TableBody, null, componentes.map(fila)))), data.signals.length > 0 && /* @__PURE__ */ import_react2.default.createElement(import_material2.Box, { sx: { mt: 1 } }, data.signals.map((s, i) => /* @__PURE__ */ import_react2.default.createElement(import_material2.Typography, { key: i, variant: "caption", color: "error", display: "block" }, s))));
465
+ return /* @__PURE__ */ import_react4.default.createElement(import_material3.Box, { sx: { p: 2, display: "flex", flexDirection: "column", minHeight: 0 } }, /* @__PURE__ */ import_react4.default.createElement(import_material3.Stack, { direction: "row", alignItems: "center", spacing: 1, sx: { mb: 1 } }, /* @__PURE__ */ import_react4.default.createElement(import_material3.Typography, { variant: "subtitle2" }, "What this Kwirth has inside"), /* @__PURE__ */ import_react4.default.createElement(import_material3.Chip, { size: "small", variant: "outlined", label: `${inventory.components.length} components` }), /* @__PURE__ */ import_react4.default.createElement(import_material3.Box, { sx: { flexGrow: 1 } }), /* @__PURE__ */ import_react4.default.createElement(import_material3.TextField, { size: "small", placeholder: "Filter\u2026", value: filter, onChange: (e) => setFilter(e.target.value), sx: { width: 220 } }), /* @__PURE__ */ import_react4.default.createElement(import_material3.Tooltip, { title: "Table view" }, /* @__PURE__ */ import_react4.default.createElement(import_material3.IconButton, { size: "small", color: vista === "table" ? "primary" : "default", "aria-label": "Table view", onClick: () => setVista("table") }, /* @__PURE__ */ import_react4.default.createElement(import_icons.ViewList, { fontSize: "small" }))), /* @__PURE__ */ import_react4.default.createElement(import_material3.Tooltip, { title: "Graph view" }, /* @__PURE__ */ import_react4.default.createElement(import_material3.IconButton, { size: "small", color: vista === "graph" ? "primary" : "default", "aria-label": "Graph view", onClick: () => setVista("graph") }, /* @__PURE__ */ import_react4.default.createElement(import_icons.Hub, { fontSize: "small" }))), /* @__PURE__ */ import_react4.default.createElement(
466
+ import_material3.Select,
467
+ {
468
+ size: "small",
469
+ value: data.autoRefresh,
470
+ "aria-label": "Auto refresh",
471
+ onChange: (e) => {
472
+ data.autoRefresh = Number(e.target.value);
473
+ repintar();
474
+ },
475
+ sx: { minWidth: 104, "& .MuiSelect-select": { py: 0.5, fontSize: "0.8rem" } }
476
+ },
477
+ /* @__PURE__ */ import_react4.default.createElement(import_material3.MenuItem, { value: 0 }, "Manual"),
478
+ /* @__PURE__ */ import_react4.default.createElement(import_material3.MenuItem, { value: 5 }, "Every 5s"),
479
+ /* @__PURE__ */ import_react4.default.createElement(import_material3.MenuItem, { value: 15 }, "Every 15s"),
480
+ /* @__PURE__ */ import_react4.default.createElement(import_material3.MenuItem, { value: 30 }, "Every 30s"),
481
+ /* @__PURE__ */ import_react4.default.createElement(import_material3.MenuItem, { value: 60 }, "Every minute")
482
+ ), /* @__PURE__ */ import_react4.default.createElement(import_material3.Tooltip, { title: "Take a new snapshot" }, /* @__PURE__ */ import_react4.default.createElement(import_material3.IconButton, { size: "small", onClick: refresh }, /* @__PURE__ */ import_react4.default.createElement(import_icons.Refresh, { fontSize: "small" })))), /* @__PURE__ */ import_react4.default.createElement(import_material3.Typography, { variant: "caption", color: "text.secondary", sx: { mb: 1 } }, "Snapshot taken at ", new Date(inventory.takenAt).toLocaleTimeString(), data.autoRefresh ? ` \u2014 refreshing every ${data.autoRefresh}s while this tab is open` : " \u2014 it does not refresh on its own", ".", " ", "Delivered counts since each component started; the rate is measured against your previous snapshot."), /* @__PURE__ */ import_react4.default.createElement(import_material3.Box, { ref: boxRef, sx: { display: "flex", flexDirection: "column", overflowY: vista === "table" ? "auto" : "hidden", overflowX: "hidden", width: "100%", flexGrow: 1, height: `calc(100vh - ${boxTop}px - 35px)` } }, vista === "graph" && /* @__PURE__ */ import_react4.default.createElement(StatusDiagram, { inventory, active: activos }), vista === "table" && /* @__PURE__ */ import_react4.default.createElement(import_material3.Table, { size: "small", stickyHeader: true }, /* @__PURE__ */ import_react4.default.createElement(import_material3.TableHead, null, /* @__PURE__ */ import_react4.default.createElement(import_material3.TableRow, null, /* @__PURE__ */ import_react4.default.createElement(import_material3.TableCell, null, "Kind"), /* @__PURE__ */ import_react4.default.createElement(import_material3.TableCell, null, "Name"), /* @__PURE__ */ import_react4.default.createElement(import_material3.TableCell, null, "State"), /* @__PURE__ */ import_react4.default.createElement(import_material3.TableCell, { align: "right" }, "Consumers"), /* @__PURE__ */ import_react4.default.createElement(import_material3.TableCell, { align: "right" }, "Delivered"), /* @__PURE__ */ import_react4.default.createElement(import_material3.TableCell, null, "Why"))), /* @__PURE__ */ import_react4.default.createElement(import_material3.TableBody, null, componentes.map(fila)))), data.signals.length > 0 && /* @__PURE__ */ import_react4.default.createElement(import_material3.Box, { sx: { mt: 1 } }, data.signals.map((s, i) => /* @__PURE__ */ import_react4.default.createElement(import_material3.Typography, { key: i, variant: "caption", color: "error", display: "block" }, s))));
163
483
  };
164
484
 
165
485
  // src/front/StatusChannel.ts
166
- var StatusSetup = () => import_react3.default.createElement("div", null, "Kwirth Status has nothing to configure: open it and it shows what this Kwirth has inside.");
486
+ var StatusSetup = () => import_react5.default.createElement("div", null, "Kwirth Status has nothing to configure: open it and it shows what this Kwirth has inside.");
167
487
  var StatusChannel = class {
168
488
  constructor() {
169
489
  this.setupVisible = false;
@@ -194,7 +514,7 @@
194
514
  return import_kwirth_common2.EInstanceConfigScope.NONE;
195
515
  }
196
516
  getChannelIcon() {
197
- return import_react3.default.createElement(StatusIcon);
517
+ return import_react5.default.createElement(StatusIcon);
198
518
  }
199
519
  getSetupVisibility() {
200
520
  return this.setupVisible;
@@ -208,6 +528,7 @@
208
528
  switch (msg.type) {
209
529
  case import_kwirth_common2.EInstanceMessageType.DATA:
210
530
  if (msg.payloadType === "inventory" /* INVENTORY */ && msg.inventory) {
531
+ data.previous = data.inventory;
211
532
  data.inventory = msg.inventory;
212
533
  }
213
534
  return { action: import_kwirth_common_front.EChannelRefreshAction.REFRESH };
@@ -232,7 +553,21 @@
232
553
  startChannel(_channelObject) {
233
554
  return true;
234
555
  }
235
- stopChannel(_channelObject) {
556
+ /*
557
+ Al parar hay que DECIRLO, y ademas tirar la foto.
558
+
559
+ 'started' lo ponia a true la respuesta del arranque y no lo bajaba nadie, asi que al parar el
560
+ canal la pestaña se quedaba enseñando el inventario como si nada. Y esa foto ya no vale: es de
561
+ un momento anterior y nada la va a refrescar mientras el canal este parado — dejarla puesta es
562
+ justo el tipo de dato viejo con pinta de actual que este plugin existe para evitar.
563
+ */
564
+ stopChannel(channelObject) {
565
+ const data = channelObject.data;
566
+ if (data) {
567
+ data.started = false;
568
+ data.inventory = void 0;
569
+ data.previous = void 0;
570
+ }
236
571
  return true;
237
572
  }
238
573
  pauseChannel(_channelObject) {
@@ -241,7 +576,13 @@
241
576
  continueChannel(_channelObject) {
242
577
  return true;
243
578
  }
244
- socketDisconnected(_channelObject) {
579
+ /*
580
+ Si se cae el socket, el canal deja de recibir y la foto se queda congelada sin avisar. Se trata
581
+ igual que una parada: mejor decir que hay que arrancar que enseñar algo que ya no se actualiza.
582
+ */
583
+ socketDisconnected(channelObject) {
584
+ const data = channelObject.data;
585
+ if (data) data.started = false;
245
586
  return true;
246
587
  }
247
588
  /*
package/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "id": "status",
5
5
  "name": "@kwirthmagnify/kwirth-plugin-status",
6
6
  "displayName": "Kwirth Status",
7
- "version": "0.1.0",
7
+ "version": "0.2.1",
8
8
  "description": "A look inside Kwirth: what is installed, how it is doing and who consumes what",
9
9
  "icon": "<svg viewBox='0 0 24 24'><g fill='none' stroke='currentColor' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'><rect x='2.6' y='4.6' width='18.8' height='14.8' rx='2.2'/><path d='M6 12h2.6l1.5-3.4 2.6 6.8 1.5-3.4H18'/></g></svg>",
10
10
  "requiresRestart": false,