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 +1 -52
- package/package.json +1 -1
- package/src/-0-hooks/hooks.ts +2 -2
- package/src/-a-archives/archivesBackBlaze.ts +1 -1
- package/src/0-path-value-core/PathWatcher.ts +6 -0
- package/src/4-querysub/Querysub.ts +0 -1
- package/src/deployManager/components/ProcessesView.tsx +169 -0
- package/src/deployManager/components/RouteConfigView.tsx +28 -2
- package/src/deployManager/components/ServiceDetailPage.tsx +9 -124
- package/src/deployManager/machineApplyMainCode.ts +0 -0
- package/src/deployManager/machineController.ts +40 -37
- package/src/deployManager/processLogs.ts +171 -0
- package/src/deployManager/processManager.ts +369 -0
- package/src/deployManager/setupMachineMain.ts +1 -1
- package/src/diagnostics/trackResources.ts +4 -2
- package/src/library-components/URLParam.ts +6 -0
- package/src/misc/appPaths.ts +56 -0
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 {
|
|
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
package/src/-0-hooks/hooks.ts
CHANGED
|
@@ -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 "
|
|
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(
|
|
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,
|
|
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 {
|
|
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 "../
|
|
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}>
|
|
Binary file
|