querysub 0.528.0 → 0.530.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 +4 -3
- package/src/-a-archives/archivesBackBlaze.ts +50 -950
- package/src/4-dom/qreactGlobal.d.ts +17 -0
- package/src/deployManager/components/MachineDetailPage.tsx +2 -10
- package/src/deployManager/components/MachinesListPage.tsx +2 -11
- package/src/deployManager/components/ServiceDetailPage.tsx +108 -46
- package/src/deployManager/components/deployButtons.tsx +4 -4
- package/src/deployManager/machineApplyMainCode.ts +77 -42
- package/src/deployManager/machineSchema.ts +3 -14
- package/src/deployManager/parametersTimeline.ts +62 -0
- package/src/deployManager/parametersTimelineMain.ts +123 -0
- package/src/deployManager/setupMachineMain.ts +27 -0
- package/src/diagnostics/periodic.ts +2 -2
- package/src/library-components/UsageBar.tsx +58 -0
- package/tsconfig.json +7 -0
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { qreact as qreactType } from "./qreact";
|
|
2
|
+
|
|
3
|
+
// qreact.tsx assigns itself onto globalThis at runtime, but only files that import { qreact } see it in the type system. Sliftutils' .tsx sources get pulled into our program (its .d.ts files' relative imports resolve to the neighboring sources) and compile under our qreact jsxFactory without importing it — this ambient declaration gives them the global qreact value and the global JSX fallback namespace.
|
|
4
|
+
declare global {
|
|
5
|
+
const qreact: typeof qreactType;
|
|
6
|
+
namespace JSX {
|
|
7
|
+
type IntrinsicElements = qreactType.JSX.IntrinsicElements;
|
|
8
|
+
type Element = qreactType.JSX.Element;
|
|
9
|
+
type IntrinsicClassAttributes<T = unknown> = qreactType.JSX.IntrinsicClassAttributes<T>;
|
|
10
|
+
interface ElementClass {
|
|
11
|
+
render: any;
|
|
12
|
+
}
|
|
13
|
+
interface ElementAttributesProperty {
|
|
14
|
+
props: {};
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -11,6 +11,7 @@ import { managementPageURL } from "../../diagnostics/managementPages";
|
|
|
11
11
|
import { t } from "../../2-proxy/schema2";
|
|
12
12
|
import { Querysub } from "../../4-querysub/Querysub";
|
|
13
13
|
import { getLogViewerParams } from "../../diagnostics/logs/IndexedLogs/LogViewerParams";
|
|
14
|
+
import { UsageBar, getUsageThresholds } from "../../library-components/UsageBar";
|
|
14
15
|
|
|
15
16
|
export class MachineDetailPage extends qreact.Component {
|
|
16
17
|
render() {
|
|
@@ -119,16 +120,7 @@ export class MachineDetailPage extends qreact.Component {
|
|
|
119
120
|
<ShowMore className={css.whiteSpace("pre-wrap")} maxHeight={80}>
|
|
120
121
|
{(() => {
|
|
121
122
|
if (typeof value === "object") {
|
|
122
|
-
return <
|
|
123
|
-
<div className={
|
|
124
|
-
css.absolute.pos(0, 0)
|
|
125
|
-
.size(`${value.value / value.max * 100}%`, "100%")
|
|
126
|
-
.hsl(0, 0, 70)
|
|
127
|
-
} />
|
|
128
|
-
<div className={css.relative}>
|
|
129
|
-
{value.type} ({formatNumber(value.value)} / {formatNumber(value.max)})
|
|
130
|
-
</div>
|
|
131
|
-
</div>;
|
|
123
|
+
return <UsageBar label={value.type} value={value.value} max={value.max} {...getUsageThresholds(value.type)} />;
|
|
132
124
|
}
|
|
133
125
|
return value;
|
|
134
126
|
})()}
|
|
@@ -11,6 +11,7 @@ import { formatDateJSX } from "../../misc/formatJSX";
|
|
|
11
11
|
import { DeployMachineButtons, RenderGitRefInfo } from "./deployButtons";
|
|
12
12
|
import { isDefined } from "../../misc";
|
|
13
13
|
import { Anchor, createLink } from "../../library-components/ATag";
|
|
14
|
+
import { UsageBar, getUsageThresholds } from "../../library-components/UsageBar";
|
|
14
15
|
|
|
15
16
|
export class MachinesListPage extends qreact.Component {
|
|
16
17
|
state = t.state({
|
|
@@ -182,17 +183,7 @@ export class MachinesListPage extends qreact.Component {
|
|
|
182
183
|
<RenderGitRefInfo gitRef={machineInfo.gitRef} />
|
|
183
184
|
{Object.values(machineInfo.info).map(value => {
|
|
184
185
|
if (typeof value === "string") return undefined;
|
|
185
|
-
return <
|
|
186
|
-
<div className={
|
|
187
|
-
css.absolute.pos(0, 0)
|
|
188
|
-
.size(`${value.value / value.max * 100}%`, "100%")
|
|
189
|
-
.hsl(0, 0, 70)
|
|
190
|
-
} />
|
|
191
|
-
<div className={css.relative}>
|
|
192
|
-
{value.type} ({formatNumber(value.value)} / {formatNumber(value.max)})
|
|
193
|
-
</div>
|
|
194
|
-
</div>;
|
|
195
|
-
|
|
186
|
+
return <UsageBar label={value.type} value={value.value} max={value.max} {...getUsageThresholds(value.type)} />;
|
|
196
187
|
})}
|
|
197
188
|
</div>
|
|
198
189
|
<div className={css.vbox(4).flexGrow(1)}>
|
|
@@ -5,7 +5,7 @@ import { css } from "typesafecss";
|
|
|
5
5
|
import { t } from "../../2-proxy/schema2";
|
|
6
6
|
import { Querysub } from "../../4-querysub/Querysub";
|
|
7
7
|
import { currentViewParam, selectedServiceIdParam, selectedMachineIdParam } from "../urlParams";
|
|
8
|
-
import { formatDateTimeDetailed, formatTime, formatVeryNiceDateTime } from "socket-function/src/formatting/format";
|
|
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
11
|
import { deepCloneJSON, nextId, sort, timeInSecond } from "socket-function/src/misc";
|
|
@@ -46,7 +46,9 @@ export class ServiceDetailPage extends qreact.Component {
|
|
|
46
46
|
}),
|
|
47
47
|
// Milliseconds; 0 means no scheduled time picked yet (use Deploy Now instead)
|
|
48
48
|
switchTime: t.number(0),
|
|
49
|
-
// Seconds; 0 means use the
|
|
49
|
+
// Seconds until the release goes live; 0 means use the default (DEFAULT_OVERLAP_TIME)
|
|
50
|
+
releaseDelayOverride: t.number(0),
|
|
51
|
+
// Seconds the old instances outlive the release; 0 means use the default (DEFAULT_OVERLAP_TIME). The no-overlap deploy buttons are how you deploy with an actual 0.
|
|
50
52
|
overlapOverride: t.number(0),
|
|
51
53
|
});
|
|
52
54
|
|
|
@@ -123,6 +125,7 @@ export class ServiceDetailPage extends qreact.Component {
|
|
|
123
125
|
Querysub.commit(() => {
|
|
124
126
|
this.state.editorState = stripReleaseState(deployConfig);
|
|
125
127
|
this.state.switchTime = 0;
|
|
128
|
+
this.state.releaseDelayOverride = 0;
|
|
126
129
|
this.state.overlapOverride = 0;
|
|
127
130
|
});
|
|
128
131
|
} finally {
|
|
@@ -151,11 +154,13 @@ export class ServiceDetailPage extends qreact.Component {
|
|
|
151
154
|
content: <div className={css.fixed.pos(0, 0).size("100vw", "100vh").hsla(0, 0, 0, 0.5).display("flex").alignItems("center").justifyContent("center")}>
|
|
152
155
|
<div className={css.vbox(12).pad2(20).hsl(0, 0, 12).colorhsl(0, 0, 90).bord2(0, 0, 40) + " keepModalsOpen"}>
|
|
153
156
|
<div className={css.boldStyle.fontSize(16)}>⚡ Force deploy now?</div>
|
|
154
|
-
<div>
|
|
157
|
+
<div>{deployConfig.parameters.overlapTime
|
|
158
|
+
? `This config becomes live immediately. The old instances keep running for ${formatTime(deployConfig.parameters.overlapTime)} before shutting down.`
|
|
159
|
+
: "This config becomes live immediately. The old instances are shut down right away, with no overlap."}</div>
|
|
155
160
|
<div className={css.hbox(10)}>
|
|
156
161
|
<Button hue={0} onClick={() => {
|
|
157
162
|
modal && modal.close();
|
|
158
|
-
deployConfig.parameters.releaseTime =
|
|
163
|
+
deployConfig.parameters.releaseTime = Date.now();
|
|
159
164
|
this.deployConfig(deployConfig);
|
|
160
165
|
}}>⚡ Force Deploy Now</Button>
|
|
161
166
|
<Button onClick={() => modal && modal.close()}>Cancel</Button>
|
|
@@ -251,7 +256,18 @@ export class ServiceDetailPage extends qreact.Component {
|
|
|
251
256
|
if (!releaseTime || Date.now() >= releaseTime || !originalConfig.oldParameters) return undefined;
|
|
252
257
|
return {
|
|
253
258
|
switchTime: releaseTime,
|
|
254
|
-
overlapTime: originalConfig.parameters.overlapTime
|
|
259
|
+
overlapTime: originalConfig.parameters.overlapTime ?? DEFAULT_OVERLAP_TIME,
|
|
260
|
+
};
|
|
261
|
+
})();
|
|
262
|
+
// By our calculation both the old and new parameters' instances are running (the release passed, but the old instances haven't been shut down yet). Purely time-based — we don't verify the instances are actually running.
|
|
263
|
+
const overlappingDeploy = (() => {
|
|
264
|
+
let releaseTime = originalConfig.parameters.releaseTime || 0;
|
|
265
|
+
let overlapTime = originalConfig.parameters.overlapTime ?? DEFAULT_OVERLAP_TIME;
|
|
266
|
+
if (!releaseTime || !originalConfig.oldParameters) return undefined;
|
|
267
|
+
if (Date.now() < releaseTime || Date.now() >= releaseTime + overlapTime) return undefined;
|
|
268
|
+
return {
|
|
269
|
+
oldParameters: originalConfig.oldParameters,
|
|
270
|
+
shutdownTime: releaseTime + overlapTime,
|
|
255
271
|
};
|
|
256
272
|
})();
|
|
257
273
|
|
|
@@ -378,16 +394,15 @@ export class ServiceDetailPage extends qreact.Component {
|
|
|
378
394
|
this.updateEditorState(updated);
|
|
379
395
|
}}
|
|
380
396
|
/>
|
|
381
|
-
<
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
/>
|
|
397
|
+
<div className={css.hbox(12)}>
|
|
398
|
+
{(() => {
|
|
399
|
+
let lastDeployTime = getLiveServiceParameters(originalConfig).releaseTime;
|
|
400
|
+
if (!lastDeployTime || lastDeployTime > now) return undefined;
|
|
401
|
+
return <span>
|
|
402
|
+
Last deploy at {formatDateTime(lastDeployTime)} ({formatTime(now - lastDeployTime)} ago)
|
|
403
|
+
</span>;
|
|
404
|
+
})()}
|
|
405
|
+
</div>
|
|
391
406
|
{/* Machine Status */}
|
|
392
407
|
{config.parameters.deploy && <div className={css.vbox(8).fillWidth}>
|
|
393
408
|
<h3>Deployed Machines ({machineIds.length})</h3>
|
|
@@ -668,7 +683,7 @@ export class ServiceDetailPage extends qreact.Component {
|
|
|
668
683
|
<ParametersDiff base={getLiveServiceParameters(originalConfig)} next={originalConfig.parameters} />
|
|
669
684
|
<div className={css.vbox(4)}>
|
|
670
685
|
<div>
|
|
671
|
-
|
|
686
|
+
Old instances shut down in {formatTime(scheduledDeploy.switchTime + scheduledDeploy.overlapTime - now)} ({formatDateTimeDetailed(scheduledDeploy.switchTime + scheduledDeploy.overlapTime)})
|
|
672
687
|
</div>
|
|
673
688
|
<div className={css.hbox(8)}>
|
|
674
689
|
<button
|
|
@@ -684,10 +699,27 @@ export class ServiceDetailPage extends qreact.Component {
|
|
|
684
699
|
}}>
|
|
685
700
|
Cancel Scheduled Deploy
|
|
686
701
|
</button>
|
|
702
|
+
<button
|
|
703
|
+
className={css.pad2(12, 8).button.bord2(0, 70, 40).hsl(0, 70, 90)}
|
|
704
|
+
disabled={this.state.isDeploying}
|
|
705
|
+
title="Changes the scheduled deploy to go live immediately"
|
|
706
|
+
onClick={() => {
|
|
707
|
+
this.confirmForceDeployNow(deepCloneJSON(stripReleaseState(originalConfig)));
|
|
708
|
+
}}>
|
|
709
|
+
⚡ Force Schedule Now
|
|
710
|
+
</button>
|
|
687
711
|
</div>
|
|
688
712
|
</div>
|
|
689
713
|
</div>}
|
|
690
714
|
|
|
715
|
+
{/* The release went live, but the old instances are still running out their overlap */}
|
|
716
|
+
{overlappingDeploy && <div className={css.vbox(10).pad2(12).fillWidth.bord2(280, 60, 50).hsl(280, 40, 95)}>
|
|
717
|
+
<div className={css.boldStyle.fontSize(15)}>
|
|
718
|
+
🔀 Overlapping configs — the new config is live, and the old instances shut down in {formatTime(overlappingDeploy.shutdownTime - now)} ({formatDateTimeDetailed(overlappingDeploy.shutdownTime)})
|
|
719
|
+
</div>
|
|
720
|
+
<ParametersDiff base={overlappingDeploy.oldParameters} next={originalConfig.parameters} />
|
|
721
|
+
</div>}
|
|
722
|
+
|
|
691
723
|
{/* The EDITOR's changes (browser-only) — deploying them schedules a (new) deploy */}
|
|
692
724
|
{(() => {
|
|
693
725
|
if (!hasUnsavedChanges) return undefined;
|
|
@@ -707,18 +739,19 @@ export class ServiceDetailPage extends qreact.Component {
|
|
|
707
739
|
<span title={`${configCompareKey(config)} !== ${configCompareKey(deployedConfig)}`}>Only the title / notes changed, so this saves immediately</span>
|
|
708
740
|
</div>;
|
|
709
741
|
}
|
|
710
|
-
let
|
|
742
|
+
let releaseDelaySeconds = this.state.releaseDelayOverride || Math.round(DEFAULT_OVERLAP_TIME / timeInSecond);
|
|
743
|
+
let releaseDelay = releaseDelaySeconds * timeInSecond;
|
|
744
|
+
let overlapSeconds = this.state.overlapOverride || Math.round(DEFAULT_OVERLAP_TIME / timeInSecond);
|
|
711
745
|
let overlap = overlapSeconds * timeInSecond;
|
|
712
746
|
let scheduledTime = this.state.switchTime;
|
|
713
|
-
|
|
714
|
-
const makeDeployConfig = (releaseTime: number) => {
|
|
747
|
+
const makeDeployConfig = (releaseTime: number, overlapTime = overlap) => {
|
|
715
748
|
let deployConfig = deepCloneJSON(config);
|
|
716
749
|
deployConfig.parameters.releaseTime = releaseTime;
|
|
717
|
-
deployConfig.parameters.overlapTime =
|
|
750
|
+
deployConfig.parameters.overlapTime = overlapTime;
|
|
718
751
|
return deployConfig;
|
|
719
752
|
};
|
|
720
|
-
// The switch time is inferred from the
|
|
721
|
-
let effectiveSwitchTime = scheduledTime || now +
|
|
753
|
+
// The switch time is inferred from the release delay, unless one is explicitly set
|
|
754
|
+
let effectiveSwitchTime = scheduledTime || now + releaseDelay;
|
|
722
755
|
return <div className={css.vbox(10).pad2(12).fillWidth.bord2(45, 70, 45).hsl(45, 60, 95)}>
|
|
723
756
|
<div className={css.boldStyle.fontSize(15)}>
|
|
724
757
|
✏️ Edited (only in this browser, until deployed)
|
|
@@ -729,10 +762,19 @@ export class ServiceDetailPage extends qreact.Component {
|
|
|
729
762
|
className={css.pad2(12, 8).button.bord2(0, 0, 20).hsl(120, 70, 85).fontWeight("bold")}
|
|
730
763
|
disabled={this.state.isDeploying}
|
|
731
764
|
onClick={() => {
|
|
732
|
-
this.deployConfig(makeDeployConfig(scheduledTime || Date.now() +
|
|
765
|
+
this.deployConfig(makeDeployConfig(scheduledTime || Date.now() + releaseDelay));
|
|
733
766
|
}}>
|
|
734
767
|
{this.state.isDeploying && "⏳ Scheduling..." || scheduledDeploy && "🔁 Replace Scheduled Deploy" || "🚀 Schedule Deploy"}
|
|
735
768
|
</button>
|
|
769
|
+
<button
|
|
770
|
+
className={css.pad2(12, 8).button.bord2(0, 0, 20).hsl(35, 70, 85)}
|
|
771
|
+
disabled={this.state.isDeploying}
|
|
772
|
+
title="Deploys with the configured release delay, but the old instances shut down immediately at release time (overlap of 0)"
|
|
773
|
+
onClick={() => {
|
|
774
|
+
this.deployConfig(makeDeployConfig(scheduledTime || Date.now() + releaseDelay, 0));
|
|
775
|
+
}}>
|
|
776
|
+
Deploy, No Overlap
|
|
777
|
+
</button>
|
|
736
778
|
<div className={css.opacity(scheduledTime && 1 || 0.5)}>
|
|
737
779
|
<InputLabel
|
|
738
780
|
label="Switch at"
|
|
@@ -749,34 +791,48 @@ export class ServiceDetailPage extends qreact.Component {
|
|
|
749
791
|
onClick={() => {
|
|
750
792
|
this.state.switchTime = 0;
|
|
751
793
|
}}>
|
|
752
|
-
↺ Reset to
|
|
794
|
+
↺ Reset to delay-based time
|
|
753
795
|
</button>}
|
|
754
796
|
<div className={css.opacity(scheduledTime && 0.5 || 1)}>
|
|
755
797
|
<InputLabel
|
|
756
|
-
label="
|
|
798
|
+
label="Release in (seconds)"
|
|
757
799
|
integer
|
|
758
|
-
value={
|
|
800
|
+
value={releaseDelaySeconds}
|
|
759
801
|
onChangeValue={value => {
|
|
760
|
-
this.state.
|
|
802
|
+
this.state.releaseDelayOverride = Number(value) || 0;
|
|
761
803
|
}}
|
|
762
804
|
/>
|
|
763
805
|
</div>
|
|
806
|
+
<InputLabel
|
|
807
|
+
label="Overlap (seconds)"
|
|
808
|
+
integer
|
|
809
|
+
value={overlapSeconds}
|
|
810
|
+
onChangeValue={value => {
|
|
811
|
+
this.state.overlapOverride = Number(value) || 0;
|
|
812
|
+
}}
|
|
813
|
+
/>
|
|
764
814
|
</div>
|
|
765
815
|
{!!scheduledTime && scheduledTime <= now && <span className={css.colorhsl(0, 85, 45).fontWeight("bold")}>
|
|
766
816
|
⚠️ The switch time is in the past — deploying switches immediately
|
|
767
817
|
</span>}
|
|
768
|
-
{notEnoughTime && <span className={css.colorhsl(35, 85, 40).fontWeight("bold")}>
|
|
769
|
-
⚠️ The switch time is sooner than the overlap ({formatTime(overlap)}) — the new instances won't be fully started before the old ones shut down
|
|
770
|
-
</span>}
|
|
771
818
|
<div className={css.hbox(8)}>
|
|
772
819
|
<button
|
|
773
820
|
className={css.pad2(12, 8).button.bord2(0, 70, 40).hsl(0, 70, 90)}
|
|
774
821
|
disabled={this.state.isDeploying}
|
|
775
822
|
onClick={() => {
|
|
776
|
-
this.confirmForceDeployNow(makeDeployConfig(
|
|
823
|
+
this.confirmForceDeployNow(makeDeployConfig(Date.now()));
|
|
777
824
|
}}>
|
|
778
825
|
⚡ Force Deploy Now
|
|
779
826
|
</button>
|
|
827
|
+
<button
|
|
828
|
+
className={css.pad2(12, 8).button.bord2(0, 90, 30).hsl(0, 85, 65).colorhsl(0, 0, 100).fontWeight("bold")}
|
|
829
|
+
disabled={this.state.isDeploying}
|
|
830
|
+
title="Deploys immediately AND shuts down the old instances immediately (overlap of 0)"
|
|
831
|
+
onClick={() => {
|
|
832
|
+
this.confirmForceDeployNow(makeDeployConfig(Date.now(), 0));
|
|
833
|
+
}}>
|
|
834
|
+
⚡ Force Deploy Now, No Overlap
|
|
835
|
+
</button>
|
|
780
836
|
</div>
|
|
781
837
|
</div>;
|
|
782
838
|
})()}
|
|
@@ -790,21 +846,27 @@ export class ServiceDetailPage extends qreact.Component {
|
|
|
790
846
|
)}
|
|
791
847
|
|
|
792
848
|
{/* Config Editor */}
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
849
|
+
{(() => {
|
|
850
|
+
const discardButton = hasUnsavedChanges && <button
|
|
851
|
+
className={css.pad2(12, 8).button.bord2(0, 0, 20).hsl(50, 80, 50)}
|
|
852
|
+
onClick={() => {
|
|
853
|
+
this.state.editorState = undefined;
|
|
854
|
+
}}>
|
|
855
|
+
Discard Changes
|
|
856
|
+
</button>;
|
|
857
|
+
return <>
|
|
858
|
+
{discardButton}
|
|
859
|
+
<TypedConfigEditor
|
|
860
|
+
value={config}
|
|
861
|
+
onValueChange={(newValue) => {
|
|
862
|
+
this.updateEditorState(newValue as ServiceConfig);
|
|
863
|
+
}}
|
|
864
|
+
typeDefinition={serviceConfigType}
|
|
865
|
+
sizeClassName={css.size(1000, 600)}
|
|
866
|
+
/>
|
|
867
|
+
{discardButton}
|
|
868
|
+
</>;
|
|
869
|
+
})()}
|
|
808
870
|
</div>;
|
|
809
871
|
}
|
|
810
872
|
}
|
|
@@ -46,7 +46,7 @@ export class UpdateButtons extends qreact.Component<{
|
|
|
46
46
|
state = t.state({
|
|
47
47
|
isDeploying: t.type(false),
|
|
48
48
|
});
|
|
49
|
-
// Deploys all outdated services to latestRef. Scheduled releases
|
|
49
|
+
// Deploys all outdated services to latestRef. Scheduled releases delay the release by the configured overlap (an unrelated value, but a reasonable delay); immediate releases right away.
|
|
50
50
|
private deployAll(outdatedServices: ServiceConfig[], latestRef: string, immediate: boolean) {
|
|
51
51
|
this.state.isDeploying = true;
|
|
52
52
|
// Props are synchronized state, so everything the async work needs is cloned into plain locals HERE, in the synced part
|
|
@@ -56,7 +56,7 @@ export class UpdateButtons extends qreact.Component<{
|
|
|
56
56
|
if (immediate) {
|
|
57
57
|
updated.parameters.releaseTime = undefined;
|
|
58
58
|
} else {
|
|
59
|
-
let overlap = updated.parameters.overlapTime
|
|
59
|
+
let overlap = updated.parameters.overlapTime ?? DEFAULT_OVERLAP_TIME;
|
|
60
60
|
updated.parameters.releaseTime = Date.now() + overlap;
|
|
61
61
|
}
|
|
62
62
|
return updated;
|
|
@@ -183,8 +183,8 @@ export class UpdateServiceButtons extends qreact.Component<{
|
|
|
183
183
|
// Props are synchronized state, so everything the async work needs is cloned into plain locals HERE, in the synced part
|
|
184
184
|
let service = deepCloneJSON(this.props.service);
|
|
185
185
|
service.parameters.gitRef = gitInfo.latestRef;
|
|
186
|
-
//
|
|
187
|
-
let overlap = service.parameters.overlapTime
|
|
186
|
+
// Delay the release by the configured overlap (an unrelated value, but a reasonable delay)
|
|
187
|
+
let overlap = service.parameters.overlapTime ?? DEFAULT_OVERLAP_TIME;
|
|
188
188
|
service.parameters.releaseTime = Date.now() + overlap;
|
|
189
189
|
Querysub.onCommitFinished(async () => {
|
|
190
190
|
try {
|
|
@@ -25,6 +25,7 @@ import { onServiceConfigChange, scheduleNodeShutdown } from "./machineController
|
|
|
25
25
|
import { PromiseObj } from "../promise";
|
|
26
26
|
import path from "path";
|
|
27
27
|
import { fsExistsAsync } from "../fs";
|
|
28
|
+
import { ALIVE_WINDOW_FOREVER, ParametersTimelineEntry, syncParametersTimelineFiles } from "./parametersTimeline";
|
|
28
29
|
|
|
29
30
|
// The deployed-service stdout log (pipe.txt) is a rolling buffer: once it grows
|
|
30
31
|
// to PIPE_FILE_LINE_LIMIT lines it is truncated down to PIPE_FILE_LINE_KEEP. The
|
|
@@ -132,9 +133,9 @@ const SCREEN_SUFFIX = "-dply";
|
|
|
132
133
|
export function getScreenName(config: { serviceKey: string; index: number }): string {
|
|
133
134
|
return `${config.serviceKey}-${config.index}${SCREEN_SUFFIX}`.replace(/[^a-zA-Z0-9\-_]/g, "_");
|
|
134
135
|
}
|
|
135
|
-
// The
|
|
136
|
-
export function
|
|
137
|
-
return getScreenName({ serviceKey: config.serviceKey + "-
|
|
136
|
+
// The old instances kept alive during a release overlap. The canonical screens restart with the new config at release time; these keep the OLD version serving until releaseTime + overlapTime, and are killed shortly after.
|
|
137
|
+
export function getOldScreenName(config: { serviceKey: string; index: number }): string {
|
|
138
|
+
return getScreenName({ serviceKey: config.serviceKey + "-old", index: config.index });
|
|
138
139
|
}
|
|
139
140
|
|
|
140
141
|
export async function streamScreenOutput(config: {
|
|
@@ -585,35 +586,41 @@ const ensureGitSynced = measureWrap(async function ensureGitSynced(config: {
|
|
|
585
586
|
let launchesPerService = new Map<string, number>();
|
|
586
587
|
let lastLaunchedTimePerService = new Map<string, number>();
|
|
587
588
|
|
|
588
|
-
// Starts (or verifies) the "-
|
|
589
|
-
async function
|
|
590
|
-
|
|
589
|
+
// Starts (or verifies) the "-old" instance of a service during a release overlap, running the old (no longer live) parameters. It keeps the old version serving while the new canonical instances start up, and is killed by the unused-screen cleanup afterwards (once releaseTime + overlapTime passes we stop ensuring it).
|
|
590
|
+
async function ensureOldInstance(config: {
|
|
591
|
+
old: ServiceParameters;
|
|
592
|
+
aliveWindow: [number, number];
|
|
591
593
|
index: number;
|
|
592
594
|
screenNamesUsed: Set<string>;
|
|
593
595
|
screenStateMap: Map<string, { screenName: string; isProcessRunning: boolean; pid: string }>;
|
|
594
596
|
}) {
|
|
595
|
-
let {
|
|
596
|
-
let
|
|
597
|
-
config.screenNamesUsed.add(
|
|
597
|
+
let { old, index } = config;
|
|
598
|
+
let oldScreenName = getOldScreenName({ serviceKey: old.key, index });
|
|
599
|
+
config.screenNamesUsed.add(oldScreenName);
|
|
600
|
+
let shutdownTime = config.aliveWindow[1];
|
|
601
|
+
const syncTimeline = async () => {
|
|
602
|
+
let pid = parseInt(config.screenStateMap.get(oldScreenName)?.pid || "") || undefined;
|
|
603
|
+
await syncParametersTimelineFiles(os.homedir() + "/" + SERVICE_FOLDER + oldScreenName + "/", [{ pid, aliveWindow: config.aliveWindow, parameters: old }]);
|
|
604
|
+
};
|
|
598
605
|
|
|
599
606
|
let root = os.homedir() + "/" + SERVICE_FOLDER;
|
|
600
|
-
let folder = root +
|
|
607
|
+
let folder = root + oldScreenName + "/";
|
|
601
608
|
let gitFolder = folder + "git/";
|
|
602
609
|
await fs.promises.mkdir(gitFolder, { recursive: true });
|
|
603
|
-
if (
|
|
610
|
+
if (old.gitRef) {
|
|
604
611
|
let prevGitRef = "";
|
|
605
612
|
try {
|
|
606
613
|
prevGitRef = await getGitRefLive(gitFolder);
|
|
607
614
|
} catch { }
|
|
608
615
|
await ensureGitSynced({
|
|
609
616
|
gitFolder,
|
|
610
|
-
repoUrl:
|
|
611
|
-
gitRef:
|
|
617
|
+
repoUrl: old.repoUrl,
|
|
618
|
+
gitRef: old.gitRef,
|
|
612
619
|
});
|
|
613
620
|
let afterGitRef = await getGitRefLive(gitFolder);
|
|
614
621
|
let nodeModulesMissing = !await fsExistsAsync(gitFolder + "node_modules");
|
|
615
622
|
if (afterGitRef !== prevGitRef || nodeModulesMissing) {
|
|
616
|
-
console.log(green(`Yarn installing for ${magenta(
|
|
623
|
+
console.log(green(`Yarn installing for ${magenta(oldScreenName)} (ref ${prevGitRef} -> ${afterGitRef}, nodeModulesMissing=${nodeModulesMissing})`));
|
|
617
624
|
await runPromise(`yarn install --mutex network`, { cwd: gitFolder });
|
|
618
625
|
}
|
|
619
626
|
}
|
|
@@ -623,23 +630,27 @@ async function ensureNextInstance(config: {
|
|
|
623
630
|
if (await fsExistsAsync(parameterPath)) {
|
|
624
631
|
prevParameters = await fs.promises.readFile(parameterPath, "utf8");
|
|
625
632
|
}
|
|
626
|
-
let newParametersString = JSON.stringify(
|
|
627
|
-
let sameParameters = sameRestartParameters(prevParameters,
|
|
628
|
-
let screenIsRunning = config.screenStateMap.get(
|
|
633
|
+
let newParametersString = JSON.stringify(old);
|
|
634
|
+
let sameParameters = sameRestartParameters(prevParameters, old);
|
|
635
|
+
let screenIsRunning = config.screenStateMap.get(oldScreenName)?.isProcessRunning;
|
|
629
636
|
if (sameParameters && screenIsRunning) {
|
|
630
637
|
if (prevParameters !== newParametersString) {
|
|
631
638
|
await fs.promises.writeFile(parameterPath, newParametersString);
|
|
632
639
|
}
|
|
633
|
-
|
|
640
|
+
await notifyServiceShutdown(oldScreenName, shutdownTime);
|
|
641
|
+
await syncTimeline();
|
|
642
|
+
console.log(green(`Verified release-overlap old instance ${magenta(oldScreenName)} is running`));
|
|
634
643
|
return;
|
|
635
644
|
}
|
|
636
645
|
|
|
637
|
-
console.log(`Starting release-overlap instance ${magenta(
|
|
646
|
+
console.log(`Starting release-overlap old instance ${magenta(oldScreenName)}, with ${newParametersString}, isRunning = ${screenIsRunning}, sameParameters = ${sameParameters}`);
|
|
638
647
|
await fs.promises.writeFile(parameterPath, newParametersString);
|
|
639
648
|
await runScreenCommand({
|
|
640
|
-
screenName:
|
|
641
|
-
command:
|
|
649
|
+
screenName: oldScreenName,
|
|
650
|
+
command: old.command,
|
|
642
651
|
});
|
|
652
|
+
await notifyServiceShutdown(oldScreenName, shutdownTime);
|
|
653
|
+
await syncTimeline();
|
|
643
654
|
}
|
|
644
655
|
|
|
645
656
|
function sameRestartParameters(prevParametersJSON: string, next: ServiceParameters): boolean {
|
|
@@ -656,8 +667,8 @@ const resyncServicesBase = runInSerial(measureWrap(async function resyncServices
|
|
|
656
667
|
let machineId = getOwnMachineId(getDomain());
|
|
657
668
|
let allConfigs = await getEffectiveServiceConfigs();
|
|
658
669
|
let relevantConfigs = allConfigs
|
|
659
|
-
.filter(config => getMachineIdList(getLiveServiceParameters(config)).includes(machineId) || getMachineIdList(config.parameters).includes(machineId))
|
|
660
|
-
.filter(x => getLiveServiceParameters(x).deploy || x.parameters.deploy);
|
|
670
|
+
.filter(config => getMachineIdList(getLiveServiceParameters(config)).includes(machineId) || getMachineIdList(config.parameters).includes(machineId) || config.oldParameters && getMachineIdList(config.oldParameters).includes(machineId))
|
|
671
|
+
.filter(x => getLiveServiceParameters(x).deploy || x.parameters.deploy || x.oldParameters?.deploy);
|
|
661
672
|
|
|
662
673
|
let machineInfo = await getLiveMachineInfo();
|
|
663
674
|
|
|
@@ -669,25 +680,29 @@ const resyncServicesBase = runInSerial(measureWrap(async function resyncServices
|
|
|
669
680
|
for (let record of relevantConfigs) {
|
|
670
681
|
let config = { ...record, parameters: getLiveServiceParameters(record) };
|
|
671
682
|
let releaseTime = record.parameters.releaseTime || 0;
|
|
683
|
+
// The overlap length always comes from the NEWEST parameters, even though it governs how long the OLD instances outlive the release
|
|
684
|
+
let overlapTime = record.parameters.overlapTime ?? DEFAULT_OVERLAP_TIME;
|
|
672
685
|
let isUpcomingRelease = !!releaseTime && Date.now() < releaseTime && !!record.oldParameters;
|
|
673
|
-
let
|
|
674
|
-
if (isUpcomingRelease) {
|
|
686
|
+
let isOverlapRunning = !!releaseTime && Date.now() >= releaseTime && Date.now() < releaseTime + overlapTime && !!record.oldParameters;
|
|
687
|
+
if (isUpcomingRelease || isOverlapRunning) {
|
|
675
688
|
upcomingReleases.push({ releaseTime, overlapTime });
|
|
676
|
-
console.log(magenta(`Release in flight for ${record.serviceId} (${record.parameters.key}): new instances start at ${new Date(releaseTime
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
}
|
|
689
|
+
console.log(magenta(`Release in flight for ${record.serviceId} (${record.parameters.key}): new instances start at ${new Date(releaseTime).toLocaleString()}${isOverlapRunning && " (overlap running)" || ""}, old instances shut down at ${new Date(releaseTime + overlapTime).toLocaleString()}`));
|
|
690
|
+
}
|
|
691
|
+
// The old-overlap instances follow the OLD parameters' machineIds, so machines being removed by the release still keep the old version alive through the overlap (their canonical screens die at release time)
|
|
692
|
+
if (isOverlapRunning && record.oldParameters?.deploy) {
|
|
693
|
+
let oldParameters = record.oldParameters;
|
|
694
|
+
let oldTargets = getMachineTargets(oldParameters).filter(target => target.machineId === machineId);
|
|
695
|
+
for (let i = 0; i < oldTargets.length; i++) {
|
|
696
|
+
try {
|
|
697
|
+
await ensureOldInstance({
|
|
698
|
+
old: { ...oldParameters, command: applyCommandTemplate(oldParameters.command, oldTargets[i].variables) },
|
|
699
|
+
aliveWindow: [releaseTime, releaseTime + overlapTime],
|
|
700
|
+
index: i,
|
|
701
|
+
screenNamesUsed,
|
|
702
|
+
screenStateMap,
|
|
703
|
+
});
|
|
704
|
+
} catch (e: any) {
|
|
705
|
+
console.error(`Error starting release-overlap old instance for ${record.serviceId} (${record.parameters.key}): ${e.stack}`);
|
|
691
706
|
}
|
|
692
707
|
}
|
|
693
708
|
}
|
|
@@ -748,6 +763,24 @@ const resyncServicesBase = runInSerial(measureWrap(async function resyncServices
|
|
|
748
763
|
let sameParameters = sameRestartParameters(prevParameters, instanceParameters);
|
|
749
764
|
let screenIsRunning = screenStateMap.get(screenName)?.isProcessRunning;
|
|
750
765
|
|
|
766
|
+
const syncTimeline = async (pid: string | undefined) => {
|
|
767
|
+
let entries: ParametersTimelineEntry[] = [{
|
|
768
|
+
pid: parseInt(pid || "") || undefined,
|
|
769
|
+
aliveWindow: [config.parameters.releaseTime || 0, isUpcomingRelease ? releaseTime : ALIVE_WINDOW_FOREVER],
|
|
770
|
+
parameters: instanceParameters,
|
|
771
|
+
}];
|
|
772
|
+
if (isUpcomingRelease) {
|
|
773
|
+
let futureTargets = getMachineTargets(record.parameters).filter(target => target.machineId === machineId);
|
|
774
|
+
if (i < futureTargets.length) {
|
|
775
|
+
entries.push({
|
|
776
|
+
aliveWindow: [releaseTime, ALIVE_WINDOW_FOREVER],
|
|
777
|
+
parameters: { ...record.parameters, command: applyCommandTemplate(record.parameters.command, futureTargets[i].variables) },
|
|
778
|
+
});
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
await syncParametersTimelineFiles(folder, entries);
|
|
782
|
+
};
|
|
783
|
+
|
|
751
784
|
let nodePathId = folder + SERVICE_NODE_FILE_NAME;
|
|
752
785
|
if (await fsExistsAsync(nodePathId)) {
|
|
753
786
|
let nodeId = await fs.promises.readFile(nodePathId, "utf8");
|
|
@@ -758,6 +791,7 @@ const resyncServicesBase = runInSerial(measureWrap(async function resyncServices
|
|
|
758
791
|
if (prevParameters !== newParametersString) {
|
|
759
792
|
await fs.promises.writeFile(parameterPath, newParametersString);
|
|
760
793
|
}
|
|
794
|
+
await syncTimeline(screenStateMap.get(screenName)?.pid);
|
|
761
795
|
console.log(green(`Verified ${magenta(screenName)} is running`));
|
|
762
796
|
continue;
|
|
763
797
|
}
|
|
@@ -798,6 +832,7 @@ const resyncServicesBase = runInSerial(measureWrap(async function resyncServices
|
|
|
798
832
|
throw new Error(`${logs}\n\nService ${magenta(screenName)} is not running after starting. Trying again in ${formatTime(MACHINE_RESYNC_INTERVAL)}, or on next change. Last logs above.`);
|
|
799
833
|
}
|
|
800
834
|
console.log(green(`Service ${magenta(screenName)} is verified to be running after starting.`));
|
|
835
|
+
await syncTimeline(foundScreen?.pid);
|
|
801
836
|
|
|
802
837
|
launchCount++;
|
|
803
838
|
lastLaunchedTime = Date.now();
|
|
@@ -838,13 +873,13 @@ async function resyncServices() {
|
|
|
838
873
|
}
|
|
839
874
|
}
|
|
840
875
|
|
|
841
|
-
// The regular resync poll is too coarse for release boundaries (the
|
|
876
|
+
// The regular resync poll is too coarse for release boundaries (the release itself and the overlap end), so schedule an extra resync at the next boundary.
|
|
842
877
|
let cancelReleaseResync: (() => void) | undefined;
|
|
843
878
|
function scheduleReleaseResync(upcomingReleases: { releaseTime: number; overlapTime: number }[]) {
|
|
844
879
|
let now = Date.now();
|
|
845
880
|
let next: number | undefined;
|
|
846
881
|
for (let release of upcomingReleases) {
|
|
847
|
-
for (let boundary of [release.releaseTime
|
|
882
|
+
for (let boundary of [release.releaseTime, release.releaseTime + release.overlapTime]) {
|
|
848
883
|
if (boundary <= now) continue;
|
|
849
884
|
if (next === undefined || boundary < next) {
|
|
850
885
|
next = boundary;
|