querysub 0.652.0 → 0.653.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "querysub",
3
- "version": "0.652.0",
3
+ "version": "0.653.0",
4
4
  "main": "index.js",
5
5
  "license": "MIT",
6
6
  "note1": "note on node-forge fork, see https://github.com/digitalbazaar/forge/issues/744 for details",
@@ -1,14 +1,48 @@
1
1
  import preact from "preact";
2
2
  import { SocketFunction } from "socket-function/SocketFunction";
3
3
  import { qreact } from "../../4-dom/qreact";
4
- import { sort } from "socket-function/src/misc";
4
+ import { sort, timeInMinute } from "socket-function/src/misc";
5
5
  import { isDefined } from "../../misc";
6
- import { formatVeryNiceDateTime } from "socket-function/src/formatting/format";
7
- import { InputPicker } from "../../library-components/InputPicker";
8
- import { MachineServiceController } from "../machineSchema";
6
+ import { formatTime } from "socket-function/src/formatting/format";
7
+ import { css } from "typesafecss";
8
+ import { Querysub } from "../../4-querysub/Querysub";
9
+ import { MachineServiceController, getLiveServiceParameters, getMachineTargets, applyCommandTemplate } from "../machineSchema";
10
+ import { FUNCTION_RUNNER_COMMAND_PREFIX } from "../serviceCategories";
9
11
 
10
12
  module.hotreload = true;
11
13
 
14
+ const DEAD_HEARTBEAT_THRESHOLD = 10 * timeInMinute;
15
+
16
+ const NETWORK_ARG_REGEX = /--network[= ]+("([^"]*)"|'([^']*)'|\S+)/g;
17
+
18
+ /** The networks each machine's deployed function runner services listen on, from the `--network` args of their commands (templated per machine entry). */
19
+ export function getMachineNetworks(controller: ReturnType<typeof MachineServiceController>): Map<string, string[]> {
20
+ let result = new Map<string, string[]>();
21
+ for (let serviceId of controller.getServiceList() || []) {
22
+ let config = controller.getServiceConfig(serviceId);
23
+ if (!config) continue;
24
+ let parameters = getLiveServiceParameters(config);
25
+ if (!parameters.deploy) continue;
26
+ if (!parameters.command.trimStart().startsWith(FUNCTION_RUNNER_COMMAND_PREFIX)) continue;
27
+ for (let target of getMachineTargets(parameters)) {
28
+ let command = applyCommandTemplate(parameters.command, target.variables);
29
+ for (let match of command.matchAll(NETWORK_ARG_REGEX)) {
30
+ let network = match[2] ?? match[3] ?? match[1];
31
+ if (!network) continue;
32
+ let list = result.get(target.machineId);
33
+ if (!list) {
34
+ list = [];
35
+ result.set(target.machineId, list);
36
+ }
37
+ if (!list.includes(network)) {
38
+ list.push(network);
39
+ }
40
+ }
41
+ }
42
+ }
43
+ return result;
44
+ }
45
+
12
46
  export class MachinePicker extends qreact.Component<{
13
47
  label?: preact.ComponentChild;
14
48
  picked: string[];
@@ -22,19 +56,51 @@ export class MachinePicker extends qreact.Component<{
22
56
  let machines = (controller.getMachineList() || []).map(x => controller.getMachineInfo(x)).filter(isDefined);
23
57
  sort(machines, x => -x.heartbeat);
24
58
 
25
- let options = machines.map(machineObj => ({
26
- value: machineObj.machineId,
27
- label: `${machineObj.machineId} ${machineObj.info["getExternalIP"]} (${Object.keys(machineObj.services || {}).length} services, last heartbeat ${formatVeryNiceDateTime(machineObj.heartbeat)})`,
28
- }));
59
+ let networksPerMachine = getMachineNetworks(controller);
60
+ let now = Querysub.nowDelayed(timeInMinute);
29
61
 
30
- return <InputPicker<string>
31
- label={this.props.label}
32
- picked={this.props.picked}
33
- options={options}
34
- addPicked={this.props.addPicked}
35
- removePicked={this.props.removePicked}
36
- singleOption={this.props.singleOption}
37
- fillWidth={this.props.fillWidth}
38
- />;
62
+ return <div className={css.vbox(6) + (this.props.fillWidth && css.fillWidth || "")}>
63
+ {this.props.label && <b>{this.props.label}</b>}
64
+ <div className={css.hbox(8).wrap}>
65
+ {machines.map(machine => {
66
+ let machineId = machine.machineId;
67
+ let isPicked = this.props.picked.includes(machineId);
68
+ let ip = machine.info["getExternalIP"];
69
+ if (typeof ip !== "string") {
70
+ ip = "";
71
+ }
72
+ let sinceHeartbeat = now - machine.heartbeat;
73
+ let isLikelyDead = sinceHeartbeat > DEAD_HEARTBEAT_THRESHOLD;
74
+ let networks = networksPerMachine.get(machineId) || [];
75
+ return <button
76
+ key={machineId}
77
+ className={
78
+ css.button.pad2(10, 6).textAlign("left").vbox(2)
79
+ + (isPicked && css.hsl(100, 50, 85).bord2(100, 50, 40, 2) || css.hsl(0, 0, 100).bord2(0, 0, 70))
80
+ }
81
+ onClick={() => {
82
+ if (isPicked) {
83
+ this.props.removePicked(machineId);
84
+ } else {
85
+ this.props.addPicked(machineId);
86
+ }
87
+ }}
88
+ >
89
+ <div className={css.fontSize(17).fontWeight("bold")}>
90
+ {ip || machineId}
91
+ </div>
92
+ {ip && <div className={css.fontSize(11).colorhsl(0, 0, 40)}>
93
+ {machineId}
94
+ </div>}
95
+ {networks.length > 0 && <div className={css.fontSize(11).colorhsl(220, 60, 40)}>
96
+ {networks.join(", ")}
97
+ </div>}
98
+ {isLikelyDead && <div className={css.fontSize(11).colorhsl(0, 80, 45)}>
99
+ ⚠️ Likely dead ({formatTime(sinceHeartbeat)} since heartbeat)
100
+ </div>}
101
+ </button>;
102
+ })}
103
+ </div>
104
+ </div>;
39
105
  }
40
106
  }
