dsh-comfyui-gateway 0.4.3 → 0.4.7

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/client.js CHANGED
@@ -51,12 +51,14 @@ window.__ModuleLoader__.load({
51
51
  restart: '重启',
52
52
  remove: '解绑',
53
53
  controlNone: '不受网关托管(只能派发,不能启停)',
54
- controlSpawn: '本机托管:网关启动/停止该进程',
55
- controlSsh: '远程托管:通过 ssh 启停',
56
- command: '启动命令',
57
- commandHint: '整条命令行,例如 E:\\ComfyUI\\python_embeded\\python.exe -s E:\\ComfyUI\\ComfyUI\\main.py --port 8190',
58
- cwd: '工作目录(可空)',
59
- sshHost: 'SSH 目标(如 user@192.168.3.253)',
54
+ controlSpawn: '本机托管:网关启动/停止这个 bat 文件',
55
+ controlSsh: '远程托管:通过 ssh 启停(已停用)',
56
+ batPath: 'bat 文件',
57
+ batHint: '填 .bat 的完整路径,网关启动时执行它;停止时连同它的子进程一起杀掉。',
58
+ batPlaceholder: 'E:\\ComfyUI\\启动.bat',
59
+ batMissing: '填一个存在的 .bat 文件路径',
60
+ batPicked: '已选文件',
61
+ cwd: '工作目录(可空,默认 bat 所在目录)',
60
62
  apply: '应用',
61
63
  probe: '刷新状态',
62
64
  healthy: '在线',
@@ -95,8 +97,8 @@ window.__ModuleLoader__.load({
95
97
  advancedHint: '填了启动命令,面板上的“启动/停止”才会真正作用于本机进程。',
96
98
  controlType: '托管方式',
97
99
  noneOpt: '不托管',
98
- spawnOpt: '本机命令',
99
- sshOpt: 'SSH 远程',
100
+ spawnOpt: '启动 bat 文件',
101
+ sshOpt: 'SSH 远程(旧配置)',
100
102
  applyFirst: '先保存实例修改',
101
103
  changed: '有未保存的修改',
102
104
  refresh: '自动刷新 2s',
@@ -117,6 +119,13 @@ window.__ModuleLoader__.load({
117
119
  noSessions: '当前无人登录',
118
120
  revokeAll: '踢掉全部会话',
119
121
  remotePort: '远程端口',
122
+ remotePortHint: '只服务浏览器;改完点“重启网关”生效。',
123
+ instancePort: '端口',
124
+ editPort: '改端口',
125
+ savePort: '保存端口',
126
+ portRange: '端口 1–65535',
127
+ portSame: '两个端口不能相同',
128
+ baseUrlHint: 'http://主机:端口,不带尾斜杠',
120
129
  gatewayPort: '网关端口',
121
130
  sectionRemote: '远程控制',
122
131
  sectionRemoteHint: '另一台电脑/手机访问的页面:账号、等级、配对码。',
@@ -184,12 +193,14 @@ window.__ModuleLoader__.load({
184
193
  restart: 'Restart',
185
194
  remove: 'Unbind',
186
195
  controlNone: 'Not owned (dispatch only)',
187
- controlSpawn: 'Host-owned: the gateway starts and stops it',
188
- controlSsh: 'Remote-owned over ssh',
189
- command: 'Start command',
190
- commandHint: 'Full command line, e.g. E:\\ComfyUI\\python_embeded\\python.exe -s E:\\ComfyUI\\ComfyUI\\main.py --port 8190',
191
- cwd: 'Working directory (optional)',
192
- sshHost: 'SSH target (user@host)',
196
+ controlSpawn: 'Host-owned: the gateway starts and stops this bat file',
197
+ controlSsh: 'Remote-owned over ssh (retired)',
198
+ batPath: 'Bat file',
199
+ batHint: 'Full path to a .bat file. The gateway runs it on start and kills its whole tree on stop.',
200
+ batPlaceholder: 'E:\\ComfyUI\\start.bat',
201
+ batMissing: 'Point at an existing .bat file',
202
+ batPicked: 'Picked',
203
+ cwd: 'Working directory (optional, defaults to the bat folder)',
193
204
  apply: 'Apply',
194
205
  probe: 'Refresh',
195
206
  healthy: 'online',
@@ -229,7 +240,7 @@ window.__ModuleLoader__.load({
229
240
  controlType: 'Control',
230
241
  noneOpt: 'None',
231
242
  spawnOpt: 'Local command',
232
- sshOpt: 'SSH remote',
243
+ sshOpt: 'SSH remote (legacy)',
233
244
  applyFirst: 'Save instance changes first',
234
245
  changed: 'unsaved changes',
235
246
  refresh: 'auto refresh 2s',
@@ -250,6 +261,13 @@ window.__ModuleLoader__.load({
250
261
  noSessions: 'Nobody is logged in',
251
262
  revokeAll: 'Revoke all sessions',
252
263
  remotePort: 'Remote port',
264
+ remotePortHint: 'Serves browsers only; press Restart gateway to apply.',
265
+ instancePort: 'Port',
266
+ editPort: 'Edit port',
267
+ savePort: 'Save port',
268
+ portRange: 'Port 1–65535',
269
+ portSame: 'The two ports must differ',
270
+ baseUrlHint: 'http://host:port, no trailing slash',
253
271
  gatewayPort: 'Gateway port',
254
272
  sectionRemote: 'Remote console',
255
273
  sectionRemoteHint: 'The page other machines open: accounts, tiers, pairing code.',
@@ -376,6 +394,47 @@ window.__ModuleLoader__.load({
376
394
  return data;
377
395
  }
378
396
 
397
+ /**
398
+ * Status rows and writable rows are not the same shape, and mixing them up is
399
+ * what made the 托管方式 dropdown snap back to 不托管:
400
+ *
401
+ * status row: { control: 'spawn', controlDetail: { type:'spawn', command, cwd } }
402
+ * writable: { control: { type:'spawn', command, cwd } }
403
+ *
404
+ * The card seeds its draft from the status row, so `draft.control` is the
405
+ * *string* "spawn" and `draft.control.type` is undefined. The select then
406
+ * reads undefined → falls back to 'none', and writing `{...draft.control}`
407
+ * spreads a string, producing `{ type: 'ssh' }` with no command at all. After
408
+ * the next 2 s poll the entry is unchanged on the server, the draft is
409
+ * reseeded from the status row, and the choice visibly jumps back.
410
+ *
411
+ * One converter, used by the card and by every full-list write, so there is
412
+ * exactly one place that knows the difference.
413
+ */
414
+ function writableInstance(entry) {
415
+ const detail = entry.controlDetail;
416
+ const control =
417
+ detail !== undefined && detail !== null && typeof detail === 'object'
418
+ ? { ...detail }
419
+ : { type: typeof entry.control === 'string' && entry.control !== '' ? entry.control : 'none' };
420
+ // A status row always reports a detail object; keep the bat path even when
421
+ // the type is 'none' so switching back and forth does not lose it.
422
+ return {
423
+ id: entry.id,
424
+ label: entry.label,
425
+ baseUrl: entry.baseUrl,
426
+ weight: entry.weight ?? 1,
427
+ maxConcurrent: entry.maxConcurrent ?? 1,
428
+ enabled: entry.enabled !== false,
429
+ autoStart: entry.autoStart === true,
430
+ control: {
431
+ type: control.type === 'script' ? 'script' : 'none',
432
+ batPath: typeof control.batPath === 'string' ? control.batPath : '',
433
+ cwd: typeof control.cwd === 'string' ? control.cwd : '',
434
+ },
435
+ };
436
+ }
437
+
379
438
  const STATE_LABEL = {
380
439
  queued: 'queued',
381
440
  running: 'running',
@@ -411,11 +470,33 @@ window.__ModuleLoader__.load({
411
470
  /** One instance card: identity, live numbers, and its process controls. */
412
471
  function InstanceCard({ entry, t, onAction, onPatch, onRemove, busy }) {
413
472
  const [open, setOpen] = useState(false);
414
- const [draft, setDraft] = useState(entry);
415
- useEffect(() => setDraft(entry), [entry]);
473
+ // Seed from the *writable* shape, never from the status row: see
474
+ // writableInstance() for why spreading a status row loses the choice.
475
+ const [draft, setDraft] = useState(() => writableInstance(entry));
476
+ // The URL is the one field a person actually needs to fix from the card
477
+ // head (a port moved, a host changed), so it gets its own inline editor
478
+ // with its own save. Everything else lives behind "advanced".
479
+ const [editingUrl, setEditingUrl] = useState(false);
480
+ const [urlDraft, setUrlDraft] = useState(String(entry.baseUrl ?? ''));
481
+ const [urlError, setUrlError] = useState(null);
482
+ /**
483
+ * Reseed only when the *saved* configuration actually changed.
484
+ *
485
+ * Depending on `entry` reseeds on every poll: the status payload is rebuilt
486
+ * every 2 s, so `entry` is a new object each tick, the effect fires, and the
487
+ * draft is thrown away — the dropdown visibly snaps back to 不托管 even
488
+ * though nobody saved anything. The signature below changes only when the
489
+ * server really has different values (after Apply, or an external edit).
490
+ */
491
+ const savedSignature = JSON.stringify(writableInstance(entry));
492
+ useEffect(() => {
493
+ setDraft(JSON.parse(savedSignature));
494
+ }, [savedSignature]);
495
+ // The URL field is seeded when the editor opens, not on every status
496
+ // poll: polling would overwrite what the person is typing.
416
497
  const dirty = useMemo(
417
- () => JSON.stringify(draft) !== JSON.stringify(entry),
418
- [draft, entry],
498
+ () => JSON.stringify(draft) !== savedSignature,
499
+ [draft, savedSignature],
419
500
  );
420
501
  const device = (entry.devices ?? [])[0];
421
502
  const vramText =
@@ -423,6 +504,42 @@ window.__ModuleLoader__.load({
423
504
  ? `${Math.round((device.vramTotal - (device.vramFree ?? 0)) / 1048576)} / ${Math.round(device.vramTotal / 1048576)} MiB`
424
505
  : '—';
425
506
  const owned = entry.control !== 'none';
507
+ /**
508
+ * A bare port number is accepted as a shorthand: "8190" keeps the host
509
+ * the instance already has, which is what somebody changing a port means.
510
+ */
511
+ const normalizeUrl = (raw) => {
512
+ const text = String(raw ?? '').trim();
513
+ if (text === '') return '';
514
+ if (/^\d+$/.test(text)) {
515
+ const host = /^[a-z]+:\/\/([^/:]+)/i.exec(String(entry.baseUrl ?? ''))?.[1];
516
+ return host === undefined ? '' : `http://${host}:${text}`;
517
+ }
518
+ const withScheme = /^https?:\/\//i.test(text) ? text : `http://${text}`;
519
+ try {
520
+ const url = new URL(withScheme);
521
+ const port = Number(url.port);
522
+ if (!Number.isFinite(port) || port < 1 || port > 65535) return '';
523
+ return `${url.protocol}//${url.host}`;
524
+ } catch {
525
+ return '';
526
+ }
527
+ };
528
+ const saveUrl = () => {
529
+ const next = normalizeUrl(urlDraft);
530
+ if (next === '') {
531
+ setUrlError(t('portRange'));
532
+ return;
533
+ }
534
+ setUrlError(null);
535
+ setEditingUrl(false);
536
+ // Only identity + the field being edited travel: the status entry
537
+ // carries display-only keys (`control` is the string "spawn" there,
538
+ // while the writable shape wants `control.type`). Spreading the whole
539
+ // entry would send the display string back and wipe the process
540
+ // settings.
541
+ onPatch({ id: entry.id, baseUrl: next });
542
+ };
426
543
  return h(
427
544
  'div',
428
545
  { className: 'cgw-card' },
@@ -431,13 +548,51 @@ window.__ModuleLoader__.load({
431
548
  { className: 'cgw-card-head' },
432
549
  h('span', { className: `cgw-dot cgw-dot--${entry.healthy ? 'ok' : entry.starting ? 'warn' : 'bad'}` }),
433
550
  h('span', { className: 'cgw-section-title' }, entry.label || entry.id),
434
- h('span', { className: 'cgw-meta cgw-mono' }, entry.baseUrl),
551
+ editingUrl
552
+ ? h(
553
+ 'span',
554
+ { className: 'cgw-row' },
555
+ h('input', {
556
+ className: 'cgw-input cgw-mono',
557
+ style: { width: 190 },
558
+ placeholder: t('baseUrlHint'),
559
+ value: urlDraft,
560
+ onChange: (event) => setUrlDraft(event.target.value),
561
+ }),
562
+ h(
563
+ 'button',
564
+ { className: 'cgw-btn cgw-btn--primary', disabled: busy, onClick: () => saveUrl() },
565
+ t('savePort'),
566
+ ),
567
+ h(
568
+ 'button',
569
+ { className: 'cgw-btn cgw-btn--ghost', disabled: busy, onClick: () => setEditingUrl(false) },
570
+ '×',
571
+ ),
572
+ )
573
+ : h('span', { className: 'cgw-meta cgw-mono' }, entry.baseUrl),
435
574
  h('span', { className: 'cgw-grow' }),
436
575
  entry.starting ? h('span', { className: 'cgw-meta' }, t('starting')) : null,
437
576
  entry.stopping ? h('span', { className: 'cgw-meta' }, t('stopping')) : null,
577
+ h(
578
+ 'button',
579
+ {
580
+ className: 'cgw-btn cgw-btn--ghost',
581
+ disabled: busy || editingUrl,
582
+ title: t('baseUrlHint'),
583
+ onClick: () => {
584
+ setUrlDraft(String(entry.baseUrl ?? ''));
585
+ setUrlError(null);
586
+ setEditingUrl(true);
587
+ },
588
+ },
589
+ t('editPort'),
590
+ ),
438
591
  h('span', { className: 'cgw-badge' }, entry.healthy ? t('healthy') : t('unhealthy')),
439
592
  entry.pid ? h('span', { className: 'cgw-badge cgw-mono' }, `pid ${entry.pid}`) : null,
440
593
  ),
594
+ urlError === null ? null : h('div', { className: 'cgw-error' }, urlError),
595
+
441
596
  h(
442
597
  'div',
443
598
  { className: 'cgw-kv' },
@@ -534,8 +689,10 @@ window.__ModuleLoader__.load({
534
689
  setDraft({ ...draft, control: { ...(draft.control ?? {}), type: event.target.value } }),
535
690
  },
536
691
  h('option', { value: 'none' }, t('noneOpt')),
537
- h('option', { value: 'spawn' }, t('spawnOpt')),
538
- h('option', { value: 'ssh' }, t('sshOpt')),
692
+ h('option', { value: 'script' }, t('spawnOpt')),
693
+ // Legacy rows that still say 'ssh' keep an option so the
694
+ // select does not show a blank line; saving normalizes it.
695
+ draft.control?.type === 'ssh' ? h('option', { value: 'ssh' }, t('sshOpt')) : null,
539
696
  ),
540
697
  ),
541
698
  (draft.control?.type ?? 'none') === 'none'
@@ -543,31 +700,31 @@ window.__ModuleLoader__.load({
543
700
  : h(
544
701
  'div',
545
702
  { style: { display: 'flex', flexDirection: 'column', gap: 6 } },
546
- draft.control?.type === 'ssh'
547
- ? h('input', {
548
- className: 'cgw-input',
549
- placeholder: t('sshHost'),
550
- value: draft.control?.sshHost ?? '',
551
- onChange: (event) =>
552
- setDraft({ ...draft, control: { ...(draft.control ?? {}), sshHost: event.target.value } }),
553
- })
554
- : null,
555
- h('input', {
556
- className: 'cgw-input',
557
- placeholder: t('commandHint'),
558
- value: draft.control?.command ?? '',
559
- onChange: (event) =>
560
- setDraft({ ...draft, control: { ...(draft.control ?? {}), command: event.target.value } }),
561
- }),
562
- draft.control?.type === 'spawn'
563
- ? h('input', {
564
- className: 'cgw-input',
565
- placeholder: t('cwd'),
566
- value: draft.control?.cwd ?? '',
567
- onChange: (event) =>
568
- setDraft({ ...draft, control: { ...(draft.control ?? {}), cwd: event.target.value } }),
569
- })
570
- : null,
703
+ h(
704
+ 'div',
705
+ { className: 'cgw-row' },
706
+ h('span', { className: 'cgw-meta' }, t('batPath')),
707
+ h('input', {
708
+ className: 'cgw-input cgw-input--wide cgw-mono',
709
+ placeholder: t('batPlaceholder'),
710
+ value: draft.control?.batPath ?? '',
711
+ onChange: (event) =>
712
+ setDraft({ ...draft, control: { ...(draft.control ?? {}), batPath: event.target.value } }),
713
+ }),
714
+ ),
715
+ h('div', { className: 'cgw-meta' }, t('batHint')),
716
+ h(
717
+ 'div',
718
+ { className: 'cgw-row' },
719
+ h('span', { className: 'cgw-meta' }, t('cwd')),
720
+ h('input', {
721
+ className: 'cgw-input cgw-input--wide cgw-mono',
722
+ placeholder: t('cwd'),
723
+ value: draft.control?.cwd ?? '',
724
+ onChange: (event) =>
725
+ setDraft({ ...draft, control: { ...(draft.control ?? {}), cwd: event.target.value } }),
726
+ }),
727
+ ),
571
728
  ),
572
729
  h(
573
730
  'div',
@@ -594,7 +751,7 @@ window.__ModuleLoader__.load({
594
751
  }
595
752
 
596
753
  /** Remote-access control: the pairing code, accounts, sessions, leases. */
597
- function RemoteCard({ t, status, busy, act }) {
754
+ function RemoteCard({ t, status, busy, act, portDraft, onPortDraft, onSavePort }) {
598
755
  const [code, setCode] = useState(null);
599
756
  const [name, setName] = useState('');
600
757
  const [password, setPassword] = useState('');
@@ -718,6 +875,23 @@ window.__ModuleLoader__.load({
718
875
  h('span', { className: 'cgw-meta' }, remote.listening ? t('remoteOn') : t('remoteOff')),
719
876
  ),
720
877
  remote.enabled === true ? null : h('div', { className: 'cgw-meta' }, t('remoteOffHint')),
878
+ h(
879
+ 'div',
880
+ { className: 'cgw-row' },
881
+ h('span', { className: 'cgw-meta' }, t('remotePort')),
882
+ h('input', {
883
+ className: 'cgw-input cgw-input--num',
884
+ value: portDraft,
885
+ onChange: (event) => onPortDraft(String(event.target.value).replace(/[^0-9]/g, '')),
886
+ }),
887
+ h(
888
+ 'button',
889
+ { className: 'cgw-btn', disabled: busy, onClick: () => void onSavePort() },
890
+ t('save'),
891
+ ),
892
+ h('span', { className: 'cgw-grow' }),
893
+ h('span', { className: 'cgw-meta' }, t('remotePortHint')),
894
+ ),
721
895
  h('div', { className: 'cgw-meta' }, t('requireCodeHint')),
722
896
  h(
723
897
  'div',
@@ -947,80 +1121,66 @@ window.__ModuleLoader__.load({
947
1121
 
948
1122
  const instanceAction = (action, id) => act('/comfyui-gateway/instances', { action, id });
949
1123
 
1124
+ /** Every full-list write goes through the one shape converter. */
1125
+ const writableList = () => (status?.instances ?? []).map((entry) => writableInstance(entry));
1126
+
950
1127
  /** Apply one instance patch: replace that row, keep the rest verbatim. */
951
1128
  const patchInstance = (patch) => {
952
- const current = status?.instances ?? [];
953
- const raw = current.map((entry) => ({
954
- id: entry.id,
955
- label: entry.label,
956
- baseUrl: entry.baseUrl,
957
- weight: entry.weight ?? 1,
958
- maxConcurrent: entry.maxConcurrent ?? 1,
959
- enabled: entry.enabled !== false,
960
- autoStart: entry.autoStart === true,
961
- control: entry.controlDetail ?? { type: entry.control === 'none' ? 'none' : entry.control },
962
- }));
1129
+ const raw = writableList();
963
1130
  const index = raw.findIndex((entry) => entry.id === patch.id);
964
1131
  if (index < 0) return;
965
- raw[index] = { ...raw[index], ...patch, id: patch.id };
1132
+ // Merge into the converted row, so a patch carrying only `{id, baseUrl}`
1133
+ // still sends the row's real control block instead of dropping it.
1134
+ raw[index] = { ...raw[index], ...patch, id: patch.id, control: patch.control ?? raw[index].control };
966
1135
  return act('/comfyui-gateway/instances', { action: 'apply', instances: raw });
967
1136
  };
968
1137
 
969
1138
  const addInstance = async () => {
970
1139
  const url = newUrl.trim();
971
1140
  if (url === '') return;
972
- const current = status?.instances ?? [];
973
- const raw = current.map((entry) => ({
974
- id: entry.id,
975
- label: entry.label,
976
- baseUrl: entry.baseUrl,
977
- weight: entry.weight ?? 1,
978
- maxConcurrent: entry.maxConcurrent ?? 1,
979
- enabled: entry.enabled !== false,
980
- autoStart: entry.autoStart === true,
981
- control: entry.controlDetail ?? { type: entry.control === 'none' ? 'none' : entry.control },
982
- }));
1141
+ const raw = writableList();
983
1142
  raw.push({ baseUrl: url, enabled: true, maxConcurrent: 1, weight: 1, control: { type: 'none' } });
984
1143
  const data = await act('/comfyui-gateway/instances', { action: 'apply', instances: raw });
985
1144
  if (data?.ok !== false) setNewUrl('');
986
1145
  };
987
1146
 
988
1147
  const removeInstance = (id) => {
989
- const raw = (status?.instances ?? [])
990
- .filter((entry) => entry.id !== id)
991
- .map((entry) => ({
992
- id: entry.id,
993
- label: entry.label,
994
- baseUrl: entry.baseUrl,
995
- weight: entry.weight ?? 1,
996
- maxConcurrent: entry.maxConcurrent ?? 1,
997
- enabled: entry.enabled !== false,
998
- autoStart: entry.autoStart === true,
999
- control: entry.controlDetail ?? { type: entry.control === 'none' ? 'none' : entry.control },
1000
- }));
1148
+ const raw = writableList().filter((entry) => entry.id !== id);
1001
1149
  return act('/comfyui-gateway/instances', { action: 'apply', instances: raw });
1002
1150
  };
1003
1151
 
1004
1152
  const toggleDynamic = (on) => act('/comfyui-gateway/pool', { action: 'dynamic', enabled: on });
1005
1153
 
1006
- /** Both ports save the same way: validate, write, say a restart is due. */
1007
- const savePorts = async () => {
1008
- const port = Number(draftPort);
1154
+ /**
1155
+ * Each port is saved from the card that owns it: the gateway port from
1156
+ * the gateway card, the remote port from the remote card. Splitting the
1157
+ * form is the whole point — a person editing the console port is looking
1158
+ * at console settings, not at the gateway listener. They still share one
1159
+ * validation because they must not collide.
1160
+ */
1161
+ const commitPort = async (port) => {
1009
1162
  const remotePort = Number(draftRemotePort);
1010
- for (const value of [port, remotePort]) {
1011
- if (!Number.isFinite(value) || value < 1 || value > 65535) {
1012
- setNotice({ kind: 'error', text: '端口 1–65535' });
1013
- return;
1014
- }
1163
+ const gatewayPort = Number(draftPort);
1164
+ const other = port === gatewayPort ? remotePort : gatewayPort;
1165
+ if (!Number.isFinite(port) || port < 1 || port > 65535) {
1166
+ setNotice({ kind: 'error', text: t('portRange') });
1167
+ return;
1015
1168
  }
1016
- if (port === remotePort) {
1017
- setNotice({ kind: 'error', text: '两个端口不能相同' });
1169
+ if (Number.isFinite(other) && port === other) {
1170
+ setNotice({ kind: 'error', text: t('portSame') });
1018
1171
  return;
1019
1172
  }
1020
- await act('/comfyui-gateway/settings', { listen: { port }, remote: { port: remotePort } });
1173
+ if (port === gatewayPort) {
1174
+ await act('/comfyui-gateway/settings', { listen: { port } });
1175
+ } else {
1176
+ await act('/comfyui-gateway/settings', { remote: { port } });
1177
+ }
1021
1178
  setNotice({ kind: 'info', text: t('remoteRestart') });
1022
1179
  };
1023
1180
 
1181
+ const saveGatewayPort = () => commitPort(Number(draftPort));
1182
+ const saveRemotePort = () => commitPort(Number(draftRemotePort));
1183
+
1024
1184
 
1025
1185
 
1026
1186
  const restartGateway = async () => {
@@ -1091,7 +1251,15 @@ window.__ModuleLoader__.load({
1091
1251
  h('span', { className: 'cgw-group-title' }, t('sectionRemote')),
1092
1252
  h('span', { className: 'cgw-meta' }, t('sectionRemoteHint')),
1093
1253
  ),
1094
- h(RemoteCard, { t, status, busy, act }),
1254
+ h(RemoteCard, {
1255
+ t,
1256
+ status,
1257
+ busy,
1258
+ act,
1259
+ portDraft: draftRemotePort,
1260
+ onPortDraft: setDraftRemotePort,
1261
+ onSavePort: saveRemotePort,
1262
+ }),
1095
1263
  ),
1096
1264
  h(
1097
1265
  'div',
@@ -1145,16 +1313,8 @@ window.__ModuleLoader__.load({
1145
1313
  onChange: (event) => setDraftPort(event.target.value.replace(/[^0-9]/g, '')),
1146
1314
  },
1147
1315
  ),
1148
- h('span', { className: 'cgw-meta' }, t('remotePort')),
1149
- h(
1150
- 'input',
1151
- {
1152
- className: 'cgw-input cgw-input--num',
1153
- value: draftRemotePort,
1154
- onChange: (event) => setDraftRemotePort(event.target.value.replace(/[^0-9]/g, '')),
1155
- },
1156
- ),
1157
- h('button', { className: 'cgw-btn', disabled: busy, onClick: () => void savePorts() }, t('save')),
1316
+ h('button', { className: 'cgw-btn', disabled: busy, onClick: () => void saveGatewayPort() }, t('save')),
1317
+ h('span', { className: 'cgw-grow' }),
1158
1318
  h('code', { className: 'cgw-mono cgw-meta' }, String(status.settings?.listen?.host ?? '0.0.0.0')),
1159
1319
  ),
1160
1320
  h(
package/lib/config.js CHANGED
@@ -85,7 +85,7 @@ export function defaultSettings() {
85
85
  maxConcurrent: 1,
86
86
  enabled: true,
87
87
  autoStart: false,
88
- control: { type: 'none', command: '', cwd: '', sshHost: '', stopTimeoutMs: 15000, readyTimeoutMs: 300000 },
88
+ control: { type: 'none', batPath: '', cwd: '', stopTimeoutMs: 15000, readyTimeoutMs: 300000 },
89
89
  },
90
90
  {
91
91
  id: 'p8189',
@@ -96,7 +96,7 @@ export function defaultSettings() {
96
96
  maxConcurrent: 1,
97
97
  enabled: true,
98
98
  autoStart: false,
99
- control: { type: 'none', command: '', cwd: '', sshHost: '', stopTimeoutMs: 15000, readyTimeoutMs: 300000 },
99
+ control: { type: 'none', batPath: '', cwd: '', stopTimeoutMs: 15000, readyTimeoutMs: 300000 },
100
100
  },
101
101
  ],
102
102
  };
package/lib/gateway.js CHANGED
@@ -483,7 +483,13 @@ export class GatewayServer {
483
483
  maxConcurrent: runtime.instance.maxConcurrent,
484
484
  weight: runtime.instance.weight,
485
485
  control: runtime.instance.control.type,
486
- /** The full control block, so the panel can edit command/cwd/sshHost. */
486
+ /**
487
+ * The full control block, so the panel can edit batPath/cwd.
488
+ *
489
+ * `control` above is the *type string* — the panel must never spread it
490
+ * into an object (that is what made the 托管方式 dropdown snap back to
491
+ * 不托管: `{...'spawn'}` yields `{}` with no type). Read this one instead.
492
+ */
487
493
  controlDetail: runtime.instance.control,
488
494
  pid: runtime.pid,
489
495
  starting: runtime.starting,