querysub 0.556.0 → 0.558.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/appSecrets.ts CHANGED
@@ -1,62 +1,11 @@
1
1
  import fs from "fs";
2
- import os from "os";
3
- import { isNode } from "socket-function/src/misc";
4
2
  import { lazy } from "socket-function/src/caching";
5
3
  import { ArchivesBackblaze } from "sliftutils/storage/backblaze";
6
- import { parseArgsFactory } from "./src/misc/rawParams";
4
+ import { STORAGE_DIR, getDomain, getBackblazePath } from "./src/misc/appPaths";
7
5
  import { Querysub } from "./src/4-querysub/Querysub";
8
6
 
9
- // getSecret always imports us relative to the process cwd, so the cwd is the repo root and this matches what getStorageDir would resolve
10
- const STORAGE_DIR = "./database-storage/";
11
-
12
- // querysubConfig and getDomain are copied verbatim from src/config.ts, so this file has no dependencies on our own application (parseArgsFactory is fine to import, as it has no dependencies of its own)
13
-
14
- let yargObj = parseArgsFactory()
15
- .option("domain", { type: "string", desc: `Sets the domain` })
16
- .argv
17
- ;
18
-
19
- type QuerysubConfig = {
20
- domain?: string;
21
- emaildomain?: string;
22
- notifyemails?: string[];
23
- };
24
- let querysubConfig = lazy((): QuerysubConfig => {
25
- if (!isNode()) throw new Error("querysubConfig is only available on the server");
26
- const path = "./querysub.json";
27
- if (!fs.existsSync(path)) {
28
- return {};
29
- }
30
- try {
31
- return JSON.parse(fs.readFileSync(path, "utf8"));
32
- } catch (e) {
33
- console.error("Error parsing querysub.json", e);
34
- return {};
35
- }
36
- });
37
-
38
- export function getDomain() {
39
- if (!isNode()) {
40
- return location.hostname.split(".").slice(-2).join(".");
41
- }
42
- return yargObj.domain || querysubConfig().domain || "querysub.com";
43
- }
44
-
45
7
  const keysArchives = lazy(() => new ArchivesBackblaze({ bucketName: getDomain() }));
46
8
 
47
- export function getBackblazePath() {
48
- let testPaths = [
49
- STORAGE_DIR + "backblaze.json",
50
- os.homedir() + "/backblaze.json",
51
- ];
52
- for (let path of testPaths) {
53
- if (fs.existsSync(path)) {
54
- return path;
55
- }
56
- }
57
- return testPaths[0];
58
- }
59
-
60
9
  export const getCloudflareCreds = lazy(async (): Promise<{ key: string; email: string }> => {
61
10
  let archives = keysArchives();
62
11
  let credsJSON = await archives.get("keys/cloudflare.json");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "querysub",
3
- "version": "0.556.0",
3
+ "version": "0.558.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,5 +1,5 @@
1
- import { MaybePromise } from "socket-function/src/types";
2
- import { CallInterceptor } from "../3-path-functions/PathFunctionHelpers";
1
+ import type { MaybePromise } from "socket-function/src/types";
2
+ import type { CallInterceptor } from "../3-path-functions/PathFunctionHelpers";
3
3
  import type { EdgeNodeConfig } from "../4-deploy/edgeNodes";
4
4
  import type { ExtraMetadata } from "../5-diagnostics/nodeMetadata";
5
5
 
@@ -3,7 +3,7 @@ import { Archives } from "./archives";
3
3
  import fs from "fs";
4
4
  import { isNode, timeInMinute } from "socket-function/src/misc";
5
5
  import { isLogBackblaze } from "../config";
6
- import { getBackblazePath } from "../../appSecrets";
6
+ import { getBackblazePath } from "../misc/appPaths";
7
7
  import { ArchivesBackblaze as ArchivesBackblazeBase } from "sliftutils/storage/backblaze";
8
8
  import type { IArchives, ArchivesConfig } from "sliftutils/storage/IArchives";
9
9
 
@@ -50,6 +50,12 @@ function incrementWatcherSequence() {
50
50
  });
51
51
  }
52
52
 
53
+ if (!registerResource) {
54
+ debugger;
55
+ require("debugbreak")(2);
56
+ debugger;
57
+ }
58
+
53
59
  // WATCH CASES