@@ -73,10 +73,10 @@ class ProcessOutput extends qreact.Component<{ record: ProcessRecord; machineNod
73
73
  private outputDiv: HTMLDivElement | undefined;
74
74
  // The pane opens showing the newest output and follows it as it streams; scrolling up to read releases the pin, scrolling back down re-engages it
75
75
  private pinnedToBottom = true;
76
- componentDidUpdate() {
77
- if (this.pinnedToBottom && this.outputDiv) {
78
- this.outputDiv.scrollTop = this.outputDiv.scrollHeight;
79
- }
76
+ private scrollToEndIfPinned() {
77
+ let div = this.outputDiv;
78
+ if (!this.pinnedToBottom || !div) return;
79
+ div.scrollTop = div.scrollHeight;
80
80
  }
81
81
  render() {
82
82
  return <div
@@ -96,6 +96,11 @@ class ProcessOutput extends qreact.Component<{ record: ProcessRecord; machineNod
96
96
  // The pane is dark, so the ansi colour only sets the hue - the lightness has to stay readable against it
97
97
  return <span className={css.colorhsl(rgbToHsl(color).h, ANSI_SATURATION, ANSI_LIGHTNESS)}>{text}</span>;
98
98
  })}
99
+ {/* Keyed by the data length so it is recreated on every append, and its ref fires after the new content is in the DOM - which is when scrolling to the end works */}
100
+ {this.state.data && <span
101
+ key={`end-${this.state.data.length}`}
102
+ ref={element => element && this.scrollToEndIfPinned()}
103
+ />}
99
104
  </div>;
100
105
  }
101
106
  }
@@ -7,7 +7,7 @@ import { Querysub } from "../../4-querysub/Querysub";
7
7
  import { currentViewParam, selectedServiceIdParam, selectedMachineIdParam } from "../urlParams";
