@serve.zone/dcrouter 13.19.1 → 13.20.2

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.
@@ -43,22 +43,28 @@ function parseTargetPort(value: any): number | undefined {
43
43
 
44
44
  function getRouteTargetInputs(formEl: any) {
45
45
  const textInputs = Array.from(formEl.querySelectorAll('dees-input-text')) as any[];
46
+ const checkboxInputs = Array.from(formEl.querySelectorAll('dees-input-checkbox')) as any[];
46
47
  return {
47
48
  hostInput: textInputs.find((input) => input.key === 'targetHost'),
48
49
  portInput: textInputs.find((input) => input.key === 'targetPort'),
50
+ preservePortInput: checkboxInputs.find((input) => input.key === 'preserveMatchPort'),
49
51
  };
50
52
  }
51
53
 
52
54
  function setupTargetInputState(formEl: any) {
53
55
  const updateState = async () => {
54
56
  const data = await formEl.collectFormData();
57
+ const contentEl = formEl.closest('.content') || formEl.parentElement;
55
58
  const usesNetworkTarget = !!getDropdownKey(data.networkTargetRef);
56
- const { hostInput, portInput } = getRouteTargetInputs(formEl);
59
+ const preserveMatchPort = !usesNetworkTarget && Boolean(data.preserveMatchPort);
60
+ const { hostInput, portInput, preservePortInput } = getRouteTargetInputs(formEl);
57
61
  const hostDescription = usesNetworkTarget
58
62
  ? 'Controlled by the selected network target'
59
63
  : 'Used when no network target is selected';
60
64
  const portDescription = usesNetworkTarget
61
65
  ? 'Controlled by the selected network target'
66
+ : preserveMatchPort
67
+ ? 'Forwarded to the backend on the same port the client matched'
62
68
  : 'Used when no network target is selected';
63
69
 
64
70
  if (hostInput) {
@@ -67,10 +73,24 @@ function setupTargetInputState(formEl: any) {
67
73
  hostInput.description = hostDescription;
68
74
  }
69
75
  if (portInput) {
70
- portInput.disabled = usesNetworkTarget;
71
- portInput.required = !usesNetworkTarget;
76
+ portInput.disabled = usesNetworkTarget || preserveMatchPort;
77
+ portInput.required = !usesNetworkTarget && !preserveMatchPort;
72
78
  portInput.description = portDescription;
73
79
  }
80
+ if (preservePortInput) {
81
+ preservePortInput.disabled = usesNetworkTarget;
82
+ preservePortInput.description = usesNetworkTarget
83
+ ? 'Unavailable when a network target is selected'
84
+ : 'Forward to the backend using the same port that matched this route';
85
+ if (usesNetworkTarget) {
86
+ preservePortInput.value = false;
87
+ }
88
+ }
89
+
90
+ const remoteIngressGroup = contentEl?.querySelector('.remoteIngressGroup') as HTMLElement | null;
91
+ if (remoteIngressGroup) {
92
+ remoteIngressGroup.style.display = Boolean(data.remoteIngressEnabled) ? 'flex' : 'none';
93
+ }
74
94
 
75
95
  await formEl.updateRequiredStatus?.();
76
96
  };
@@ -465,10 +485,13 @@ export class OpsViewRoutes extends DeesElement {
465
485
  ? (Array.isArray(route.match.domains) ? route.match.domains : [route.match.domains])
466
486
  : [];
467
487
  const firstTarget = route.action.targets?.[0];
488
+ const currentPreserveMatchPort = firstTarget?.port === 'preserve';
468
489
  const currentTargetHost = firstTarget
469
490
  ? (Array.isArray(firstTarget.host) ? firstTarget.host[0] : firstTarget.host)
470
491
  : '';
471
- const currentTargetPort = firstTarget?.port != null ? String(firstTarget.port) : '';
492
+ const currentTargetPort = typeof firstTarget?.port === 'number' ? String(firstTarget.port) : '';
493
+ const currentRemoteIngressEnabled = route.remoteIngress?.enabled === true;
494
+ const currentEdgeFilter = route.remoteIngress?.edgeFilter || [];
472
495
 
473
496
  // Compute current TLS state for pre-population
474
497
  const currentTls = (route.action as any).tls;
@@ -493,6 +516,11 @@ export class OpsViewRoutes extends DeesElement {
493
516
  <dees-input-dropdown .key=${'networkTargetRef'} .label=${'Network Target'} .options=${targetOptions} .selectedOption=${targetOptions.find((o) => o.key === (merged.metadata?.networkTargetRef || '')) || null}></dees-input-dropdown>
494
517
  <dees-input-text .key=${'targetHost'} .label=${'Target Host'} .description=${'Used when no network target is selected'} .value=${currentTargetHost}></dees-input-text>
495
518
  <dees-input-text .key=${'targetPort'} .label=${'Target Port'} .description=${'Used when no network target is selected'} .value=${currentTargetPort}></dees-input-text>
519
+ <dees-input-checkbox .key=${'preserveMatchPort'} .label=${'Preserve incoming port'} .value=${currentPreserveMatchPort}></dees-input-checkbox>
520
+ <dees-input-checkbox .key=${'remoteIngressEnabled'} .label=${'Enable Remote Ingress'} .value=${currentRemoteIngressEnabled}></dees-input-checkbox>
521
+ <div class="remoteIngressGroup" style="display: ${currentRemoteIngressEnabled ? 'flex' : 'none'}; flex-direction: column; gap: 16px;">
522
+ <dees-input-list .key=${'remoteIngressEdgeFilter'} .label=${'Edge Filter'} .description=${'Optional edge IDs or tags. Leave empty to allow all edges.'} .placeholder=${'Add edge ID or tag...'} .value=${currentEdgeFilter}></dees-input-list>
523
+ </div>
496
524
  <dees-input-dropdown .key=${'tlsMode'} .label=${'TLS Mode'} .options=${tlsModeOptions} .selectedOption=${tlsModeOptions.find((o) => o.key === currentTlsMode) || tlsModeOptions[0]}></dees-input-dropdown>
497
525
  <div class="tlsCertificateGroup" style="display: ${needsCert ? 'flex' : 'none'}; flex-direction: column; gap: 16px;">
498
526
  <dees-input-dropdown .key=${'tlsCertificate'} .label=${'Certificate'} .options=${tlsCertOptions} .selectedOption=${tlsCertOptions.find((o) => o.key === currentTlsCert) || tlsCertOptions[0]}></dees-input-dropdown>
@@ -526,14 +554,22 @@ export class OpsViewRoutes extends DeesElement {
526
554
 
527
555
  const profileKey = getDropdownKey(formData.sourceProfileRef);
528
556
  const targetKey = getDropdownKey(formData.networkTargetRef);
529
- const targetPort = parseTargetPort(formData.targetPort)
530
- ?? (targetKey ? parseTargetPort(currentTargetPort) ?? ports[0] : undefined);
557
+ const preserveMatchPort = !targetKey && Boolean(formData.preserveMatchPort);
558
+ const targetPort = preserveMatchPort
559
+ ? 'preserve'
560
+ : parseTargetPort(formData.targetPort)
561
+ ?? (targetKey ? parseTargetPort(currentTargetPort) ?? ports[0] : undefined);
531
562
 
532
563
  if (targetPort === undefined) {
533
564
  alert('Target Port must be a valid port number when no network target is selected.');
534
565
  return;
535
566
  }
536
567
 
568
+ const remoteIngressEnabled = Boolean(formData.remoteIngressEnabled);
569
+ const remoteIngressEdgeFilter: string[] = Array.isArray(formData.remoteIngressEdgeFilter)
570
+ ? formData.remoteIngressEdgeFilter.filter(Boolean)
571
+ : [];
572
+
537
573
  const updatedRoute: any = {
538
574
  name: formData.name,
539
575
  match: {
@@ -549,6 +585,12 @@ export class OpsViewRoutes extends DeesElement {
549
585
  },
550
586
  ],
551
587
  },
588
+ remoteIngress: remoteIngressEnabled
589
+ ? {
590
+ enabled: true,
591
+ ...(remoteIngressEdgeFilter.length > 0 ? { edgeFilter: remoteIngressEdgeFilter } : {}),
592
+ }
593
+ : null,
552
594
  ...(priority != null && !isNaN(priority) ? { priority } : {}),
553
595
  };
554
596
 
@@ -640,6 +682,11 @@ export class OpsViewRoutes extends DeesElement {
640
682
  <dees-input-dropdown .key=${'networkTargetRef'} .label=${'Network Target'} .options=${targetOptions}></dees-input-dropdown>
641
683
  <dees-input-text .key=${'targetHost'} .label=${'Target Host'} .description=${'Used when no network target is selected'} .value=${'localhost'}></dees-input-text>
642
684
  <dees-input-text .key=${'targetPort'} .label=${'Target Port'} .description=${'Used when no network target is selected'}></dees-input-text>
685
+ <dees-input-checkbox .key=${'preserveMatchPort'} .label=${'Preserve incoming port'} .value=${false}></dees-input-checkbox>
686
+ <dees-input-checkbox .key=${'remoteIngressEnabled'} .label=${'Enable Remote Ingress'} .value=${false}></dees-input-checkbox>
687
+ <div class="remoteIngressGroup" style="display: none; flex-direction: column; gap: 16px;">
688
+ <dees-input-list .key=${'remoteIngressEdgeFilter'} .label=${'Edge Filter'} .description=${'Optional edge IDs or tags. Leave empty to allow all edges.'} .placeholder=${'Add edge ID or tag...'}></dees-input-list>
689
+ </div>
643
690
  <dees-input-dropdown .key=${'tlsMode'} .label=${'TLS Mode'} .options=${tlsModeOptions} .selectedOption=${tlsModeOptions[0]}></dees-input-dropdown>
644
691
  <div class="tlsCertificateGroup" style="display: none; flex-direction: column; gap: 16px;">
645
692
  <dees-input-dropdown .key=${'tlsCertificate'} .label=${'Certificate'} .options=${tlsCertOptions} .selectedOption=${tlsCertOptions[0]}></dees-input-dropdown>
@@ -673,14 +720,22 @@ export class OpsViewRoutes extends DeesElement {
673
720
 
674
721
  const profileKey = getDropdownKey(formData.sourceProfileRef);
675
722
  const targetKey = getDropdownKey(formData.networkTargetRef);
676
- const targetPort = parseTargetPort(formData.targetPort)
677
- ?? (targetKey ? ports[0] : undefined);
723
+ const preserveMatchPort = !targetKey && Boolean(formData.preserveMatchPort);
724
+ const targetPort = preserveMatchPort
725
+ ? 'preserve'
726
+ : parseTargetPort(formData.targetPort)
727
+ ?? (targetKey ? ports[0] : undefined);
678
728
 
679
729
  if (targetPort === undefined) {
680
730
  alert('Target Port must be a valid port number when no network target is selected.');
681
731
  return;
682
732
  }
683
733
 
734
+ const remoteIngressEnabled = Boolean(formData.remoteIngressEnabled);
735
+ const remoteIngressEdgeFilter: string[] = Array.isArray(formData.remoteIngressEdgeFilter)
736
+ ? formData.remoteIngressEdgeFilter.filter(Boolean)
737
+ : [];
738
+
684
739
  const route: any = {
685
740
  name: formData.name,
686
741
  match: {
@@ -696,6 +751,14 @@ export class OpsViewRoutes extends DeesElement {
696
751
  },
697
752
  ],
698
753
  },
754
+ ...(remoteIngressEnabled
755
+ ? {
756
+ remoteIngress: {
757
+ enabled: true,
758
+ ...(remoteIngressEdgeFilter.length > 0 ? { edgeFilter: remoteIngressEdgeFilter } : {}),
759
+ },
760
+ }
761
+ : {}),
699
762
  ...(priority != null && !isNaN(priority) ? { priority } : {}),
700
763
  };
701
764
 
@@ -49,19 +49,28 @@ export class OpsViewVpn extends DeesElement {
49
49
  @state()
50
50
  accessor vpnState: appstate.IVpnState = appstate.vpnStatePart.getState()!;
51
51
 
52
+ @state()
53
+ accessor targetProfilesState: appstate.ITargetProfilesState = appstate.targetProfilesStatePart.getState()!;
54
+
52
55
  constructor() {
53
56
  super();
54
57
  const sub = appstate.vpnStatePart.select().subscribe((newState) => {
55
58
  this.vpnState = newState;
56
59
  });
57
60
  this.rxSubscriptions.push(sub);
61
+
62
+ const targetProfilesSub = appstate.targetProfilesStatePart.select().subscribe((newState) => {
63
+ this.targetProfilesState = newState;
64
+ });
65
+ this.rxSubscriptions.push(targetProfilesSub);
58
66
  }
59
67
 
60
68
  async connectedCallback() {
61
69
  await super.connectedCallback();
62
- await appstate.vpnStatePart.dispatchAction(appstate.fetchVpnAction, null);
63
- // Ensure target profiles are loaded for autocomplete candidates
64
- await appstate.targetProfilesStatePart.dispatchAction(appstate.fetchTargetProfilesAction, null);
70
+ await Promise.all([
71
+ appstate.vpnStatePart.dispatchAction(appstate.fetchVpnAction, null),
72
+ appstate.targetProfilesStatePart.dispatchAction(appstate.fetchTargetProfilesAction, null),
73
+ ]);
65
74
  }
66
75
 
67
76
  public static styles = [
@@ -330,13 +339,7 @@ export class OpsViewVpn extends DeesElement {
330
339
  'Status': statusHtml,
331
340
  'Routing': routingHtml,
332
341
  'VPN IP': client.assignedIp || '-',
333
- 'Target Profiles': client.targetProfileIds?.length
334
- ? html`${client.targetProfileIds.map(id => {
335
- const profileState = appstate.targetProfilesStatePart.getState();
336
- const profile = profileState?.profiles.find(p => p.id === id);
337
- return html`<span class="tagBadge">${profile?.name || id}</span>`;
338
- })}`
339
- : '-',
342
+ 'Target Profiles': this.renderTargetProfileBadges(client.targetProfileIds),
340
343
  'Description': client.description || '-',
341
344
  'Created': new Date(client.createdAt).toLocaleDateString(),
342
345
  };
@@ -347,6 +350,7 @@ export class OpsViewVpn extends DeesElement {
347
350
  iconName: 'lucide:plus',
348
351
  type: ['header'],
349
352
  actionFunc: async () => {
353
+ await this.ensureTargetProfilesLoaded();
350
354
  const { DeesModal } = await import('@design.estate/dees-catalog');
351
355
  const profileCandidates = this.getTargetProfileCandidates();
352
356
  const createModal = await DeesModal.createAndShow({
@@ -647,6 +651,7 @@ export class OpsViewVpn extends DeesElement {
647
651
  type: ['contextmenu', 'inRow'],
648
652
  actionFunc: async (actionData: any) => {
649
653
  const client = actionData.item as interfaces.data.IVpnClient;
654
+ await this.ensureTargetProfilesLoaded();
650
655
  const { DeesModal } = await import('@design.estate/dees-catalog');
651
656
  const currentDescription = client.description ?? '';
652
657
  const currentTargetProfileNames = this.resolveProfileIdsToLabels(client.targetProfileIds) || [];
@@ -810,12 +815,28 @@ export class OpsViewVpn extends DeesElement {
810
815
  `;
811
816
  }
812
817
 
818
+ private async ensureTargetProfilesLoaded(): Promise<void> {
819
+ await appstate.targetProfilesStatePart.dispatchAction(appstate.fetchTargetProfilesAction, null);
820
+ }
821
+
822
+ private renderTargetProfileBadges(ids?: string[]): TemplateResult | string {
823
+ const labels = this.resolveProfileIdsToLabels(ids, {
824
+ pendingLabel: 'Loading profile...',
825
+ missingLabel: (id) => `Unknown profile (${id})`,
826
+ });
827
+
828
+ if (!labels?.length) {
829
+ return '-';
830
+ }
831
+
832
+ return html`${labels.map((label) => html`<span class="tagBadge">${label}</span>`)}`;
833
+ }
834
+
813
835
  /**
814
836
  * Build stable profile labels for list inputs.
815
837
  */
816
838
  private getTargetProfileChoices() {
817
- const profileState = appstate.targetProfilesStatePart.getState();
818
- const profiles = profileState?.profiles || [];
839
+ const profiles = this.targetProfilesState.profiles || [];
819
840
  const nameCounts = new Map<string, number>();
820
841
 
821
842
  for (const profile of profiles) {
@@ -837,12 +858,27 @@ export class OpsViewVpn extends DeesElement {
837
858
  /**
838
859
  * Convert profile IDs to form labels (for populating edit form values).
839
860
  */
840
- private resolveProfileIdsToLabels(ids?: string[]): string[] | undefined {
861
+ private resolveProfileIdsToLabels(
862
+ ids?: string[],
863
+ options: {
864
+ pendingLabel?: string;
865
+ missingLabel?: (id: string) => string;
866
+ } = {},
867
+ ): string[] | undefined {
841
868
  if (!ids?.length) return undefined;
842
869
  const choices = this.getTargetProfileChoices();
843
870
  const labelsById = new Map(choices.map((profile) => [profile.id, profile.label]));
844
871
  return ids.map((id) => {
845
- return labelsById.get(id) || id;
872
+ const label = labelsById.get(id);
873
+ if (label) {
874
+ return label;
875
+ }
876
+
877
+ if (this.targetProfilesState.lastUpdated === 0 && !this.targetProfilesState.error) {
878
+ return options.pendingLabel || 'Loading profile...';
879
+ }
880
+
881
+ return options.missingLabel?.(id) || id;
846
882
  });
847
883
  }
848
884
 
package/ts_web/readme.md CHANGED
@@ -1,76 +1,56 @@
1
1
  # @serve.zone/dcrouter-web
2
2
 
3
- Browser UI package for dcrouter's operations dashboard. 🖥️
4
-
5
- This package contains the browser entrypoint, app state, router, and web components that power the Ops dashboard served by dcrouter.
3
+ Browser-side frontend for the dcrouter Ops dashboard. This folder is the SPA entrypoint, router, app state, and web-component UI rendered by OpsServer.
6
4
 
7
5
  ## Issue Reporting and Security
8
6
 
9
7
  For reporting bugs, issues, or security vulnerabilities, please visit [community.foss.global/](https://community.foss.global/). This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a [code.foss.global/](https://code.foss.global/) account to submit Pull Requests directly.
10
8
 
11
- ## What Is In Here
12
-
13
- | Path | Purpose |
14
- | --- | --- |
15
- | `index.ts` | Browser entrypoint that initializes routing and renders `<ops-dashboard>` |
16
- | `appstate.ts` | Central reactive state and action definitions |
17
- | `router.ts` | URL-based dashboard routing |
18
- | `elements/` | Dashboard views and reusable UI pieces |
19
-
20
- ## Main Views
21
-
22
- The dashboard currently includes views for:
9
+ ## What It Boots
23
10
 
24
- - overview and configuration
25
- - network activity and route management
26
- - source profiles, target profiles, and network targets
27
- - email activity and email domains
28
- - DNS providers, domains, DNS records, and certificates
29
- - API tokens and users
30
- - VPN, remote ingress, logs, and security views
11
+ - `index.ts` initializes the app router and renders `<ops-dashboard>` into `document.body`
12
+ - `router.ts` defines top-level dashboard routes and subviews
13
+ - `appstate.ts` holds reactive state, TypedRequest actions, and TypedSocket log streaming
14
+ - `elements/` contains the dashboard shell and feature views
31
15
 
32
- ## Route Management UX
16
+ ## View Map
33
17
 
34
- The web UI reflects dcrouter's current route ownership model:
35
-
36
- - system routes are shown separately from user routes
37
- - system routes are visible and toggleable
38
- - system routes are not directly editable or deletable
39
- - API routes are fully managed through the route-management forms
18
+ | Top-level view | Subviews |
19
+ | --- | --- |
20
+ | `overview` | `stats`, `configuration` |
21
+ | `network` | `activity`, `routes`, `sourceprofiles`, `networktargets`, `targetprofiles`, `remoteingress`, `vpn` |
22
+ | `email` | `log`, `security`, `domains` |
23
+ | `access` | `apitokens`, `users` |
24
+ | `security` | `overview`, `blocked`, `authentication` |
25
+ | `domains` | `providers`, `domains`, `dns`, `certificates` |
26
+ | `logs` | flat view |
40
27
 
41
28
  ## How It Talks To dcrouter
42
29
 
43
- The frontend uses TypedRequest and shared interfaces from `@serve.zone/dcrouter-interfaces`.
44
-
45
- State actions in `appstate.ts` fetch and mutate:
46
-
47
- - stats and health
48
- - logs
49
- - routes and tokens
50
- - certificates and ACME config
51
- - DNS providers, domains, and records
52
- - email domains and email operations
53
- - VPN, remote ingress, and RADIUS data
30
+ - TypedRequest for the main API surface
31
+ - shared request and data contracts from `@serve.zone/dcrouter-interfaces`
32
+ - TypedSocket for real-time log streaming
33
+ - QR code generation for VPN client UX
54
34
 
55
35
  ## Development Notes
56
36
 
57
- The browser bundle is built from this package and served by the main dcrouter package.
37
+ This package is the frontend module boundary, but it is built and served as part of the main workspace.
58
38
 
59
39
  ```bash
60
- pnpm run bundle
40
+ pnpm run build
61
41
  pnpm run watch
62
42
  ```
63
43
 
64
- The generated bundle is written into `dist_serve/` by the main build pipeline.
44
+ The built dashboard assets are emitted into `dist_serve/` by the workspace build pipeline.
65
45
 
66
- ## When To Use This Package
46
+ ## What This Package Is For
67
47
 
68
- - Use it if you want the dashboard frontend as a package/module boundary.
69
- - Use the main `@serve.zone/dcrouter` package if you want the server that actually serves this UI.
48
+ - Use it when you want the dashboard frontend as its own published module boundary.
49
+ - Use `@serve.zone/dcrouter` when you want the server that actually hosts this UI and the backend API.
70
50
 
71
51
  ## License and Legal Information
72
52
 
73
- This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [license](../license) file.
53
+ This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [LICENSE](../license) file.
74
54
 
75
55
  **Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
76
56