54
60
  // 1) getOwnNodeId() is used to trigger pathValueClientWatcher (no one else should be using it)
55
61
  // 2) Use other nodes to immediately proxy to the other nodes
@@ -50,7 +50,6 @@ import yargs, { check } from "yargs";
50
50
  import { parseArgsFactory } from "../misc/rawParams";
51
51
 
52
52
  import * as typesafecss from "typesafecss";
53
- import "../library-components/urlResetGroups";
54
53
  import { createLocalSchema } from "./schemaHelpers";
55
54
 
56
55
  setTimeout(() => import("./FunctionRunnerTracking"));
@@ -0,0 +1,169 @@
1
+ import { SocketFunction } from "socket-function/SocketFunction";
2
+ import { qreact } from "../../4-dom/qreact";
3
+ import { css } from "typesafecss";
4
+ import { t } from "../../2-proxy/schema2";
5
+ import { Querysub } from "../../4-querysub/Querysub";
6
+ import { formatTime, formatDateTimeDetailed } from "socket-function/src/formatting/format";
7
+ import { sort, timeInSecond, nextId } from "socket-function/src/misc";
8
+ import { MachineController, watchProcessOutput, stopWatchingProcessOutput } from "../machineController";
9
+ import type { ProcessRecord } from "../processLogs";
10
+ import { Button } from "../../library-components/Button";
11
+ import { parseAnsiColors } from "../../diagnostics/logs/ansiFormat";
12
+
13
+ module.hotreload = true;
14
+
15
+ const RUNNING_COLOR = { h: 130, s: 55, l: 88 };
16
+ const DEAD_COLOR = { h: 0, s: 0, l: 92 };
17
+ const OUTPUT_BUFFER_LIMIT = 1_000_000;
18
+ const OUTPUT_BUFFER_KEPT = 100_000;
19
+ const OUTPUT_MAX_HEIGHT = "40vh";
20
+
21
+ /** One process's live output. Mounting starts the stream, unmounting stops it, so the watch lifetime is exactly the time it is on screen. */
22
+ class ProcessOutput extends qreact.Component<{ record: ProcessRecord; machineNodeId: string }> {
23
+ state = t.state({
24
+ data: t.type(""),
25
+ });
26
+ private callbackId = nextId();
27
+ // Unmounting before the watch finishes registering would otherwise stop a callback that gets added right after, leaving it streaming into a component that is gone
28
+ private watching: Promise<void> = Promise.resolve();
29
+ private unmounted = false;
30
+ componentDidMount() {
31
+ // Props are synchronized state, so the plain values the async work needs are read here
32
+ let { folder, launchId } = this.props.record;
33
+ let nodeId = this.props.machineNodeId;
34
+ let callbackId = this.callbackId;
35
+ this.watching = (async () => {
36
+ await watchProcessOutput({
37
+ nodeId,
38
+ folder,
39
+ launchId,
40
+ callbackId,
41
+ onData: async (data: string) => {
42
+ Querysub.commit(() => {
43
+ let full = this.state.data + data;
44
+ // Trimmed well below the limit, so trimming is rare - each trim jumps the scroll position
45
+ if (full.length > OUTPUT_BUFFER_LIMIT) {
46
+ full = full.slice(-OUTPUT_BUFFER_KEPT);
47
+ }
48
+ this.state.data = full;
49
+ });
50
+ },
51
+ });
52
+ // Unmounted while we were registering, so stop the watch we just started
53
+ if (this.unmounted) {
54
+ await stopWatchingProcessOutput({ callbackId });
55
+ }
56
+ })();
57
+ }
58
+ componentWillUnmount() {
59
+ this.unmounted = true;
60
+ let callbackId = this.callbackId;
61
+ let watching = this.watching;
62
+ Querysub.onCommitFinished(async () => {
63
+ await watching;
64
+ await stopWatchingProcessOutput({ callbackId });
65
+ });
66
+ }
67
+ render() {
68
+ return <div className={
69
+ css.fontFamily("monospace").whiteSpace("pre-wrap").fillWidth
70
+ .maxHeight(OUTPUT_MAX_HEIGHT).overflow("auto").pad2(10, 8)
71
+ .hsl(0, 0, 12).colorhsl(0, 0, 90)
72
+ }>
73
+ {parseAnsiColors(this.state.data)}
74
+ </div>;
75
+ }
76
+ }
77
+
78
+ class ProcessRow extends qreact.Component<{ record: ProcessRecord; machineNodeId: string }> {
79
+ state = t.state({
80
+ watching: t.type(false),
81
+ });
82
+ render() {
83
+ let record = this.props.record;
84
+ let now = Querysub.nowDelayed(timeInSecond);
85
+ let running = record.deadTime === undefined;
86
+ let color = running && RUNNING_COLOR || DEAD_COLOR;
87
+ return <div className={css.vbox(6).fillWidth.pad2(10, 8).bord2(color.h, color.s, color.l - 20).hsl(color.h, color.s, color.l)}>
88
+ <div className={css.hbox(10).wrap.alignItems("center")}>
89
+ <div className={css.boldStyle}>{record.screenName}</div>
90
+ <div className={css.colorhsl(0, 0, 35)}>{record.serviceKey} #{record.index}</div>
91
+ <div className={css.hbox(4)}>
92
+ <span>started</span>
93
+ <span className={css.boldStyle}>{formatDateTimeDetailed(record.startTime)}</span>
94
+ <span className={css.colorhsl(0, 0, 40)}>({formatTime(now - record.startTime)} ago)</span>
95
+ </div>
96
+ {running && <div>● running for {formatTime(now - record.startTime)}</div>}
97
+ {!running && <div className={css.hbox(4)}>
98
+ <span>died</span>
99
+ <span className={css.boldStyle}>{formatDateTimeDetailed(record.deadTime || 0)}</span>
100
+ <span className={css.colorhsl(0, 0, 40)}>
101
+ ({formatTime(now - (record.deadTime || 0))} ago, ran {formatTime((record.deadTime || 0) - record.startTime)})
102
+ </span>
103
+ </div>}
104
+ {record.pid !== undefined && <div className={css.colorhsl(0, 0, 45)}>pid {record.pid}</div>}
105
+ <div className={css.flexGrow(1)} />
106
+ <Button flavor="tiny" onClick={() => this.state.watching = !this.state.watching}>
107
+ {this.state.watching && "Hide output" || "Watch output"}
108
+ </Button>
109
+ </div>
110
+ {this.state.watching && <ProcessOutput record={record} machineNodeId={this.props.machineNodeId} />}
111
+ </div>;
112
+ }
113
+ }
114
+
115
+ class MachineProcesses extends qreact.Component<{ machineId: string; applyNodeId: string; serviceId?: string }> {
116
+ state = t.state({
117
+ showHistorical: t.type(false),
118
+ });
119
+ render() {
120
+ let records = MachineController(SocketFunction.browserNodeId()).listOtherProcesses({ nodeId: this.props.applyNodeId });
121
+ if (!records) return <div className={css.pad2(10, 8)}>{this.props.machineId}: loading processes...</div>;
122
+ let shown = records;
123
+ if (this.props.serviceId) {
124
+ shown = shown.filter(x => x.serviceId === this.props.serviceId);
125
+ }
126
+ let running = shown.filter(x => x.deadTime === undefined);
127
+ let historical = shown.filter(x => x.deadTime !== undefined);
128
+ sort(running, x => -x.startTime);
129
+ sort(historical, x => -(x.deadTime || 0));
130
+ return <div className={css.vbox(8).fillWidth}>
131
+ <div className={css.hbox(10).wrap.alignItems("center")}>
132
+ <div className={css.boldStyle}>{this.props.machineId}</div>
133
+ <div className={css.colorhsl(0, 0, 45)}>{running.length} running</div>
134
+ {historical.length > 0 && <Button flavor="tiny" onClick={() => this.state.showHistorical = !this.state.showHistorical}>
135
+ {this.state.showHistorical && `Hide ${historical.length} historical` || `Show ${historical.length} historical`}
136
+ </Button>}
137
+ </div>
138
+ {running.map(record => <ProcessRow key={record.launchId} record={record} machineNodeId={this.props.applyNodeId} />)}
139
+ {this.state.showHistorical && historical.map(record =>
140
+ <ProcessRow key={record.launchId} record={record} machineNodeId={this.props.applyNodeId} />
141
+ )}
142
+ </div>;
143
+ }
144
+ }
145
+
146
+ /** Every machine's processes. The listing is cached per machine, so Invalidate empties it and each machine repopulates as its own call returns - watching a process streams live regardless. */
147
+ export class ProcessesView extends qreact.Component<{
148
+ machines: { machineId: string; applyNodeId: string }[];
149
+ serviceId?: string;
150
+ }> {
151
+ render() {
152
+ return <div className={css.vbox(14).fillWidth}>
153
+ <div className={css.hbox(10).alignItems("center")}>
154
+ <h3 className={css.flexGrow(1)}>Processes</h3>
155
+ <Button onClick={() => {
156
+ MachineController(SocketFunction.browserNodeId()).listOtherProcesses.resetAll();
157
+ }}>
158
+ Invalidate
159
+ </Button>
160
+ </div>
161
+ {this.props.machines.map(machine => <MachineProcesses
162
+ key={machine.machineId}
163
+ machineId={machine.machineId}
164
+ applyNodeId={machine.applyNodeId}
165
+ serviceId={this.props.serviceId}
166
+ />)}
167
+ </div>;
168
+ }
169
+ }
@@ -5,6 +5,7 @@ import { css } from "typesafecss";
5
5
  import { sort } from "socket-function/src/misc";