8
8
  import { formatDateTime, formatDateTimeDetailed, formatTime, formatVeryNiceDateTime } from "socket-function/src/formatting/format";
9
9
  import { InputPicker } from "../../library-components/InputPicker";
10
- import { MachinePicker } from "./MachinePicker";
10
+ import { MachinePicker, getMachineNetworks } from "./MachinePicker";
11
11
  import { deepCloneJSON, sort, timeInSecond } from "socket-function/src/misc";
12
12
  import { InputLabel } from "../../library-components/InputLabel";
13
13
  import { Button } from "../../library-components/Button";
@@ -105,43 +105,72 @@ export class ServiceDetailPage extends qreact.Component {
105
105
  });
106
106
  };
107
107
 
108
- let dupIndexes = new Map<string, number>();
109
- return <div className={css.vbox(10).fillWidth.pad2(12).bord2(0, 0, 20)}>
110
- {targets.map((target, entryIndex) => {
111
- let index = dupIndexes.get(target.machineId) || 0;
112
- dupIndexes.set(target.machineId, index + 1);
113
- // Variables set on the entry but no longer in the command are still shown (flagged), so stale values can be cleared.
114
- let variableNames = [...new Set([...templateVariables, ...Object.keys(target.variables)])];
115
- return <div className={css.hbox(12).wrap.alignItems("center").fillWidth}>
116
- <div className={css.boldStyle}>{getScreenName({ serviceKey: config.parameters.key, index })} ({target.machineId})</div>
117
- {variableNames.map(name => {
118
- let inCommand = templateVariables.includes(name);
119
- return <InputLabel
120
- label={inCommand && name || `${name} (not in command)`}
121
- value={target.variables[name] || ""}
122
- onChangeValue={value => setVariable(entryIndex, name, value)}
123
- />;
124
- })}
125
- <button
126
- className={css.pad2(10, 4).button.bord2(0, 0, 20).hsl(120, 70, 90)}
127
- title="Duplicate this entry (same machine and variables)"
128
- onClick={() => {
129
- updateTargets(targets => {
130
- targets.splice(entryIndex + 1, 0, deepCloneJSON(targets[entryIndex]));
131
- });
132
- }}>
133
- +
134
- </button>
135
- <button
136
- className={css.pad2(10, 4).button.bord2(0, 0, 20).hsl(0, 70, 90)}
137
- title="Remove this entry"
138
- onClick={() => {
139
- updateTargets(targets => {
140
- targets.splice(entryIndex, 1);
141
- });
142
- }}>
143
-
144
- </button>
108
+ let controller = MachineServiceController(SocketFunction.browserNodeId());
109
+ let networksPerMachine = getMachineNetworks(controller);
110
+
111
+ // Grouped by machine, keeping each entry's index into the targets array (edits go by that index) and its per-machine occurrence index (the screen name index).
112
+ let machineGroups = new Map<string, { entryIndex: number; index: number; target: (typeof targets)[number] }[]>();
113
+ targets.forEach((target, entryIndex) => {
114
+ let group = machineGroups.get(target.machineId);
115
+ if (!group) {
116
+ group = [];
117
+ machineGroups.set(target.machineId, group);
118
+ }
119
+ group.push({ entryIndex, index: group.length, target });
120
+ });
121
+
122
+ return <div className={css.vbox(8).fillWidth}>
123
+ {[...machineGroups.entries()].map(([machineId, group]) => {
124
+ let ip = controller.getMachineInfo(machineId)?.info["getExternalIP"];
125
+ if (typeof ip !== "string") {
126
+ ip = "";
127
+ }
128
+ let networks = networksPerMachine.get(machineId) || [];
129
+ return <div key={machineId} className={css.vbox(6).fillWidth.pad2(8, 6).hsl(0, 0, 94)}>
130
+ <div className={css.hbox(10).alignItems("baseline")}>
131
+ <div className={css.boldStyle.fontSize(15)}>{ip || machineId}</div>
132
+ {ip && <div className={css.fontSize(11).colorhsl(0, 0, 40)}>{machineId}</div>}
133
+ {networks.length > 0 && <div className={css.fontSize(11).colorhsl(220, 60, 40)}>
134
+ {networks.join(", ")}
135
+ </div>}
136
+ </div>
137
+ <div className={css.hbox(6).wrap}>
138
+ {group.map(({ entryIndex, index, target }) => {
139
+ // Variables set on the entry but no longer in the command are still shown (flagged), so stale values can be cleared.
140
+ let variableNames = [...new Set([...templateVariables, ...Object.keys(target.variables)])];
141
+ return <div key={entryIndex} className={css.hbox(8).alignItems("center").pad2(8, 4).hsl(0, 0, 100)}>
142
+ <div className={css.fontSize(11).colorhsl(0, 0, 40)}>{getScreenName({ serviceKey: config.parameters.key, index })}</div>
143
+ {variableNames.map(name => {
144
+ let inCommand = templateVariables.includes(name);
145
+ return <InputLabel
146
+ label={inCommand && name || `${name} (not in command)`}
147
+ value={target.variables[name] || ""}
148
+ onChangeValue={value => setVariable(entryIndex, name, value)}
149
+ />;
150
+ })}
151
+ <button
152
+ className={css.pad2(10, 4).button.bord2(0, 0, 20).hsl(120, 70, 90)}
153
+ title="Duplicate this entry (same machine and variables)"
154
+ onClick={() => {
155
+ updateTargets(targets => {
156
+ targets.splice(entryIndex + 1, 0, deepCloneJSON(targets[entryIndex]));
157
+ });
158
+ }}>
159
+ +
160
+ </button>
161
+ <button
162
+ className={css.pad2(10, 4).button.bord2(0, 0, 20).hsl(0, 70, 90)}
163
+ title="Remove this entry"
164
+ onClick={() => {
165
+ updateTargets(targets => {
166
+ targets.splice(entryIndex, 1);
167
+ });
168
+ }}>
169
+
170
+ </button>
171
+ </div>;
172
+ })}
173
+ </div>
145
174
  </div>;
146
175
  })}
147
176
  </div>;
@@ -306,7 +335,7 @@ export class ServiceDetailPage extends qreact.Component {
306
335
  this.updateEditorState(updated);
307
336
  }}
308
337
  />
309
- <div className={css.hbox(12)}>
338
+ <div className={css.hbox(12).alignItems("center")}>
310
339
  {(() => {
311
340
  let lastDeployTime = getLiveServiceParameters(originalConfig).releaseTime;
312
341
  if (!lastDeployTime || lastDeployTime > now) return undefined;
@@ -314,6 +343,9 @@ export class ServiceDetailPage extends qreact.Component {
314
343
  Last deploy at {formatDateTime(lastDeployTime)} ({formatTime(now - lastDeployTime)} ago)
315
344
  </span>;
316
345
  })()}
346
+ {!config.parameters.deploy && <div className={css.fontSize(24).boldStyle.colorhsl(0, 80, 45).hsl(0, 80, 95).pad2(12, 6)}>
347
+ NOT DEPLOYED, ENABLE IN ORDER TO DEPLOY
348
+ </div>}
317
349
  </div>
318
350
  {/* Machine Status */}
319
351
  {config.parameters.deploy && <div className={css.vbox(8).fillWidth}>
@@ -5,7 +5,7 @@ import { DISK_PATHVALUE_COLOR, PATHVALUE_COLOR, FUNCTION_RUNNER_COLOR } from "..
5
5
  // What a service IS, worked out from the command it runs. The commands are the only signal we have - nothing in the config says "this is a storage server" - so the prefixes live here rather than in whichever page happened to need one first.
6
6
  export const STORAGE_COMMAND_PREFIX = "yarn storageserve";
7
7
  const PATHVALUE_COMMAND_PREFIX = "yarn server-public";
8
- const FUNCTION_RUNNER_COMMAND_PREFIX = "yarn function-public";
8
+ export const FUNCTION_RUNNER_COMMAND_PREFIX = "yarn function-public";
9
9
 
10
10
  export type ServiceCategory = {
11
11
  label: string;