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