6
6
  import { formatNumber, formatDateTime, formatTime, formatDateTimeDetailed } from "socket-function/src/formatting/format";
7
7
  import { parseHostedUrl, parseBackblazeUrl } from "sliftutils/storage/remoteStorage/remoteConfig";
8
+ import { resolveIntermediateSources, getIntermediateSources } from "sliftutils/storage/remoteStorage/intermediateSources";
8
9
  import { SocketFunction } from "socket-function/SocketFunction";
9
10
  import { FULL_VALID_WINDOW, FULL_ROUTE } from "sliftutils/storage/IArchives";
10
11
  import type { RemoteConfig, RemoteConfigBase, HostedConfig, BackblazeConfig } from "sliftutils/storage/IArchives";
@@ -20,6 +21,7 @@ type Source = HostedConfig | BackblazeConfig;
20
21
  const DEFAULT_HTTPS_PORT = 443;
21
22
  const TAG_COLOR = { h: 210, s: 45, l: 88 };
22
23
  const WARNING_COLOR = { h: 35, s: 90, l: 85 };
24
+ const NOTICE_COLOR = { h: 205, s: 70, l: 88 };
23
25
  const SOURCE_BORDER_COLOR = { h: 0, s: 0, l: 75 };
24
26
  const ACTIVE_COLOR = { h: 130, s: 55, l: 85 };
