querysub 0.251.0 → 0.253.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "querysub",
3
- "version": "0.251.0",
3
+ "version": "0.253.0",
4
4
  "main": "index.js",
5
5
  "license": "MIT",
6
6
  "note1": "note on node-forge fork, see https://github.com/digitalbazaar/forge/issues/744 for details",
@@ -22,7 +22,7 @@
22
22
  "js-sha512": "^0.9.0",
23
23
  "node-forge": "https://github.com/sliftist/forge#e618181b469b07bdc70b968b0391beb8ef5fecd6",
24
24
  "pako": "^2.1.0",
25
- "socket-function": "^0.133.0",
25
+ "socket-function": "^0.134.0",
26
26
  "terser": "^5.31.0",
27
27
  "typesafecss": "^0.22.0",
28
28
  "yaml": "^2.5.0",
@@ -64,4 +64,4 @@
64
64
  "resolutions": {
65
65
  "node-forge": "https://github.com/sliftist/forge#e618181b469b07bdc70b968b0391beb8ef5fecd6"
66
66
  }
67
- }
67
+ }
@@ -366,6 +366,7 @@ class NodePathAuthorities {
366
366
  // as being out of date.
367
367
  let now = Date.now();
368
368
  let isReadReady = await errorToUndefinedSilent(PathController.nodes[nodeId].isReadReady());
369
+
369
370
  // If the node is gone, delete it from authorities
370
371
  if (isReadReady === undefined) {
371
372
  console.error(yellow(`Node errored out, removing from authorities ${nodeId}`));
@@ -354,6 +354,7 @@ export class PathFunctionRunner {
354
354
  let runCallPromise = self.runCall(call, functionSpec);
355
355
  void runCallPromise.finally(() => {
356
356
  outstandingCalls--;
357
+ // IMPORTANT! We remove the call, NOT for GC, but! Because if the call writes are rejected (due to contention), then the Result will also be rejected, changing it back to undefined, so (as long as little enough time has passed), we will run the call again! Or rather, we will try to, as long as runningCalls doesn't have the value!
357
358
  runningCalls.delete(call.CallId);
358
359
  });
359
360
  logErrors(runCallPromise);
@@ -290,6 +290,16 @@ async function edgeNodeFunction(config: {
290
290
  }
291
291
  async function getEdgeNodeConfig(): Promise<EdgeNodeConfig> {
292
292
  let edgeIndex = cachedConfig || await (await fetch(config.edgeNodeConfigURL)).json() as EdgeNodesIndex;
293
+
294
+ edgeIndex.edgeNodes.sort((a, b) => -(a.bootTime - b.bootTime));
295
+ // Filter out duplicate hostnames (taking the max bootTime). We can't have multiple services using the same port, so duplicates are DEFINITELY dead. This fixes some issues with accessing old services, and generally makes reading the logs easier.
296
+ let hostNames = new Set<string>();
297
+ edgeIndex.edgeNodes = edgeIndex.edgeNodes.filter(x => {
298
+ if (hostNames.has(x.host)) return false;
299
+ hostNames.add(x.host);
300
+ return true;
301
+ });
302
+
293
303
  cachedConfig = undefined;
294
304
  let liveHashForced = false;
295
305
  if (Date.now() < liveHashOverrideExpiryTime) {
@@ -7,7 +7,7 @@ import { getArchivesBackblazePublic } from "../-a-archives/archivesBackBlaze";
7
7
  import { nestArchives } from "../-a-archives/archives";
8
8
  import { SocketFunction } from "socket-function/SocketFunction";
9
9
  import { runInSerial, runInfinitePoll, runInfinitePollCallAtStart } from "socket-function/src/batching";
10
- import { compare, compareArray, isNodeTrue, timeInMinute } from "socket-function/src/misc";
10
+ import { compare, compareArray, isNodeTrue, sort, timeInMinute } from "socket-function/src/misc";
11
11
  import { cacheLimited, lazy } from "socket-function/src/caching";
12
12
  import { canHaveChildren } from "socket-function/src/types";
13
13
  import { shutdown } from "../diagnostics/periodic";
@@ -11,6 +11,40 @@ export async function getGitRefLive(gitDir = ".") {
11
11
  return (await runPromise(`git rev-parse HEAD`, { cwd: gitDir })).trim();
12
12
  }
13
13
 
14
+ export async function getGitUncommitted(gitDir = "."): Promise<string[]> {
15
+ return (await runPromise(`git status --porcelain`, { cwd: gitDir })).split("\n").map(x => x.trim()).filter(x => x);
16
+ }
17
+
18
+ export async function getLatestRefOnUpstreamBranch(gitDir = ".") {
19
+ await runPromise(`git fetch`, { cwd: gitDir });
20
+ return (await runPromise(`git rev-parse @{upstream}`, { cwd: gitDir })).trim();
21
+ }
22
+
23
+ export async function commitAndPush(config: {
24
+ gitDir: string;
25
+ message: string;
26
+ }) {
27
+ await runPromise(`git add --all`, { cwd: config.gitDir });
28
+ await runPromise(`git commit -m "${config.message}"`, { cwd: config.gitDir });
29
+ await runPromise(`git push`, { cwd: config.gitDir });
30
+ }
31
+
32
+ export async function getGitRefInfo(config: {
33
+ gitDir: string;
34
+ ref: string;
35
+ }): Promise<{
36
+ time: number;
37
+ description: string;
38
+ }> {
39
+ const timeOutput = await runPromise(`git show --format="%ct" -s ${config.ref}`, { cwd: config.gitDir });
40
+ const descriptionOutput = await runPromise(`git show --format="%s" -s ${config.ref}`, { cwd: config.gitDir });
41
+
42
+ return {
43
+ time: parseInt(timeOutput.trim(), 10) * 1000,
44
+ description: descriptionOutput.trim()
45
+ };
46
+ }
47
+
14
48
  export const getGitURLSync = cache(function getGitURLSync(gitDir?: string) {
15
49
  return child_process.execSync(`git remote get-url origin`, { cwd: gitDir || "." }).toString().trim();
16
50
  });
@@ -20,7 +54,6 @@ export const getGitRefSync = cache(function getGitRefSync(gitDir?: string) {
20
54
 
21
55
  export const setGitRef = measureWrap(async function setGitRef(config: {
22
56
  gitFolder: string;
23
- repoUrl: string;
24
57
  gitRef: string;
25
58
  }) {
26
59
  await fs.promises.mkdir(config.gitFolder, { recursive: true });
@@ -1,12 +1,13 @@
1
1
  import { SocketFunction } from "socket-function/SocketFunction";
2
2
  import { qreact } from "../4-dom/qreact";
3
- import { DeployController } from "./machineSchema";
3
+ import { MachineServiceController } from "./machineSchema";
4
4
  import { css } from "typesafecss";
5
5
  import { currentViewParam, selectedServiceIdParam, selectedMachineIdParam } from "./urlParams";
6
6
  import { ServicesListPage } from "./components/ServicesListPage";
7
7
  import { MachinesListPage } from "./components/MachinesListPage";
8
8
  import { ServiceDetailPage } from "./components/ServiceDetailPage";
9
9
  import { MachineDetailPage } from "./components/MachineDetailPage";
10
+ import { Anchor } from "../library-components/ATag";
10
11
 
11
12
  export class MachinesPage extends qreact.Component {
12
13
  private renderTabs() {
@@ -19,24 +20,20 @@ export class MachinesPage extends qreact.Component {
19
20
  { key: "services", label: "Services", otherKeys: ["service-detail"] },
20
21
  ].map(tab => {
21
22
  let isActive = currentViewParam.value === tab.key || tab.otherKeys.includes(currentViewParam.value);
22
- return <div key={tab.key}
23
+ return <Anchor noStyles key={tab.key}
24
+ values={[currentViewParam.getOverride(tab.key as any), selectedServiceIdParam.getOverride(""), selectedMachineIdParam.getOverride("")]}
23
25
  className={css.pad2(12, 8).button
24
26
  + (isActive && css.hsl(0, 0, 100).bord2(210, 50, 50))
25
27
  + (!isActive && css.hsl(0, 0, 70).colorhsl(0, 0, 50).bord2(0, 0, 50))
26
- }
27
- onClick={() => {
28
- currentViewParam.value = tab.key as any;
29
- selectedServiceIdParam.value = "";
30
- selectedMachineIdParam.value = "";
31
- }}>
28
+ }>
32
29
  {tab.label}
33
- </div>;
30
+ </Anchor>;
34
31
  })}
35
32
  </div>;
36
33
  }
37
34
 
38
35
  render() {
39
- let controller = DeployController(SocketFunction.browserNodeId());
36
+ let controller = MachineServiceController(SocketFunction.browserNodeId());
40
37
  let serviceConfigType = controller.getServiceConfigType();
41
38
  if (!serviceConfigType) return <div>Loading...</div>;
42
39
 
@@ -1,17 +1,18 @@
1
1
  import { SocketFunction } from "socket-function/SocketFunction";
2
2
  import { qreact } from "../../4-dom/qreact";
3
- import { MACHINE_RESYNC_INTERVAL, DeployController, ServiceConfig } from "../machineSchema";
3
+ import { MACHINE_RESYNC_INTERVAL, MachineServiceController, ServiceConfig } from "../machineSchema";
4
4
  import { css } from "typesafecss";
5
5
  import { currentViewParam, selectedMachineIdParam, selectedServiceIdParam } from "../urlParams";
6
6
  import { formatVeryNiceDateTime } from "socket-function/src/formatting/format";
7
7
  import { sort } from "socket-function/src/misc";
8
+ import { Anchor } from "../../library-components/ATag";
8
9
 
9
10
  export class MachineDetailPage extends qreact.Component {
10
11
  render() {
11
12
  const selectedMachineId = selectedMachineIdParam.value;
12
13
  if (!selectedMachineId) return <div>No machine selected</div>;
13
14
 
14
- let controller = DeployController(SocketFunction.browserNodeId());
15
+ let controller = MachineServiceController(SocketFunction.browserNodeId());
15
16
  let machineInfo = controller.getMachineInfo(selectedMachineId);
16
17
  let serviceList = controller.getServiceList();
17
18
 
@@ -77,14 +78,10 @@ export class MachineDetailPage extends qreact.Component {
77
78
  <h3>⚠️ Configured Services Not Running ({configuredButNotRunning.length})</h3>
78
79
  <div className={css.vbox(8)}>
79
80
  {configuredButNotRunning.map(([serviceId, serviceConfig]) => (
80
- <div key={serviceId}
81
- className={
82
- css.pad2(12).button.bord2(0, 0, 20).hsl(0, 70, 90)
83
- }
84
- onClick={() => {
85
- currentViewParam.value = "service-detail";
86
- selectedServiceIdParam.value = serviceId;
87
- }}>
81
+ <Anchor noStyles
82
+ key={serviceId}
83
+ values={[currentViewParam.getOverride("service-detail"), selectedServiceIdParam.getOverride(serviceId)]}
84
+ className={css.pad2(12).button.bord2(0, 0, 20).hsl(0, 70, 90)}>
88
85
  <div className={css.hbox(12)}>
89
86
  <div className={css.flexGrow(1)}>
90
87
  {serviceConfig.info.title} ({serviceConfig.parameters.key} / {serviceId})
@@ -96,7 +93,7 @@ export class MachineDetailPage extends qreact.Component {
96
93
  Deploy: {serviceConfig.parameters.deploy ? "Yes" : "No"}
97
94
  </div>
98
95
  </div>
99
- </div>
96
+ </Anchor>
100
97
  ))}
101
98
  </div>
102
99
  </div>
@@ -118,15 +115,10 @@ export class MachineDetailPage extends qreact.Component {
118
115
  }
119
116
 
120
117
  return (
121
- <div key={serviceId}
122
- className={
123
- css.pad2(12).button.bord2(0, 0, 20)
124
- + backgroundColor
125
- }
126
- onClick={() => {
127
- currentViewParam.value = "service-detail";
128
- selectedServiceIdParam.value = serviceId;
129
- }}>
118
+ <Anchor noStyles
119
+ key={serviceId}
120
+ values={[currentViewParam.getOverride("service-detail"), selectedServiceIdParam.getOverride(serviceId)]}
121
+ className={css.pad2(12).button.bord2(0, 0, 20) + backgroundColor}>
130
122
  <div className={css.hbox(12)}>
131
123
  <div className={css.flexGrow(1)}>
132
124
  {serviceConfig && <>{serviceConfig.info.title} ({serviceConfig.parameters.key})</> || <>serviceId = {serviceId}</>}
@@ -148,7 +140,7 @@ export class MachineDetailPage extends qreact.Component {
148
140
  </div>
149
141
  )}
150
142
  </div>
151
- </div>
143
+ </Anchor>
152
144
  );
153
145
  })}
154
146
  </div>
@@ -1,12 +1,15 @@
1
1
  import { SocketFunction } from "socket-function/SocketFunction";
2
2
  import { qreact } from "../../4-dom/qreact";
3
- import { MACHINE_RESYNC_INTERVAL, DeployController } from "../machineSchema";
3
+ import { MACHINE_RESYNC_INTERVAL, MachineServiceController } from "../machineSchema";
4
4
  import { css } from "typesafecss";
5
5
  import { t } from "../../2-proxy/schema2";
6
6
  import { Querysub } from "../../4-querysub/QuerysubController";
7
7
  import { currentViewParam, selectedMachineIdParam } from "../urlParams";
8
8
  import { formatVeryNiceDateTime } from "socket-function/src/formatting/format";
9
9
  import { sort } from "socket-function/src/misc";
10
+ import { formatTimeJSX } from "../../misc/formatJSX";
11
+ import { DeployMachineButtons, RenderGitRefInfo } from "./deployButtons";
12
+ import { isDefined } from "../../misc";
10
13
 
11
14
  export class MachinesListPage extends qreact.Component {
12
15
  state = t.state({
@@ -16,7 +19,7 @@ export class MachinesListPage extends qreact.Component {
16
19
  });
17
20
 
18
21
  render() {
19
- let controller = DeployController(SocketFunction.browserNodeId());
22
+ let controller = MachineServiceController(SocketFunction.browserNodeId());
20
23
  let machineList = controller.getMachineList();
21
24
 
22
25
  if (!machineList) return <div>Loading machines...</div>;
@@ -48,10 +51,12 @@ export class MachinesListPage extends qreact.Component {
48
51
  this.state.selectedForDeletion[machineId] = true;
49
52
  }
50
53
  }}>
51
- 🗑️ Delete Machines
54
+ 🗑️ Delete Dead Machines
52
55
  </button>
53
56
  )}
54
57
 
58
+ <DeployMachineButtons machines={machines.map(x => x[1]).filter(isDefined)} />
59
+
55
60
  {this.state.isDeleteMode && (
56
61
  <div className={css.hbox(8)}>
57
62
  <div className={css.pad2(8, 4)}>
@@ -116,58 +121,64 @@ export class MachinesListPage extends qreact.Component {
116
121
  return acc + (service.totalTimesLaunched || 0);
117
122
  }, 0);
118
123
 
119
- return <div key={machineId}
120
- className={
121
- css.pad2(12).bord2(0, 0, 20).button
122
- + (
123
- failingServices.length > 0 && css.hsl(0, 50, 60)
124
- || isMachineDead && css.hsl(0, 0, 50)
125
- || css.hsl(0, 0, 100)
126
- )
127
- + (this.state.isDeleteMode && isSelected && css.bord2(200, 80, 60, 2))
128
- }
129
- onClick={() => {
130
- if (this.state.isDeleteMode) {
131
- if (this.state.selectedForDeletion[machineId]) {
132
- delete this.state.selectedForDeletion[machineId];
124
+ return <div className={css.hbox(10)}>
125
+ <div
126
+ className={
127
+ css.pad2(12).bord2(0, 0, 20).button
128
+ + (
129
+ failingServices.length > 0 && css.hsl(0, 50, 60)
130
+ || isMachineDead && css.hsl(0, 0, 50)
131
+ || css.hsl(0, 0, 100)
132
+ )
133
+ + (this.state.isDeleteMode && isSelected && css.bord2(200, 80, 60, 2))
134
+ }
135
+ onClick={() => {
136
+ if (this.state.isDeleteMode) {
137
+ if (this.state.selectedForDeletion[machineId]) {
138
+ delete this.state.selectedForDeletion[machineId];
139
+ } else {
140
+ this.state.selectedForDeletion[machineId] = true;
141
+ }
133
142
  } else {
134
- this.state.selectedForDeletion[machineId] = true;
143
+ currentViewParam.value = "machine-detail";
144
+ selectedMachineIdParam.value = machineId;
135
145
  }
136
- } else {
137
- currentViewParam.value = "machine-detail";
138
- selectedMachineIdParam.value = machineId;
139
- }
140
- }}>
141
- <div className={css.hbox(12)}>
142
- {this.state.isDeleteMode && (
143
- <div className={css.flexShrink0}>
144
- <input
145
- type="checkbox"
146
- checked={isSelected}
147
- onChange={() => { }} // Controlled by onClick above
148
- onClick={(e) => e.stopPropagation()} // Prevent double-toggle
149
- className={css.size(16, 16)}
150
- />
151
- </div>
152
- )}
153
- {isMachineDead && <div className={css.colorhsl(0, 80, 50)}>
154
- ⚠️ Machine is likely dead
155
- </div>}
156
- <div>
157
- Last heartbeat {formatVeryNiceDateTime(machineInfo.heartbeat)}
158
- </div>
159
- <div className={css.vbox(4).flexGrow(1)}>
160
- <div>
161
- {machineId}
162
- </div>
163
- <div>
164
- {machineInfo.info["getExternalIP"]}
146
+ }}>
147
+ <div className={css.hbox(12)}>
148
+ {this.state.isDeleteMode && (
149
+ <div className={css.flexShrink0}>
150
+ <input
151
+ type="checkbox"
152
+ checked={isSelected}
153
+ onChange={() => { }} // Controlled by onClick above
154
+ onClick={(e) => e.stopPropagation()} // Prevent double-toggle
155
+ className={css.size(16, 16)}
156
+ />
157
+ </div>
158
+ )}
159
+ {isMachineDead && <div className={css.colorhsl(0, 80, 50)}>
160
+ ⚠️ Machine is likely dead
161
+ </div>}
162
+ <div className={css.vbox(4)}>
163
+ <div>
164
+ Last heartbeat {formatTimeJSX(machineInfo.heartbeat)}
165
+ </div>
166
+ <RenderGitRefInfo gitRef={machineInfo.gitRef} />
165
167
  </div>
166
- <div>
167
- {serviceCount} services {failingServices.length > 0 ? `(${failingServices.length} failing)` : ""} • {totalLaunches} launches
168
+ <div className={css.vbox(4).flexGrow(1)}>
169
+ <div>
170
+ {machineId}
171
+ </div>
172
+ <div>
173
+ {machineInfo.info["getExternalIP"]}
174
+ </div>
175
+ <div>
176
+ {serviceCount} services {failingServices.length > 0 ? `(${failingServices.length} failing)` : ""} • {totalLaunches} launches
177
+ </div>
168
178
  </div>
169
179
  </div>
170
180
  </div>
181
+ <DeployMachineButtons machines={[machineInfo]} />
171
182
  </div>;
172
183
  })}
173
184
  </div>
@@ -1,18 +1,19 @@
1
1
  import { SocketFunction } from "socket-function/SocketFunction";
2
2
  import { qreact } from "../../4-dom/qreact";
3
- import { MACHINE_RESYNC_INTERVAL, DeployController, ServiceConfig } from "../machineSchema";
3
+ import { MACHINE_RESYNC_INTERVAL, MachineServiceController, ServiceConfig } from "../machineSchema";
4
4
  import { css } from "typesafecss";
5
5
  import { t } from "../../2-proxy/schema2";
6
6
  import { Querysub } from "../../4-querysub/QuerysubController";
7
7
  import { currentViewParam, selectedServiceIdParam, selectedMachineIdParam } from "../urlParams";
8
8
  import { formatTime, formatVeryNiceDateTime } from "socket-function/src/formatting/format";
9
9
  import { InputPicker } from "../../library-components/InputPicker";
10
- import { nextId, sort } from "socket-function/src/misc";
10
+ import { deepCloneJSON, nextId, sort } from "socket-function/src/misc";
11
11
  import { InputLabel } from "../../library-components/InputLabel";
12
12
  import { Button } from "../../library-components/Button";
13
13
  import { isDefined } from "../../misc";
14
14
  import { watchScreenOutput, stopWatchingScreenOutput } from "../machineController";
15
15
  import { getPathStr2 } from "../../path";
16
+ import { Anchor } from "../../library-components/ATag";
16
17
  import { ScrollOnMount } from "../../library-components/ScrollOnMount";
17
18
  import { StickyBottomScroll } from "../../library-components/StickyBottomScroll";
18
19
  import { PrimitiveDisplay } from "../../diagnostics/logs/ObjectDisplay";
@@ -89,6 +90,8 @@ type ServiceConfig = ${serviceConfigType}
89
90
  }
90
91
 
91
92
  private updateUnsavedChanges(updatedConfig: ServiceConfig) {
93
+ // Do not let them update the serviceId, as that would break things
94
+ updatedConfig.serviceId = selectedServiceIdParam.value;
92
95
  const newConfigStr = JSON.stringify(updatedConfig, null, 4);
93
96
  this.state.unsavedChanges = updatedConfig;
94
97
 
@@ -159,6 +162,8 @@ type ServiceConfig = ${serviceConfigType}
159
162
  if (!selectedServiceId) return;
160
163
 
161
164
  Querysub.commit(() => {
165
+ // Do not let them update the serviceId, as that would break things
166
+ updatedConfig.serviceId = selectedServiceIdParam.value;
162
167
  this.state.isSaving = true;
163
168
  this.state.saveError = "";
164
169
  });
@@ -175,7 +180,7 @@ type ServiceConfig = ${serviceConfigType}
175
180
  model.setValue(`const config: ServiceConfig = ${newConfigStr}`);
176
181
  }
177
182
  }
178
- await DeployController(SocketFunction.browserNodeId()).setServiceConfig.promise(selectedServiceId, updatedConfig);
183
+ await MachineServiceController(SocketFunction.browserNodeId()).setServiceConfigs.promise([updatedConfig]);
179
184
  } catch (error) {
180
185
  Querysub.localCommit(() => {
181
186
  this.state.saveError = error instanceof Error ? error.message : String(error);
@@ -194,7 +199,7 @@ type ServiceConfig = ${serviceConfigType}
194
199
  const selectedServiceId = selectedServiceIdParam.value;
195
200
  if (!selectedServiceId) return <div>No service selected</div>;
196
201
 
197
- let controller = DeployController(SocketFunction.browserNodeId());
202
+ let controller = MachineServiceController(SocketFunction.browserNodeId());
198
203
  let originalConfig = controller.getServiceConfig(selectedServiceId);
199
204
  let serviceConfigType = controller.getServiceConfigType();
200
205
 
@@ -333,8 +338,10 @@ type ServiceConfig = ${serviceConfigType}
333
338
  <div
334
339
  className={css.hbox(12)}
335
340
  >
336
- <div className={css.flexGrow(1)}>
337
- {machineId} ({machineInfo.info["getExternalIP"]})
341
+ <div className={css.vbox(5)}>
342
+ <div>
343
+ {machineId} ({machineInfo.info["getExternalIP"]})
344
+ </div>
338
345
  </div>
339
346
  {isMachineDead && (
340
347
  <div className={css.colorhsl(0, 80, 50)}>
@@ -365,15 +372,12 @@ type ServiceConfig = ${serviceConfigType}
365
372
  </div>
366
373
  )}
367
374
 
368
- <div
375
+ <Anchor noStyles
376
+ values={[currentViewParam.getOverride("machine-detail"), selectedMachineIdParam.getOverride(machineId)]}
369
377
  className={css.button.pad2(16, 8).bord2(0, 0, 10) + css.hsl(200, 70, 90)}
370
- onClick={() => {
371
- currentViewParam.value = "machine-detail";
372
- selectedMachineIdParam.value = machineId;
373
- }}
374
378
  >
375
379
  View Machine
376
- </div>
380
+ </Anchor>
377
381
 
378
382
 
379
383
  <div
@@ -513,6 +517,33 @@ type ServiceConfig = ${serviceConfigType}
513
517
 
514
518
  <div className={css.marginAuto}></div>
515
519
 
520
+ <button className={css.pad2(12, 8).button.bord2(0, 0, 20).hsl(110, 70, 90)}
521
+ onClick={() => {
522
+ if (!selectedServiceId) return;
523
+
524
+ const newServiceId = `service-${Math.round(Date.now())}`;
525
+ const clonedConfig = deepCloneJSON(config);
526
+ clonedConfig.serviceId = newServiceId;
527
+ clonedConfig.parameters.deploy = false;
528
+ clonedConfig.info.title += " (Copy)";
529
+
530
+ Querysub.onCommitFinished(async () => {
531
+ try {
532
+ await controller.addServiceConfig.promise(newServiceId, clonedConfig);
533
+ Querysub.commit(() => {
534
+ selectedServiceIdParam.value = newServiceId;
535
+ this.state.unsavedChanges = undefined;
536
+ });
537
+ } catch (error) {
538
+ Querysub.localCommit(() => {
539
+ this.state.saveError = error instanceof Error ? error.message : String(error);
540
+ });
541
+ }
542
+ });
543
+ }}>
544
+ ✚ Duplicate Service
545
+ </button>
546
+
516
547
  <button className={css.pad2(12, 8).button.bord2(0, 80, 50).hsl(0, 80, 90)}
517
548
  onClick={() => {
518
549
  if (!selectedServiceId) return;