querysub 0.510.0 → 0.512.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 +1 -1
- package/spec.txt +0 -8
- package/src/4-querysub/Querysub.ts +2 -0
- package/src/deployManager/components/MachineDetailPage.tsx +1 -1
- package/src/deployManager/components/ServiceDetailPage.tsx +263 -215
- package/src/deployManager/components/ServicesListPage.tsx +5 -4
- package/src/deployManager/components/Tools.tsx +6 -3
- package/src/deployManager/components/deployButtons.tsx +7 -1
- package/src/deployManager/machineApplyMainCode.ts +82 -75
- package/src/deployManager/machineController.ts +2 -2
- package/src/deployManager/machineSchema.ts +107 -105
- package/src/diagnostics/logs/errorNotifications2/ErrorNotificationPage.tsx +1 -1
- package/src/library-components/SyncedController.ts +3 -2
- package/src/library-components/TypedConfigEditor.tsx +16 -17
- package/src/misc.ts +30 -0
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { SocketFunction } from "socket-function/SocketFunction";
|
|
2
2
|
import { qreact } from "../../4-dom/qreact";
|
|
3
|
-
import { DEFAULT_OVERLAP_TIME, MACHINE_RESYNC_INTERVAL, MachineServiceController, ServiceConfig } from "../machineSchema";
|
|
3
|
+
import { DEFAULT_OVERLAP_TIME, MACHINE_RESYNC_INTERVAL, MachineServiceController, ServiceConfig, ServiceParameters, getLiveServiceParameters, stripReleaseState } from "../machineSchema";
|
|
4
4
|
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 { formatTime, formatVeryNiceDateTime } from "socket-function/src/formatting/format";
|
|
8
|
+
import { 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";
|
|
@@ -32,8 +32,9 @@ import { showModal } from "../../5-diagnostics/Modal";
|
|
|
32
32
|
|
|
33
33
|
export class ServiceDetailPage extends qreact.Component {
|
|
34
34
|
state = t.state({
|
|
35
|
-
|
|
36
|
-
|
|
35
|
+
// 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.
|
|
36
|
+
editorState: t.type<ServiceConfig | undefined>(undefined),
|
|
37
|
+
isDeploying: t.type(false),
|
|
37
38
|
saveError: t.string,
|
|
38
39
|
expandedErrors: t.lookup({
|
|
39
40
|
expanded: t.type(false)
|
|
@@ -51,15 +52,12 @@ export class ServiceDetailPage extends qreact.Component {
|
|
|
51
52
|
|
|
52
53
|
|
|
53
54
|
|
|
54
|
-
private
|
|
55
|
+
private updateEditorState(updatedConfig: ServiceConfig) {
|
|
56
|
+
// Clone, so the editor state never shares objects with (or mutates) the fetched config
|
|
57
|
+
updatedConfig = deepCloneJSON(updatedConfig);
|
|
55
58
|
// Do not let them update the serviceId, as that would break things
|
|
56
59
|
updatedConfig.serviceId = selectedServiceIdParam.value;
|
|
57
|
-
this.state.
|
|
58
|
-
}
|
|
59
|
-
private updateConfigSynced(updatedConfig: ServiceConfig) {
|
|
60
|
-
Querysub.onCommitFinished(() => {
|
|
61
|
-
void this.updateConfig(updatedConfig);
|
|
62
|
-
});
|
|
60
|
+
this.state.editorState = updatedConfig;
|
|
63
61
|
}
|
|
64
62
|
|
|
65
63
|
private async startWatchingOutput(config: {
|
|
@@ -113,30 +111,23 @@ export class ServiceDetailPage extends qreact.Component {
|
|
|
113
111
|
});
|
|
114
112
|
}
|
|
115
113
|
|
|
116
|
-
//
|
|
117
|
-
private
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
updatedConfig.serviceId = selectedServiceIdParam.value;
|
|
124
|
-
this.state.isSaving = true;
|
|
125
|
-
this.state.saveError = "";
|
|
126
|
-
});
|
|
127
|
-
|
|
128
|
-
Querysub.onCommitFinished(async () => {
|
|
114
|
+
// 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.
|
|
115
|
+
private deployConfig(deployConfig: ServiceConfig) {
|
|
116
|
+
// Do not let them update the serviceId, as that would break things
|
|
117
|
+
deployConfig.serviceId = selectedServiceIdParam.value;
|
|
118
|
+
this.state.saveError = "";
|
|
119
|
+
this.state.isDeploying = true;
|
|
120
|
+
this.runControllerAction(async () => {
|
|
129
121
|
try {
|
|
130
|
-
await MachineServiceController(SocketFunction.browserNodeId()).
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
this.state.
|
|
134
|
-
this.state.
|
|
122
|
+
await MachineServiceController(SocketFunction.browserNodeId()).setServiceConfigs.promise([deployConfig]);
|
|
123
|
+
Querysub.commit(() => {
|
|
124
|
+
this.state.editorState = stripReleaseState(deployConfig);
|
|
125
|
+
this.state.switchTime = 0;
|
|
126
|
+
this.state.overlapOverride = 0;
|
|
135
127
|
});
|
|
136
128
|
} finally {
|
|
137
|
-
Querysub.
|
|
138
|
-
this.state.
|
|
139
|
-
this.state.isSaving = false;
|
|
129
|
+
Querysub.commit(() => {
|
|
130
|
+
this.state.isDeploying = false;
|
|
140
131
|
});
|
|
141
132
|
}
|
|
142
133
|
});
|
|
@@ -147,28 +138,25 @@ export class ServiceDetailPage extends qreact.Component {
|
|
|
147
138
|
try {
|
|
148
139
|
await action();
|
|
149
140
|
} catch (error) {
|
|
150
|
-
Querysub.
|
|
141
|
+
Querysub.commit(() => {
|
|
151
142
|
this.state.saveError = error instanceof Error ? error.message : String(error);
|
|
152
143
|
});
|
|
153
144
|
}
|
|
154
145
|
});
|
|
155
146
|
}
|
|
156
147
|
|
|
157
|
-
private confirmForceDeployNow(
|
|
148
|
+
private confirmForceDeployNow(deployConfig: ServiceConfig) {
|
|
158
149
|
let modal: { close: () => void } | undefined;
|
|
159
150
|
modal = showModal({
|
|
160
151
|
content: <div className={css.fixed.pos(0, 0).size("100vw", "100vh").hsla(0, 0, 0, 0.5).display("flex").alignItems("center").justifyContent("center")}>
|
|
161
152
|
<div className={css.vbox(12).pad2(20).hsl(0, 0, 98).bord2(0, 0, 40) + " keepModalsOpen"}>
|
|
162
153
|
<div className={css.boldStyle.fontSize(16)}>⚡ Force deploy now?</div>
|
|
163
|
-
<div>
|
|
154
|
+
<div>This config becomes live immediately. The old instances are shut down right away, with no overlap.</div>
|
|
164
155
|
<div className={css.hbox(10)}>
|
|
165
156
|
<Button hue={0} onClick={() => {
|
|
166
157
|
modal && modal.close();
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
switchTime: Date.now(),
|
|
170
|
-
overlapTime,
|
|
171
|
-
}));
|
|
158
|
+
deployConfig.parameters.releaseTime = undefined;
|
|
159
|
+
this.deployConfig(deployConfig);
|
|
172
160
|
}}>⚡ Force Deploy Now</Button>
|
|
173
161
|
<Button onClick={() => modal && modal.close()}>Cancel</Button>
|
|
174
162
|
</div>
|
|
@@ -186,23 +174,33 @@ export class ServiceDetailPage extends qreact.Component {
|
|
|
186
174
|
let controller = MachineServiceController(SocketFunction.browserNodeId());
|
|
187
175
|
const originalConfig = controller.getServiceConfig(selectedServiceId);
|
|
188
176
|
let serviceConfigType = controller.getServiceConfigType();
|
|
189
|
-
let pendingConfig = controller.getPendingServiceConfig(selectedServiceId);
|
|
190
|
-
let switchover = controller.getServiceSwitchover(selectedServiceId);
|
|
191
177
|
|
|
192
178
|
if (!originalConfig || !serviceConfigType) {
|
|
193
179
|
if (controller.isAnyLoading()) return <div>Loading service...</div>;
|
|
194
180
|
return <div>Service not found</div>;
|
|
195
181
|
}
|
|
196
182
|
|
|
197
|
-
//
|
|
198
|
-
const
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
183
|
+
// Parameters that were deployed but haven't gone live yet
|
|
184
|
+
const scheduledDeploy = (() => {
|
|
185
|
+
let releaseTime = originalConfig.parameters.releaseTime || 0;
|
|
186
|
+
if (!releaseTime || Date.now() >= releaseTime || !originalConfig.oldParameters) return undefined;
|
|
187
|
+
return {
|
|
188
|
+
switchTime: releaseTime,
|
|
189
|
+
overlapTime: originalConfig.parameters.overlapTime || DEFAULT_OVERLAP_TIME,
|
|
190
|
+
};
|
|
191
|
+
})();
|
|
192
|
+
|
|
193
|
+
// The editor shows its own value, else the latest deployed config. Editor values for a DIFFERENT service are ignored (they linger in state when switching services).
|
|
194
|
+
const deployedConfig = stripReleaseState(originalConfig);
|
|
195
|
+
const editorState = this.state.editorState && this.state.editorState.serviceId === selectedServiceId && this.state.editorState || undefined;
|
|
196
|
+
const config = editorState || deployedConfig;
|
|
197
|
+
// Unsaved changes are DERIVED by comparing the editor's value to the deployed config (so editing something back to its deployed value counts as no change)
|
|
198
|
+
const hasUnsavedChanges = !!editorState && configCompareKey(editorState) !== configCompareKey(deployedConfig);
|
|
202
199
|
|
|
203
200
|
// Sort machines by status and heartbeat
|
|
204
201
|
let nextIndexes = new Map<string, number>();
|
|
205
|
-
let
|
|
202
|
+
let machineIds = config.parameters.machineIds || [];
|
|
203
|
+
let machineStatuses = machineIds.map(machineId => {
|
|
206
204
|
let index = nextIndexes.get(machineId) || 0;
|
|
207
205
|
nextIndexes.set(machineId, index + 1);
|
|
208
206
|
let machineInfo = controller.getMachineInfo(machineId);
|
|
@@ -268,38 +266,64 @@ export class ServiceDetailPage extends qreact.Component {
|
|
|
268
266
|
fillWidth
|
|
269
267
|
value={config.info.title}
|
|
270
268
|
onChangeValue={value => {
|
|
271
|
-
|
|
272
|
-
this.
|
|
269
|
+
config.info.title = value;
|
|
270
|
+
this.updateEditorState(config);
|
|
273
271
|
}}
|
|
274
272
|
/>
|
|
275
273
|
<InputLabel
|
|
276
274
|
label="Key"
|
|
277
275
|
value={config.parameters.key}
|
|
278
276
|
onChangeValue={value => {
|
|
279
|
-
|
|
280
|
-
this.
|
|
277
|
+
config.parameters.key = value;
|
|
278
|
+
this.updateEditorState(config);
|
|
281
279
|
}}
|
|
282
280
|
/>
|
|
281
|
+
<div className={css.fillWidth}>
|
|
282
|
+
<InputLabel
|
|
283
|
+
label="Notes"
|
|
284
|
+
textarea
|
|
285
|
+
fillWidth
|
|
286
|
+
value={config.info.notes}
|
|
287
|
+
onChangeValue={value => {
|
|
288
|
+
config.info.notes = value;
|
|
289
|
+
this.updateEditorState(config);
|
|
290
|
+
}}
|
|
291
|
+
/>
|
|
292
|
+
</div>
|
|
283
293
|
</div>
|
|
284
294
|
|
|
285
295
|
<MachinePicker
|
|
286
296
|
label="Machines"
|
|
287
|
-
picked={
|
|
297
|
+
picked={machineIds}
|
|
288
298
|
addPicked={machineId => {
|
|
289
|
-
|
|
290
|
-
|
|
299
|
+
let updated = deepCloneJSON(config);
|
|
300
|
+
updated.parameters.machineIds = [...(updated.parameters.machineIds || []), machineId];
|
|
301
|
+
this.updateEditorState(updated);
|
|
291
302
|
}}
|
|
292
303
|
removePicked={machineId => {
|
|
293
|
-
let
|
|
304
|
+
let updated = deepCloneJSON(config);
|
|
305
|
+
let updatedMachineIds = updated.parameters.machineIds || [];
|
|
306
|
+
let index = updatedMachineIds.indexOf(machineId);
|
|
294
307
|
if (index !== -1) {
|
|
295
|
-
|
|
308
|
+
updatedMachineIds.splice(index, 1);
|
|
296
309
|
}
|
|
297
|
-
|
|
310
|
+
updated.parameters.machineIds = updatedMachineIds;
|
|
311
|
+
this.updateEditorState(updated);
|
|
312
|
+
}}
|
|
313
|
+
/>
|
|
314
|
+
<InputLabel
|
|
315
|
+
label="Deploy overlap (seconds)"
|
|
316
|
+
integer
|
|
317
|
+
value={Math.round((config.parameters.overlapTime || DEFAULT_OVERLAP_TIME) / timeInSecond)}
|
|
318
|
+
onChangeValue={value => {
|
|
319
|
+
let updated = deepCloneJSON(config);
|
|
320
|
+
updated.parameters.overlapTime = (Number(value) || 0) * timeInSecond;
|
|
321
|
+
this.updateEditorState(updated);
|
|
298
322
|
}}
|
|
299
323
|
/>
|
|
300
324
|
{/* Machine Status */}
|
|
301
|
-
{
|
|
302
|
-
<h3>Deployed Machines ({
|
|
325
|
+
{config.parameters.deploy && <div className={css.vbox(8).fillWidth}>
|
|
326
|
+
<h3>Deployed Machines ({machineIds.length})</h3>
|
|
303
327
|
<div className={css.vbox(4).fillWidth}>
|
|
304
328
|
{machineStatuses.map(({ machineId, machineInfo, serviceInfo, isMachineDead, hasError, isDisabled, index }) => {
|
|
305
329
|
if (!machineInfo) return <div key={machineId}>Loading {machineId}...</div>;
|
|
@@ -348,9 +372,9 @@ export class ServiceDetailPage extends qreact.Component {
|
|
|
348
372
|
<div title={formatVeryNiceDateTime(machineInfo.heartbeat)}>
|
|
349
373
|
Heartbeat: {formatTime(now - machineInfo.heartbeat)}
|
|
350
374
|
</div>
|
|
351
|
-
{
|
|
352
|
-
<div className={css.colorhsl(35, 80, 40).fontWeight("bold")} title={formatVeryNiceDateTime(
|
|
353
|
-
⏻ Shuts down in {formatTime(
|
|
375
|
+
{scheduledDeploy && (
|
|
376
|
+
<div className={css.colorhsl(35, 80, 40).fontWeight("bold")} title={formatVeryNiceDateTime(scheduledDeploy.switchTime)}>
|
|
377
|
+
⏻ Shuts down in {formatTime(scheduledDeploy.switchTime - now)}
|
|
354
378
|
</div>
|
|
355
379
|
)}
|
|
356
380
|
{serviceInfo && (
|
|
@@ -463,58 +487,38 @@ export class ServiceDetailPage extends qreact.Component {
|
|
|
463
487
|
|
|
464
488
|
<div className={css.hbox(12).fillWidth}>
|
|
465
489
|
<button className={css.pad2(12, 8).button.bord2(0, 0, 20)
|
|
466
|
-
+ (
|
|
490
|
+
+ (config.parameters.deploy ? css.hsl(0, 70, 90) : css.hsl(120, 70, 90))}
|
|
467
491
|
onClick={() => {
|
|
468
492
|
if (!selectedServiceId || !config) return;
|
|
469
493
|
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
void this.updateUnsavedChanges(configT);
|
|
494
|
+
let updated = deepCloneJSON(config);
|
|
495
|
+
updated.parameters.deploy = !config.parameters.deploy;
|
|
496
|
+
this.updateEditorState(updated);
|
|
474
497
|
}}>
|
|
475
|
-
{
|
|
498
|
+
{config.parameters.deploy ? "⏸️ Disable" : "🚀 Enable"}
|
|
476
499
|
</button>
|
|
477
500
|
|
|
478
|
-
|
|
501
|
+
{originalConfig.oldParameters && <button
|
|
502
|
+
className={css.pad2(12, 8).button.bord2(0, 0, 20).hsl(35, 70, 85)}
|
|
503
|
+
title="Loads the previous deploy's parameters into the editor, so you can schedule them as a new deploy"
|
|
479
504
|
onClick={() => {
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
505
|
+
const oldParameters = originalConfig.oldParameters;
|
|
506
|
+
if (!oldParameters) return;
|
|
507
|
+
this.updateEditorState({
|
|
508
|
+
...stripReleaseState(originalConfig),
|
|
509
|
+
parameters: { ...oldParameters, releaseTime: undefined },
|
|
510
|
+
});
|
|
484
511
|
}}>
|
|
485
|
-
|
|
486
|
-
</button>
|
|
487
|
-
|
|
488
|
-
{hasUnsavedChanges && <>
|
|
489
|
-
<Button
|
|
490
|
-
flavor="noui"
|
|
491
|
-
hotkeys={["global+ctrl+s"]}
|
|
492
|
-
showHotkeys
|
|
493
|
-
className={
|
|
494
|
-
css.pad2(12, 8).button.bord2(0, 0, 20).hbox(0).hsl(120, 70, 90)
|
|
495
|
-
}
|
|
496
|
-
onClick={() => {
|
|
497
|
-
if (!hasUnsavedChanges || !selectedServiceId || !this.state.unsavedChanges) return;
|
|
498
|
-
this.updateConfigSynced(this.state.unsavedChanges);
|
|
499
|
-
}
|
|
500
|
-
}>
|
|
501
|
-
{this.state.isSaving ? "Saving..." : "Save Pending"}
|
|
502
|
-
</Button>
|
|
503
|
-
<button
|
|
504
|
-
className={css.pad2(12, 8).button.bord2(0, 0, 20).hsl(50, 80, 50)}
|
|
505
|
-
onClick={() => {
|
|
506
|
-
this.state.unsavedChanges = undefined;
|
|
507
|
-
}}>
|
|
508
|
-
Discard Changes
|
|
509
|
-
</button>
|
|
510
|
-
</>}
|
|
512
|
+
↩️ Revert to Previous Deploy
|
|
513
|
+
</button>}
|
|
511
514
|
|
|
512
515
|
{gitInfo?.repoUrl === config.parameters.repoUrl && gitInfo.latestRef !== config.parameters.gitRef &&
|
|
513
516
|
<button
|
|
514
517
|
className={buttonStyle.hsl(120, 70, 90).alignSelf("stretch")}
|
|
515
|
-
onClick={
|
|
516
|
-
|
|
517
|
-
|
|
518
|
+
onClick={() => {
|
|
519
|
+
let updated = deepCloneJSON(config);
|
|
520
|
+
updated.parameters.gitRef = gitInfo.latestRef;
|
|
521
|
+
this.updateEditorState(updated);
|
|
518
522
|
}}
|
|
519
523
|
>
|
|
520
524
|
<b>Update to New</b>
|
|
@@ -529,10 +533,6 @@ export class ServiceDetailPage extends qreact.Component {
|
|
|
529
533
|
</button>
|
|
530
534
|
}
|
|
531
535
|
|
|
532
|
-
{this.state.isSaving && <div className={css.pad2(12, 8).bord2(0, 0, 20) + " rainbow-flash"}>
|
|
533
|
-
Saving pending changes
|
|
534
|
-
</div>}
|
|
535
|
-
|
|
536
536
|
<div className={css.marginAuto}></div>
|
|
537
537
|
|
|
538
538
|
<button className={css.pad2(12, 8).button.bord2(0, 0, 20).hsl(110, 70, 90)}
|
|
@@ -550,7 +550,7 @@ export class ServiceDetailPage extends qreact.Component {
|
|
|
550
550
|
await controller.addServiceConfig.promise(newServiceId, clonedConfig);
|
|
551
551
|
Querysub.commit(() => {
|
|
552
552
|
selectedServiceIdParam.value = newServiceId;
|
|
553
|
-
this.state.
|
|
553
|
+
this.state.editorState = undefined;
|
|
554
554
|
});
|
|
555
555
|
} catch (error) {
|
|
556
556
|
Querysub.localCommit(() => {
|
|
@@ -566,7 +566,7 @@ export class ServiceDetailPage extends qreact.Component {
|
|
|
566
566
|
onClick={() => {
|
|
567
567
|
if (!selectedServiceId) return;
|
|
568
568
|
|
|
569
|
-
const confirmed = confirm(`⚠️ WARNING: This will permanently delete the service "${
|
|
569
|
+
const confirmed = confirm(`⚠️ WARNING: This will permanently delete the service "${config.info.title}" and remove it from all machines.\n\nThis action cannot be undone. Are you sure you want to continue?`);
|
|
570
570
|
if (!confirmed) return;
|
|
571
571
|
|
|
572
572
|
Querysub.onCommitFinished(async () => {
|
|
@@ -580,10 +580,6 @@ export class ServiceDetailPage extends qreact.Component {
|
|
|
580
580
|
Querysub.localCommit(() => {
|
|
581
581
|
this.state.saveError = error instanceof Error ? error.message : String(error);
|
|
582
582
|
});
|
|
583
|
-
} finally {
|
|
584
|
-
Querysub.localCommit(() => {
|
|
585
|
-
this.state.isSaving = false;
|
|
586
|
-
});
|
|
587
583
|
}
|
|
588
584
|
});
|
|
589
585
|
}}>
|
|
@@ -591,102 +587,124 @@ export class ServiceDetailPage extends qreact.Component {
|
|
|
591
587
|
</button>
|
|
592
588
|
</div>
|
|
593
589
|
|
|
594
|
-
{
|
|
590
|
+
{/* The deploy that is scheduled on the SERVER — completely independent from any editor changes below */}
|
|
591
|
+
{scheduledDeploy && <div className={css.vbox(10).pad2(12).fillWidth.bord2(210, 60, 50).hsl(210, 40, 95)}>
|
|
592
|
+
<div className={css.boldStyle.fontSize(15)}>
|
|
593
|
+
🕐 Deploy scheduled for {formatTime(scheduledDeploy.switchTime - now)} ({formatDateTimeDetailed(scheduledDeploy.switchTime)})
|
|
594
|
+
</div>
|
|
595
|
+
<ParametersDiff base={getLiveServiceParameters(originalConfig)} next={originalConfig.parameters} />
|
|
596
|
+
<div className={css.vbox(4)}>
|
|
597
|
+
<div>
|
|
598
|
+
Live instance deployed at {scheduledDeploy.switchTime - scheduledDeploy.overlapTime <= now && `${formatTime(now - (scheduledDeploy.switchTime - scheduledDeploy.overlapTime))} ago` || `in ${formatTime(scheduledDeploy.switchTime - scheduledDeploy.overlapTime - now)}`} ({formatDateTimeDetailed(scheduledDeploy.switchTime - scheduledDeploy.overlapTime)})
|
|
599
|
+
</div>
|
|
600
|
+
<div className={css.hbox(8)}>
|
|
601
|
+
<button
|
|
602
|
+
className={css.pad2(12, 8).button.bord2(0, 0, 20).hsl(0, 70, 90)}
|
|
603
|
+
onClick={() => {
|
|
604
|
+
this.runControllerAction(async () => {
|
|
605
|
+
let cancelled = await controller.cancelScheduledDeploy.promise(selectedServiceId);
|
|
606
|
+
// Keep the cancelled changes in the editor, so they aren't lost (Discard Changes drops them)
|
|
607
|
+
Querysub.commit(() => {
|
|
608
|
+
this.state.editorState = cancelled;
|
|
609
|
+
});
|
|
610
|
+
});
|
|
611
|
+
}}>
|
|
612
|
+
Cancel Scheduled Deploy
|
|
613
|
+
</button>
|
|
614
|
+
</div>
|
|
615
|
+
</div>
|
|
616
|
+
</div>}
|
|
617
|
+
|
|
618
|
+
{/* The EDITOR's changes (browser-only) — deploying them schedules a (new) deploy */}
|
|
619
|
+
{(() => {
|
|
620
|
+
if (!hasUnsavedChanges) return undefined;
|
|
621
|
+
let liveParameters = getLiveServiceParameters(originalConfig);
|
|
622
|
+
// Only parameter changes need a release; info-only edits just save immediately
|
|
623
|
+
let parametersChanged = JSON.stringify({ ...config.parameters, releaseTime: undefined }) !== JSON.stringify({ ...liveParameters, releaseTime: undefined });
|
|
624
|
+
if (!parametersChanged) {
|
|
625
|
+
return <div className={css.hbox(12)}>
|
|
626
|
+
<button
|
|
627
|
+
className={css.pad2(12, 8).button.bord2(0, 0, 20).hsl(120, 70, 85).fontWeight("bold")}
|
|
628
|
+
disabled={this.state.isDeploying}
|
|
629
|
+
onClick={() => {
|
|
630
|
+
this.deployConfig(deepCloneJSON(config));
|
|
631
|
+
}}>
|
|
632
|
+
{this.state.isDeploying && "⏳ Saving..." || "💾 Save"}
|
|
633
|
+
</button>
|
|
634
|
+
<span title={`${configCompareKey(config)} !== ${configCompareKey(deployedConfig)}`}>Only the title / notes changed, so this saves immediately</span>
|
|
635
|
+
</div>;
|
|
636
|
+
}
|
|
595
637
|
let overlapSeconds = this.state.overlapOverride || Math.round((config.parameters.overlapTime || DEFAULT_OVERLAP_TIME) / timeInSecond);
|
|
596
638
|
let overlap = overlapSeconds * timeInSecond;
|
|
597
639
|
let scheduledTime = this.state.switchTime;
|
|
598
640
|
let notEnoughTime = !!scheduledTime && scheduledTime > now && scheduledTime - now < overlap;
|
|
599
|
-
|
|
641
|
+
const makeDeployConfig = (releaseTime: number) => {
|
|
642
|
+
let deployConfig = deepCloneJSON(config);
|
|
643
|
+
deployConfig.parameters.releaseTime = releaseTime;
|
|
644
|
+
deployConfig.parameters.overlapTime = overlap;
|
|
645
|
+
return deployConfig;
|
|
646
|
+
};
|
|
647
|
+
// The switch time is inferred from the overlap, unless one is explicitly set (in which case the overlap only controls when the new instances start)
|
|
648
|
+
let effectiveSwitchTime = scheduledTime || now + overlap;
|
|
649
|
+
return <div className={css.vbox(10).pad2(12).fillWidth.bord2(45, 70, 45).hsl(45, 60, 95)}>
|
|
600
650
|
<div className={css.boldStyle.fontSize(15)}>
|
|
601
|
-
|
|
651
|
+
✏️ Edited (only in this browser, until deployed)
|
|
602
652
|
</div>
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
<
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
653
|
+
<ParametersDiff base={liveParameters} next={config.parameters} />
|
|
654
|
+
<div className={css.hbox(12).wrap}>
|
|
655
|
+
<button
|
|
656
|
+
className={css.pad2(12, 8).button.bord2(0, 0, 20).hsl(120, 70, 85).fontWeight("bold")}
|
|
657
|
+
disabled={this.state.isDeploying}
|
|
658
|
+
onClick={() => {
|
|
659
|
+
this.deployConfig(makeDeployConfig(scheduledTime || Date.now() + overlap));
|
|
660
|
+
}}>
|
|
661
|
+
{this.state.isDeploying && "⏳ Scheduling..." || scheduledDeploy && "🔁 Replace Scheduled Deploy" || "🚀 Schedule Deploy"}
|
|
662
|
+
</button>
|
|
663
|
+
<div className={css.opacity(scheduledTime && 1 || 0.5)}>
|
|
664
|
+
<InputLabel
|
|
665
|
+
label="Switch at"
|
|
666
|
+
isDatetime
|
|
667
|
+
value={effectiveSwitchTime}
|
|
668
|
+
onChangeValue={value => {
|
|
669
|
+
this.state.switchTime = Number(value) || 0;
|
|
670
|
+
}}
|
|
671
|
+
/>
|
|
610
672
|
</div>
|
|
611
|
-
<
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
673
|
+
<span>(in {formatTime(effectiveSwitchTime - now)})</span>
|
|
674
|
+
{!!scheduledTime && <button
|
|
675
|
+
className={css.pad2(12, 8).button.bord2(0, 0, 20).hsl(0, 0, 90)}
|
|
676
|
+
onClick={() => {
|
|
677
|
+
this.state.switchTime = 0;
|
|
678
|
+
}}>
|
|
679
|
+
↺ Reset to overlap-based time
|
|
680
|
+
</button>}
|
|
681
|
+
<div className={css.opacity(scheduledTime && 0.5 || 1)}>
|
|
682
|
+
<InputLabel
|
|
683
|
+
label="Overlap (seconds)"
|
|
684
|
+
integer
|
|
685
|
+
value={overlapSeconds}
|
|
686
|
+
onChangeValue={value => {
|
|
687
|
+
this.state.overlapOverride = Number(value) || 0;
|
|
688
|
+
}}
|
|
689
|
+
/>
|
|
619
690
|
</div>
|
|
620
|
-
</div>
|
|
621
|
-
{
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
<div className={css.opacity(scheduledTime && 1 || 0.5)}>
|
|
638
|
-
<InputLabel
|
|
639
|
-
label="Switch at"
|
|
640
|
-
isDatetime
|
|
641
|
-
value={effectiveSwitchTime}
|
|
642
|
-
onChangeValue={value => {
|
|
643
|
-
this.state.switchTime = Number(value) || 0;
|
|
644
|
-
}}
|
|
645
|
-
/>
|
|
646
|
-
</div>
|
|
647
|
-
<span>(in {formatTime(effectiveSwitchTime - now)})</span>
|
|
648
|
-
{!!scheduledTime && <button
|
|
649
|
-
className={css.pad2(12, 8).button.bord2(0, 0, 20).hsl(0, 0, 90)}
|
|
650
|
-
onClick={() => {
|
|
651
|
-
this.state.switchTime = 0;
|
|
652
|
-
}}>
|
|
653
|
-
↺ Reset to overlap-based time
|
|
654
|
-
</button>}
|
|
655
|
-
<div className={css.opacity(scheduledTime && 0.5 || 1)}>
|
|
656
|
-
<InputLabel
|
|
657
|
-
label="Overlap (seconds)"
|
|
658
|
-
integer
|
|
659
|
-
value={overlapSeconds}
|
|
660
|
-
onChangeValue={value => {
|
|
661
|
-
this.state.overlapOverride = Number(value) || 0;
|
|
662
|
-
}}
|
|
663
|
-
/>
|
|
664
|
-
</div>
|
|
665
|
-
</div>
|
|
666
|
-
{!!scheduledTime && scheduledTime <= now && <span className={css.colorhsl(0, 85, 45).fontWeight("bold")}>
|
|
667
|
-
⚠️ The switch time is in the past — deploying switches immediately
|
|
668
|
-
</span>}
|
|
669
|
-
{notEnoughTime && <span className={css.colorhsl(35, 85, 40).fontWeight("bold")}>
|
|
670
|
-
⚠️ The switch time is sooner than the overlap ({formatTime(overlap)}) — the new instances won't be fully started before the old ones shut down
|
|
671
|
-
</span>}
|
|
672
|
-
<div className={css.hbox(8)}>
|
|
673
|
-
<button
|
|
674
|
-
className={css.pad2(12, 8).button.bord2(0, 70, 40).hsl(0, 70, 90)}
|
|
675
|
-
onClick={() => {
|
|
676
|
-
this.confirmForceDeployNow(selectedServiceId, overlap);
|
|
677
|
-
}}>
|
|
678
|
-
⚡ Force Deploy Now
|
|
679
|
-
</button>
|
|
680
|
-
<button
|
|
681
|
-
className={css.pad2(12, 8).button.bord2(0, 0, 20).hsl(50, 80, 60)}
|
|
682
|
-
onClick={() => {
|
|
683
|
-
this.runControllerAction(() => controller.discardPendingServiceConfig.promise(selectedServiceId));
|
|
684
|
-
}}>
|
|
685
|
-
Discard Pending Config
|
|
686
|
-
</button>
|
|
687
|
-
</div>
|
|
688
|
-
</div>;
|
|
689
|
-
})()}
|
|
691
|
+
</div>
|
|
692
|
+
{!!scheduledTime && scheduledTime <= now && <span className={css.colorhsl(0, 85, 45).fontWeight("bold")}>
|
|
693
|
+
⚠️ The switch time is in the past — deploying switches immediately
|
|
694
|
+
</span>}
|
|
695
|
+
{notEnoughTime && <span className={css.colorhsl(35, 85, 40).fontWeight("bold")}>
|
|
696
|
+
⚠️ The switch time is sooner than the overlap ({formatTime(overlap)}) — the new instances won't be fully started before the old ones shut down
|
|
697
|
+
</span>}
|
|
698
|
+
<div className={css.hbox(8)}>
|
|
699
|
+
<button
|
|
700
|
+
className={css.pad2(12, 8).button.bord2(0, 70, 40).hsl(0, 70, 90)}
|
|
701
|
+
disabled={this.state.isDeploying}
|
|
702
|
+
onClick={() => {
|
|
703
|
+
this.confirmForceDeployNow(makeDeployConfig(0));
|
|
704
|
+
}}>
|
|
705
|
+
⚡ Force Deploy Now
|
|
706
|
+
</button>
|
|
707
|
+
</div>
|
|
690
708
|
</div>;
|
|
691
709
|
})()}
|
|
692
710
|
|
|
@@ -700,17 +718,27 @@ export class ServiceDetailPage extends qreact.Component {
|
|
|
700
718
|
<TypedConfigEditor
|
|
701
719
|
value={config}
|
|
702
720
|
onValueChange={(newValue) => {
|
|
703
|
-
this.
|
|
721
|
+
this.updateEditorState(newValue as ServiceConfig);
|
|
704
722
|
}}
|
|
705
723
|
typeDefinition={serviceConfigType}
|
|
706
724
|
sizeClassName={css.size(1000, 600)}
|
|
707
725
|
/>
|
|
726
|
+
{hasUnsavedChanges && <button
|
|
727
|
+
className={css.pad2(12, 8).button.bord2(0, 0, 20).hsl(50, 80, 50)}
|
|
728
|
+
onClick={() => {
|
|
729
|
+
this.state.editorState = undefined;
|
|
730
|
+
}}>
|
|
731
|
+
Discard Changes
|
|
732
|
+
</button>}
|
|
708
733
|
</div>;
|
|
709
734
|
}
|
|
710
735
|
}
|
|
711
736
|
|
|
712
|
-
// An inline diff of the live
|
|
713
|
-
class
|
|
737
|
+
// An inline diff of the live parameters vs the parameters in the editor / scheduled to deploy, so it's clear exactly what deploying will change.
|
|
738
|
+
class ParametersDiff extends qreact.Component<{ base: ServiceParameters; next: ServiceParameters }> {
|
|
739
|
+
state = t.state({
|
|
740
|
+
showRaw: t.type(false),
|
|
741
|
+
});
|
|
714
742
|
render() {
|
|
715
743
|
let baseValues = new Map<string, unknown>();
|
|
716
744
|
let nextValues = new Map<string, unknown>();
|
|
@@ -719,22 +747,42 @@ class ConfigDiff extends qreact.Component<{ base: ServiceConfig; next: ServiceCo
|
|
|
719
747
|
let keys = Array.from(new Set([...baseValues.keys(), ...nextValues.keys()]));
|
|
720
748
|
sort(keys, x => x);
|
|
721
749
|
let changed = keys.filter(key =>
|
|
722
|
-
// The
|
|
723
|
-
key !== "
|
|
750
|
+
// The release time is displayed separately, so it would just be noise
|
|
751
|
+
key !== "releaseTime"
|
|
724
752
|
&& JSON.stringify(baseValues.get(key)) !== JSON.stringify(nextValues.get(key))
|
|
725
753
|
);
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
754
|
+
return <div className={css.vbox(4).fillWidth}>
|
|
755
|
+
{changed.length === 0 && <div className={css.colorhsl(0, 0, 50)}>(no parameter changes from the live parameters)</div> || <div className={css.vbox(2).fontFamily("monospace").fontSize(13)}>
|
|
756
|
+
{changed.map(key => <div className={css.hbox(6).wrap}>
|
|
757
|
+
<span className={css.boldStyle}>{key}:</span>
|
|
758
|
+
{baseValues.has(key) && <span className={css.colorhsl(0, 70, 40).textDecoration("line-through")}>{formatDiffValue(baseValues.get(key))}</span>}
|
|
759
|
+
{nextValues.has(key) && <span className={css.colorhsl(120, 60, 30)}>{formatDiffValue(nextValues.get(key))}</span> || <span className={css.colorhsl(0, 70, 40)}>(removed)</span>}
|
|
760
|
+
</div>)}
|
|
761
|
+
</div>}
|
|
762
|
+
<div className={css.hbox(8)}>
|
|
763
|
+
<button
|
|
764
|
+
className={css.pad2(8, 4).button.bord2(0, 0, 30).hsl(0, 0, 92)}
|
|
765
|
+
onClick={() => this.state.showRaw = !this.state.showRaw}>
|
|
766
|
+
{this.state.showRaw && "🧾 Hide Raw Parameters" || "🧾 Show Raw Parameters"}
|
|
767
|
+
</button>
|
|
768
|
+
</div>
|
|
769
|
+
{this.state.showRaw && <div className={css.hbox(24).wrap.alignItems("start")}>
|
|
770
|
+
<div className={css.vbox(2)}>
|
|
771
|
+
<div className={css.boldStyle}>Live</div>
|
|
772
|
+
<div className={css.fontFamily("monospace").fontSize(12).whiteSpace("pre-wrap")}>{JSON.stringify(this.props.base, undefined, 2)}</div>
|
|
773
|
+
</div>
|
|
774
|
+
<div className={css.vbox(2)}>
|
|
775
|
+
<div className={css.boldStyle}>New</div>
|
|
776
|
+
<div className={css.fontFamily("monospace").fontSize(12).whiteSpace("pre-wrap")}>{JSON.stringify(this.props.next, undefined, 2)}</div>
|
|
777
|
+
</div>
|
|
778
|
+
</div>}
|
|
735
779
|
</div>;
|
|
736
780
|
}
|
|
737
781
|
}
|
|
782
|
+
// The save time is set by the server on every save, so it would make every editor value read as changed
|
|
783
|
+
function configCompareKey(config: ServiceConfig): string {
|
|
784
|
+
return JSON.stringify({ ...config, info: { ...config.info, lastUpdatedTime: 0 } });
|
|
785
|
+
}
|
|
738
786
|
function flattenConfigValues(obj: unknown, prefix: string, out: Map<string, unknown>) {
|
|
739
787
|
if (obj && typeof obj === "object" && !Array.isArray(obj)) {
|
|
740
788
|
for (let [key, value] of Object.entries(obj as { [key: string]: unknown })) {
|