25
27
  const PAST_COLOR = { h: 0, s: 0, l: 90 };
@@ -454,10 +456,29 @@ class WindowRanges extends qreact.Component<{ sources: Source[]; ownBucketName:
454
456
  }
455
457
  }
456
458
 
459
+ /** Two configs that differ only by switchover windows are not really in conflict - resolving those away is exactly what a client does before using the config, so if the remainder matches, every server is working from the same underlying configuration. The version is ignored: a config gains a version purely by having an intermediate written into it. */
460
+ function getIntermediateOnlyDifference(consensus: RemoteConfig, other: RemoteConfig, now: number): {
461
+ intermediateOnly: boolean;
462
+ /** Undefined when nothing is pending; otherwise when the last switchover window still in the future ends */
463
+ expiresAt?: number;
464
+ } {
465
+ let sourcesOf = (config: RemoteConfig) => JSON.stringify(resolveIntermediateSources(config).sources);
466
+ let intermediateOnly = sourcesOf(consensus) === sourcesOf(other);
467
+ let ends = [...getIntermediateSources(consensus), ...getIntermediateSources(other)].map(x => x.validWindow[1]);
468
+ let pending = ends.filter(end => end > now);
469
+ return {
470
+ intermediateOnly,
471
+ expiresAt: pending.length && Math.max(...pending) || undefined,
472
+ };
473
+ }
474
+
457
475
  class ConflictWarning extends qreact.Component<{ consensus: ConfigVariant; conflict: ConfigConflict }> {
458
476
  render() {
459
477
  let { consensus, conflict } = this.props;
460
478
  let { bucketName, variant } = conflict;
479
+ let now = Querysub.timeDelayed(TIME_REFRESH_INTERVAL);
480
+ let { intermediateOnly, expiresAt } = getIntermediateOnlyDifference(consensus.rawConfig, variant.rawConfig, now);
481
+ let color = intermediateOnly && NOTICE_COLOR || WARNING_COLOR;
461
482
  let consensusKeys = new Set(consensus.sources.map(x => getSourceKey(x, bucketName)));
462
483
  let conflictKeys = new Set(variant.sources.map(x => getSourceKey(x, bucketName)));
463
484
  let extra = variant.sources.filter(x => !consensusKeys.has(getSourceKey(x, bucketName)));
@@ -469,10 +490,15 @@ class ConflictWarning extends qreact.Component<{ consensus: ConfigVariant; confl
469
490
  route {(source.route || FULL_ROUTE).join(" – ")}, valid {formatWindowTime(source.validWindow[0])} → {formatWindowTime(source.validWindow[1])}
470
491
  </div>
471
492
  </div>;
472
- return <div className={css.vbox(6).pad2(10, 8).hsl(WARNING_COLOR.h, WARNING_COLOR.s, WARNING_COLOR.l).bord2(WARNING_COLOR.h, WARNING_COLOR.s, WARNING_COLOR.l - 25)}>
493
+ return <div className={css.vbox(6).pad2(10, 8).hsl(color.h, color.s, color.l).bord2(color.h, color.s, color.l - 25)}>
473
494
  <div className={css.boldStyle}>
474
- ⚠ {bucketName}: {variant.servers.length} server{variant.servers.length === 1 && "" || "s"} report a different routing config (version {String(variant.version ?? "none")}, consensus is version {String(consensus.version ?? "none")})
495
+ {intermediateOnly && "ℹ" || ""} {bucketName}: {variant.servers.length} server{variant.servers.length === 1 && "" || "s"} report a different routing config (version {String(variant.version ?? "none")}, consensus is version {String(consensus.version ?? "none")})
475
496
  </div>
497
+ {intermediateOnly && <div>
498
+ Only the switchover windows differ - with those resolved away, every server is working from the same configuration.
499
+ {expiresAt === undefined && " They are all in the past, so this shouldn't break anything."}
500
+ {expiresAt !== undefined && ` The sources become consistent again once the last one expires, ${formatTime(expiresAt - now)} from now (${formatWindowTimeDetailed(expiresAt)}).`}
501
+ </div>}
476
502
  <div className={css.vbox(2).fontSize(TAG_FONT_SIZE)}>
477
503
  {variant.servers.map(server => <div key={server}>{server}</div>)}
478
504
  </div>
@@ -8,30 +8,24 @@ import { currentViewParam, selectedServiceIdParam, selectedMachineIdParam } from
8
8
  import { formatDateTime, formatDateTimeDetailed, formatTime, formatVeryNiceDateTime } from "socket-function/src/formatting/format";
9
9
  import { InputPicker } from "../../library-components/InputPicker";
10
10
  import { MachinePicker } from "./MachinePicker";
11
- import { deepCloneJSON, nextId, sort, timeInSecond } from "socket-function/src/misc";
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";
14
14
  import { isDefined } from "../../misc";
15
- import { watchScreenOutput, stopWatchingScreenOutput } from "../machineController";
15
+ import { ProcessesView } from "./ProcessesView";
16
16
  import { getPathStr2 } from "../../path";
17
17
  import { ATag, Anchor } from "../../library-components/ATag";
18
18
  import { ScrollOnMount } from "../../library-components/ScrollOnMount";
19
- import { StickyBottomScroll } from "../../library-components/StickyBottomScroll";
20
19
  import { PrimitiveDisplay } from "../../diagnostics/logs/ObjectDisplay";
21
- import { parseAnsiColors, rgbToHsl } from "../../diagnostics/logs/ansiFormat";
22
20
  import { RenderGitRefInfo, UpdateServiceButtons, bigEmoji, buttonStyle } from "./deployButtons";
23
21
  import { TypedConfigEditor } from "../../library-components/TypedConfigEditor";
24
22
  import { managementPageURL } from "../../diagnostics/managementPages";
25
23
  import { getLogViewerParams } from "../../diagnostics/logs/IndexedLogs/LogViewerParams";
26
- import { getScreenName } from "../machineApplyMainCode";
24
+ import { getScreenName } from "../processManager";
27
25
  import { getOwnThreadId } from "../../-f-node-discovery/NodeDiscovery";
28
26
  import { decodeNodeId } from "sliftutils/misc/https/certs";
29
27
  import { showModal } from "../../5-diagnostics/Modal";
30
28
 
31
- // Trimmed well below the limit, so trimming is rare - each trim jumps the relative scroll position
32
- const OUTPUT_BUFFER_LIMIT = 1_000_000;
33
- const OUTPUT_BUFFER_KEPT = 100_000;
34
-
35
29
  export class ServiceDetailPage extends qreact.Component {
36
30
  state = t.state({
37
31
  // The editor's current value. Purely in the browser — nothing is written anywhere until a deploy is scheduled (or forced). Whether there are unsaved changes is DERIVED by comparing this to the deployed config, never tracked separately.
@@ -41,13 +35,6 @@ export class ServiceDetailPage extends qreact.Component {
41
35
  expandedErrors: t.lookup({
42
36
  expanded: t.type(false)
43
37
  }),
44
- watchingOutputs: t.lookup({
45
- isWatching: t.type(false),
46
- data: t.type(""),
47
- callbackId: t.type(""),
48
- // The launch the buffered output belongs to. A new launch is a new process, so its output must not be appended to the previous one's.
49
- launchTime: t.number(0),
50
- }),
51
38
  // Milliseconds; 0 means no scheduled time picked yet (use Deploy Now instead)
52
39
  switchTime: t.number(0),
53
40
  // Seconds until the release goes live; 0 means use the default (DEFAULT_OVERLAP_TIME)
@@ -66,66 +53,6 @@ export class ServiceDetailPage extends qreact.Component {
66
53
  this.state.editorState = updatedConfig;
67
54
  }
68
55
 
69
- private async startWatchingOutput(config: {
70
- nodeId: string;
71
- key: string;
72
- index: number;
73
- launchTime: number;
74
- }) {
75
- const { nodeId, key, index, launchTime } = config;
76
- const outputKey = getPathStr2(key, index + "");
77
- let callbackId = nextId();
78
-
79
- // Drop the previous watch first: two live callbacks writing to one buffer is what interlaced the output of the old and new processes
80
- let previousCallbackId = Querysub.localRead(() => this.state.watchingOutputs[outputKey].callbackId);
81
- if (previousCallbackId) {
82
- await stopWatchingScreenOutput({ callbackId: previousCallbackId });
83
- }
84
-
85
- Querysub.commit(() => {
86
- // Cleared, so the buffer only ever holds the output of the launch it is watching
87
- this.state.watchingOutputs[outputKey] = { isWatching: true, data: "", callbackId, launchTime };
88
- });
89
-
90
- await watchScreenOutput({
91
- nodeId,
92
- key,
93
- index,
94
- callbackId,
95
- onData: async (data: string, dataConfig?: { reset?: boolean }) => {
96
- Querysub.localCommit(() => {
97
- let watchingState = this.state.watchingOutputs[outputKey];
98
- // A callback that outlived its watch (a restart raced with in-flight data) must not write into the new process's buffer
99
- if (watchingState.callbackId !== callbackId) return;
100
- // The screen's process changed, so what we have belongs to a process that is gone
101
- let fullData = (dataConfig?.reset && "" || watchingState.data) + data;
102
- // Don't trim every time, otherwise the relative scroll position changes by too much
103
- if (fullData.length > OUTPUT_BUFFER_LIMIT) {
104
- fullData = fullData.slice(-OUTPUT_BUFFER_KEPT);
105
- }
106
- watchingState.data = fullData;
107
- });
108
- }
109
- });
110
- }
111
-
112
- private async stopWatchingOutput(key: string, index: number) {
113
- const outputKey = getPathStr2(key, index + "");
114
-
115
- let callbackId = Querysub.localRead(() => {
116
- const watchingState = this.state.watchingOutputs[outputKey];
117
- let previousCallbackId = watchingState.callbackId;
118
- watchingState.isWatching = false;
119
- // Clearing this stops any in-flight data from landing in the buffer, and marks that there is no watch to stop next time
120
- watchingState.callbackId = "";
121
- return previousCallbackId;
122
- });
123
-
124
- Querysub.onCommitFinished(async () => {
125
- await stopWatchingScreenOutput({ callbackId });
126
- });
127
- }
128
-
129
56
  // Deploys the editor's config (with parameters.releaseTime deciding when it goes live). The editor is NEVER reset — it is set to exactly what was deployed, so it keeps its content and simply compares as having no unsaved changes.
130
57
  private deployConfig(deployConfig: ServiceConfig) {
131
58
  // Do not let them update the serviceId, as that would break things
@@ -435,13 +362,8 @@ export class ServiceDetailPage extends qreact.Component {
435
362
  }
436
363
 
437
364
  let key = config.parameters.key;
438
- const outputKey = getPathStr2(key, index + "");
439
- const isWatching = this.state.watchingOutputs[outputKey].isWatching;
440
- let outputData = this.state.watchingOutputs[outputKey].data;
441
365
  const screenName = getScreenName({ serviceKey: key, index });
442
366
 
443
- let launchTime = serviceInfo?.lastLaunchedTime || 0;
444
-
445
367
  return <div key={machineId}
446
368
  className={css.pad2(12).vbox(10).bord2(0, 0, 20).fillWidth + backgroundColor}
447
369
  >
@@ -510,24 +432,6 @@ export class ServiceDetailPage extends qreact.Component {
510
432
  </Anchor>
511
433
 
512
434
 
513
- <div
514
- className={css.button.pad2(16, 8).bord2(0, 0, 10) + (isWatching ? css.hsl(0, 70, 90) : css.hsl(120, 70, 90))
515
- }
516
- onClick={(e) => {
517
- e.stopPropagation();
518
- let applyNodeId = machineInfo.applyNodeId;
519
- Querysub.onCommitFinished(() => {
520
- if (isWatching) {
521
- void this.stopWatchingOutput(key, index);
522
- } else {
523
- void this.startWatchingOutput({ nodeId: applyNodeId, key, index, launchTime });
524
- }
525
- });
526
- }}
527
- >
528
- {isWatching ? "Stop Watching Output" : "Watch Screen Output"}
529
- </div>
530
-
531
435
  <ATag values={getLogViewerParams({ __machineId: machineId })}>
532
436
  Machine Logs
533
437
  </ATag>
@@ -559,34 +463,15 @@ export class ServiceDetailPage extends qreact.Component {
559
463
  </div>
560
464
  )}
561
465
 
562
- {isWatching &&
563
- <div
564
- className={
565
- css.pad2(8).bord2(0, 0, 10).hsl(0, 0, 10).colorhsl(0, 0, 100)
566
- .whiteSpace("pre-wrap").fontFamily("monospace")
567
- .overflowAuto
568
- .height("60vh")
569
- .vbox0
570
- .fillWidth
571
- }
572
- onClick={e => e.stopPropagation()}
573
- >
574
- <div className={css.flexShrink0}>
575
- {(() => {
576
- let parts = parseAnsiColors(outputData);
577
- return parts.map(({ text, color }) => {
578
- if (!color) return <span>{text}</span>;
579
- let hue = rgbToHsl(color).h;
580
- return <span className={css.hsl(hue, 60, 30)}>{text}</span>;
581
- });
582
- })()}
583
- </div>
584
- <StickyBottomScroll debugText={`Screen ${outputKey}`} time={Date.now()} />
585
- </div>
586
- }
587
466
  </div>;
588
467
  })}
589
468
  </div>
469
+ <ProcessesView
470
+ serviceId={selectedServiceId || ""}
471
+ machines={machineStatuses
472
+ .filter(x => x.machineInfo)
473
+ .map(x => ({ machineId: x.machineId, applyNodeId: x.machineInfo!.applyNodeId }))}
474
+ />
590
475
  </div>}
591
476
 
592
477
  <div className={css.hbox(12).fillWidth}>