@serve.zone/dcrouter 13.20.0 → 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.
package/ts/readme.md CHANGED
@@ -1,8 +1,6 @@
1
1
  # @serve.zone/dcrouter
2
2
 
3
- The core DcRouter package a unified datacenter gateway orchestrator. 🚀
4
-
5
- This is the main entry point for DcRouter. It provides the `DcRouter` class that wires together SmartProxy, smartmta, SmartDNS, SmartRadius, RemoteIngress, and the OpsServer dashboard into a single cohesive service.
3
+ The `ts/` directory is the main dcrouter runtime package. It exposes the `DcRouter` orchestrator, `IDcRouterOptions`, `runCli()`, and the server-side exports that matter when you want to boot the full router stack from code.
6
4
 
7
5
  ## Issue Reporting and Security
8
6
 
@@ -14,7 +12,19 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
14
12
  pnpm add @serve.zone/dcrouter
15
13
  ```
16
14
 
17
- ## Usage
15
+ ## Core Exports
16
+
17
+ | Export | Purpose |
18
+ | --- | --- |
19
+ | `DcRouter` | Main orchestrator for proxying, DNS, email, VPN, RADIUS, remote ingress, DB, and OpsServer |
20
+ | `IDcRouterOptions` | Top-level configuration shape |
21
+ | `runCli()` | Bootstrap helper; uses OCI env-driven config when `DCROUTER_MODE=OCI_CONTAINER` |
22
+ | `UnifiedEmailServer` and smartmta types | Re-exported email server primitives |
23
+ | `RadiusServer` and related types | RADIUS server runtime exports |
24
+ | `RemoteIngressManager` and `TunnelManager` | Remote ingress orchestration exports |
25
+ | `IHttp3Config` | HTTP/3 configuration for qualifying HTTPS routes |
26
+
27
+ ## Quick Start
18
28
 
19
29
  ```typescript
20
30
  import { DcRouter } from '@serve.zone/dcrouter';
@@ -23,120 +33,53 @@ const router = new DcRouter({
23
33
  smartProxyConfig: {
24
34
  routes: [
25
35
  {
26
- name: 'web-app',
27
- match: { domains: ['example.com'], ports: [443] },
36
+ name: 'local-app',
37
+ match: {
38
+ domains: ['localhost'],
39
+ ports: [18080],
40
+ },
28
41
  action: {
29
42
  type: 'forward',
30
- targets: [{ host: '192.168.1.10', port: 8080 }],
31
- tls: { mode: 'terminate', certificate: 'auto' }
32
- }
33
- }
43
+ targets: [{ host: '127.0.0.1', port: 3001 }],
44
+ },
45
+ },
34
46
  ],
35
- acme: { email: 'admin@example.com', enabled: true, useProduction: true }
36
- }
47
+ },
48
+ opsServerPort: 3000,
37
49
  });
38
50
 
39
51
  await router.start();
40
- // OpsServer dashboard at http://localhost:3000 (configurable via opsServerPort)
41
-
42
- // Graceful shutdown
43
- await router.stop();
44
- ```
45
-
46
- ## Module Structure
47
-
48
- ```
49
- ts/
50
- ├── index.ts # Main exports (DcRouter, re-exported smartmta types)
51
- ├── classes.dcrouter.ts # DcRouter orchestrator class + IDcRouterOptions
52
- ├── classes.cert-provision-scheduler.ts # Per-domain cert backoff scheduler
53
- ├── classes.storage-cert-manager.ts # SmartAcme cert manager backed by StorageManager
54
- ├── logger.ts # Structured logging utility
55
- ├── paths.ts # Centralized data directory paths
56
- ├── plugins.ts # All dependency imports
57
- ├── cache/ # Cache database (smartdata + LocalTsmDb)
58
- │ ├── classes.cachedb.ts # CacheDb singleton
59
- │ ├── classes.cachecleaner.ts # TTL-based cleanup
60
- │ └── documents/ # Cached document models
61
- ├── config/ # Configuration utilities
62
- ├── errors/ # Error classes and retry logic
63
- ├── http3/ # HTTP/3 (QUIC) route augmentation
64
- │ ├── index.ts # Barrel export
65
- │ └── http3-route-augmentation.ts # Pure utility: augmentRoutesWithHttp3(), IHttp3Config
66
- ├── monitoring/ # MetricsManager (SmartMetrics integration)
67
- ├── opsserver/ # OpsServer dashboard + API handlers
68
- │ ├── classes.opsserver.ts # HTTP server + TypedRouter setup
69
- │ └── handlers/ # TypedRequest handlers by domain
70
- │ ├── admin.handler.ts # Auth (login/logout/verify)
71
- │ ├── stats.handler.ts # Statistics + health
72
- │ ├── config.handler.ts # Configuration (read-only)
73
- │ ├── logs.handler.ts # Log retrieval
74
- │ ├── email.handler.ts # Email operations
75
- │ ├── certificate.handler.ts # Certificate management
76
- │ ├── radius.handler.ts # RADIUS management
77
- │ ├── remoteingress.handler.ts # Remote ingress edge + token management
78
- │ ├── route-management.handler.ts # Programmatic route CRUD
79
- │ ├── api-token.handler.ts # API token management
80
- │ └── security.handler.ts # Security metrics + connections
81
- ├── radius/ # RADIUS server integration
82
- ├── remoteingress/ # Remote ingress hub integration
83
- │ ├── classes.remoteingress-manager.ts # Edge CRUD + port derivation
84
- │ └── classes.tunnel-manager.ts # Rust hub lifecycle + status tracking
85
- ├── security/ # Security utilities
86
- ├── sms/ # SMS integration
87
- └── storage/ # StorageManager (filesystem/custom/memory)
88
- ```
89
-
90
- ## Exports
91
-
92
- ```typescript
93
- // Main class
94
- export { DcRouter, IDcRouterOptions } from './classes.dcrouter.js';
95
-
96
- // Re-exported from smartmta
97
- export { UnifiedEmailServer } from '@push.rocks/smartmta';
98
- export type { IUnifiedEmailServerOptions, IEmailRoute, IEmailDomainConfig } from '@push.rocks/smartmta';
99
-
100
- // RADIUS
101
- export { RadiusServer, IRadiusServerConfig } from './radius/index.js';
102
-
103
- // Remote Ingress
104
- export { RemoteIngressManager, TunnelManager } from './remoteingress/index.js';
105
-
106
- // HTTP/3
107
- export type { IHttp3Config } from './http3/index.js';
108
52
  ```
109
53
 
110
- ## Key Classes
111
-
112
- ### `DcRouter`
113
-
114
- The central orchestrator. Accepts `IDcRouterOptions` and manages the lifecycle of all sub-services:
54
+ ## What `DcRouter` Manages
115
55
 
116
- | Config Section | Service Started | Package |
117
- |----------------|----------------|---------|
118
- | `smartProxyConfig` | SmartProxy (HTTP/HTTPS/TCP/SNI) | `@push.rocks/smartproxy` |
119
- | `emailConfig` | UnifiedEmailServer (SMTP) | `@push.rocks/smartmta` |
120
- | `dnsNsDomains` + `dnsScopes` | DnsServer (UDP + DoH) | `@push.rocks/smartdns` |
121
- | `radiusConfig` | RadiusServer (auth + accounting) | `@push.rocks/smartradius` |
122
- | `remoteIngressConfig` | RemoteIngressManager + TunnelManager | `@serve.zone/remoteingress` |
123
- | `tls` + `dnsChallenge` | SmartAcme (ACME cert provisioning) | `@push.rocks/smartacme` |
124
- | `http3` | HTTP/3 route augmentation (enabled by default) | built-in |
125
- | `cacheConfig` | CacheDb (embedded MongoDB) | `@push.rocks/smartdata` |
126
- | *(always)* | OpsServer (dashboard + API) | `@api.global/typedserver` |
127
- | *(always)* | MetricsManager | `@push.rocks/smartmetrics` |
56
+ - SmartProxy for HTTP/HTTPS/TCP routes
57
+ - `UnifiedEmailServer` for SMTP ingress and delivery when `emailConfig` is present
58
+ - DB-backed managers for routes, API tokens, target profiles, domains, records, ACME config, and email domains when the DB is enabled
59
+ - embedded authoritative DNS and DoH route generation from `dnsNsDomains` and `dnsScopes`
60
+ - VPN, RADIUS, and remote ingress services when their config blocks are enabled
61
+ - OpsServer and the dashboard, which start on every boot
128
62
 
129
- ### `RemoteIngressManager`
63
+ ## Important Runtime Behavior
130
64
 
131
- Manages CRUD for remote ingress edge registrations. Persists edges via StorageManager. Provides port derivation from routes tagged with `remoteIngress.enabled`.
65
+ - The DB is enabled by default and uses an embedded local database when no external MongoDB URL is provided.
66
+ - System routes from config, email, and DNS are persisted with stable ownership and are toggle-only.
67
+ - API-created routes are the only routes intended for full CRUD from the dashboard or client SDK.
68
+ - Qualifying HTTPS forward routes on port `443` get HTTP/3 augmentation by default.
69
+ - `runCli()` is the supported code-level bootstrap entrypoint; the package does not expose a separate npm `bin` command.
132
70
 
133
- ### `TunnelManager`
71
+ ## Use Another Module When...
134
72
 
135
- Manages the Rust-based RemoteIngressHub lifecycle. Syncs allowed edges, tracks connection status, and exposes edge statuses (connected, publicIp, activeTunnels, lastHeartbeat).
73
+ | Need | Module |
74
+ | --- | --- |
75
+ | A higher-level client SDK for a running router | `@serve.zone/dcrouter-apiclient` or `@serve.zone/dcrouter/apiclient` |
76
+ | Raw TypedRequest request/data contracts | `@serve.zone/dcrouter-interfaces` or `@serve.zone/dcrouter/interfaces` |
77
+ | The standalone migration runner | `@serve.zone/dcrouter-migrations` |
78
+ | The browser dashboard module boundary | `@serve.zone/dcrouter-web` |
136
79
 
137
80
  ## License and Legal Information
138
81
 
139
- This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [license](../license) file.
82
+ This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [LICENSE](../license) file.
140
83
 
141
84
  **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.
142
85
 
@@ -148,7 +91,7 @@ Use of these trademarks must comply with Task Venture Capital GmbH's Trademark G
148
91
 
149
92
  ### Company Information
150
93
 
151
- Task Venture Capital GmbH
94
+ Task Venture Capital GmbH
152
95
  Registered at District Court Bremen HRB 35230 HB, Germany
153
96
 
154
97
  For any legal inquiries or further information, please contact us via email at hello@task.vc.
@@ -112,14 +112,11 @@ export class VpnManager {
112
112
  const subnet = this.getSubnet();
113
113
  const wgListenPort = this.config.wgListenPort ?? 51820;
114
114
 
115
- // Auto-detect hybrid mode: if any persisted client uses host IP and mode is
116
- // 'socket' (or unset), upgrade to 'hybrid' so the daemon can handle both
117
- let configuredMode = this.forwardingModeOverride ?? this.config.forwardingMode ?? 'socket';
118
- if (anyClientUsesHostIp && configuredMode === 'socket') {
119
- configuredMode = 'hybrid';
115
+ const desiredForwardingMode = this.getDesiredForwardingMode(anyClientUsesHostIp);
116
+ if (anyClientUsesHostIp && desiredForwardingMode === 'hybrid') {
120
117
  logger.log('info', 'VPN: Auto-upgrading forwarding mode to hybrid (client with useHostIp detected)');
121
118
  }
122
- const forwardingMode = configuredMode === 'hybrid' ? 'hybrid' : configuredMode;
119
+ const forwardingMode = desiredForwardingMode;
123
120
  const isBridge = forwardingMode === 'bridge';
124
121
  this.resolvedForwardingMode = forwardingMode;
125
122
  this.forwardingModeOverride = undefined;
@@ -218,7 +215,7 @@ export class VpnManager {
218
215
  throw new Error('VPN server not running');
219
216
  }
220
217
 
221
- await this.ensureForwardingModeForHostIpClient(opts.useHostIp === true);
218
+ await this.ensureForwardingModeForNextClient(opts.useHostIp === true);
222
219
 
223
220
  const doc = new VpnClientDoc();
224
221
  doc.clientId = opts.clientId;
@@ -298,6 +295,7 @@ export class VpnManager {
298
295
  if (doc) {
299
296
  await doc.delete();
300
297
  }
298
+ await this.reconcileForwardingMode();
301
299
  this.config.onClientChanged?.();
302
300
  }
303
301
 
@@ -368,8 +366,10 @@ export class VpnManager {
368
366
  await this.persistClient(client);
369
367
 
370
368
  if (this.vpnServer) {
371
- await this.ensureForwardingModeForHostIpClient(client.useHostIp === true);
372
- await this.vpnServer.updateClient(clientId, this.buildClientRuntimeUpdate(client));
369
+ const restarted = await this.reconcileForwardingMode();
370
+ if (!restarted) {
371
+ await this.vpnServer.updateClient(clientId, this.buildClientRuntimeUpdate(client));
372
+ }
373
373
  }
374
374
 
375
375
  this.config.onClientChanged?.();
@@ -563,6 +563,28 @@ export class VpnManager {
563
563
  ?? 'socket';
564
564
  }
565
565
 
566
+ private hasHostIpClients(extraHostIpClient = false): boolean {
567
+ if (extraHostIpClient) {
568
+ return true;
569
+ }
570
+
571
+ for (const client of this.clients.values()) {
572
+ if (client.useHostIp) {
573
+ return true;
574
+ }
575
+ }
576
+
577
+ return false;
578
+ }
579
+
580
+ private getDesiredForwardingMode(hasHostIpClients = this.hasHostIpClients()): 'socket' | 'bridge' | 'hybrid' {
581
+ const configuredMode = this.forwardingModeOverride ?? this.config.forwardingMode ?? 'socket';
582
+ if (configuredMode !== 'socket') {
583
+ return configuredMode;
584
+ }
585
+ return hasHostIpClients ? 'hybrid' : 'socket';
586
+ }
587
+
566
588
  private getDefaultDestinationPolicy(
567
589
  forwardingMode: 'socket' | 'bridge' | 'hybrid',
568
590
  useHostIp = false,
@@ -633,16 +655,45 @@ export class VpnManager {
633
655
  };
634
656
  }
635
657
 
636
- private async ensureForwardingModeForHostIpClient(useHostIp: boolean): Promise<void> {
637
- if (!useHostIp || !this.vpnServer) return;
638
- if (this.getResolvedForwardingMode() !== 'socket') return;
639
-
640
- logger.log('info', 'VPN: Restarting server in hybrid mode to support a host-IP client');
641
- this.forwardingModeOverride = 'hybrid';
658
+ private async restartWithForwardingMode(
659
+ forwardingMode: 'socket' | 'bridge' | 'hybrid',
660
+ reason: string,
661
+ ): Promise<void> {
662
+ logger.log('info', `VPN: Restarting server in ${forwardingMode} mode ${reason}`);
663
+ this.forwardingModeOverride = forwardingMode;
642
664
  await this.stop();
643
665
  await this.start();
644
666
  }
645
667
 
668
+ private async ensureForwardingModeForNextClient(useHostIp: boolean): Promise<void> {
669
+ if (!this.vpnServer) return;
670
+
671
+ const desiredForwardingMode = this.getDesiredForwardingMode(this.hasHostIpClients(useHostIp));
672
+ if (desiredForwardingMode === this.getResolvedForwardingMode()) {
673
+ return;
674
+ }
675
+
676
+ await this.restartWithForwardingMode(desiredForwardingMode, 'to support a host-IP client');
677
+ }
678
+
679
+ private async reconcileForwardingMode(): Promise<boolean> {
680
+ if (!this.vpnServer) {
681
+ return false;
682
+ }
683
+
684
+ const desiredForwardingMode = this.getDesiredForwardingMode();
685
+ const currentForwardingMode = this.getResolvedForwardingMode();
686
+ if (desiredForwardingMode === currentForwardingMode) {
687
+ return false;
688
+ }
689
+
690
+ const reason = desiredForwardingMode === 'socket'
691
+ ? 'because no host-IP clients remain'
692
+ : 'to support host-IP clients';
693
+ await this.restartWithForwardingMode(desiredForwardingMode, reason);
694
+ return true;
695
+ }
696
+
646
697
  private async persistClient(client: VpnClientDoc): Promise<void> {
647
698
  await client.save();
648
699
  }
@@ -1,8 +1,6 @@
1
1
  # @serve.zone/dcrouter-apiclient
2
2
 
3
- Typed, object-oriented API client for operating a running dcrouter instance. 🔧
4
-
5
- Use this package when you want a clean TypeScript client instead of manually firing TypedRequest calls. It wraps the OpsServer API in resource managers and resource classes such as routes, certificates, tokens, edges, emails, stats, logs, config, and RADIUS.
3
+ Typed, object-oriented client for operating a running dcrouter instance. It wraps the OpsServer `/typedrequest` API in managers and resource classes so your scripts can work with routes, certificates, tokens, remote ingress edges, emails, stats, config, logs, and RADIUS without hand-rolling requests.
6
4
 
7
5
  ## Issue Reporting and Security
8
6
 
@@ -14,7 +12,7 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
14
12
  pnpm add @serve.zone/dcrouter-apiclient
15
13
  ```
16
14
 
17
- Or import through the main package:
15
+ You can also import the same client through the main package subpath:
18
16
 
19
17
  ```typescript
20
18
  import { DcRouterApiClient } from '@serve.zone/dcrouter/apiclient';
@@ -29,24 +27,40 @@ const client = new DcRouterApiClient({
29
27
  baseUrl: 'https://dcrouter.example.com',
30
28
  });
31
29
 
32
- await client.login('admin', 'password');
30
+ await client.login('admin', 'admin');
33
31
 
34
- const { routes } = await client.routes.list();
35
- console.log(routes.map((route) => `${route.origin}:${route.name}`));
32
+ const { routes, warnings } = await client.routes.list();
33
+ console.log('route count', routes.length, 'warnings', warnings.length);
36
34
 
37
- await client.routes.build()
35
+ const route = await client.routes.build()
38
36
  .setName('api-gateway')
39
37
  .setMatch({ ports: 443, domains: ['api.example.com'] })
40
38
  .setAction({ type: 'forward', targets: [{ host: '127.0.0.1', port: 8080 }] })
41
39
  .save();
40
+
41
+ await route.toggle(false);
42
42
  ```
43
43
 
44
+ ## What the Client Gives You
45
+
46
+ | Manager | Purpose |
47
+ | --- | --- |
48
+ | `client.routes` | List merged routes, create API routes, toggle routes |
49
+ | `client.certificates` | Inspect certificates and run certificate operations |
50
+ | `client.apiTokens` | Create, list, toggle, roll, and revoke API tokens |
51
+ | `client.remoteIngress` | Manage edge registrations, statuses, and connection tokens |
52
+ | `client.emails` | Inspect email items and trigger resend flows |
53
+ | `client.stats` | Health, statistics, and operational summaries |
54
+ | `client.config` | Read the current configuration view |
55
+ | `client.logs` | Read recent logs and log-related data |
56
+ | `client.radius` | Manage RADIUS clients, VLANs, and sessions |
57
+
44
58
  ## Authentication Modes
45
59
 
46
60
  | Mode | How it works |
47
61
  | --- | --- |
48
- | Admin login | Call `login(username, password)` and the client stores the returned identity for later requests |
49
- | API token | Pass `apiToken` into the constructor for token-based automation |
62
+ | Admin login | Call `login(username, password)` and the returned identity is stored on the client |
63
+ | API token | Pass `apiToken` in the constructor and it is injected into requests automatically |
50
64
 
51
65
  ```typescript
52
66
  const client = new DcRouterApiClient({
@@ -55,52 +69,19 @@ const client = new DcRouterApiClient({
55
69
  });
56
70
  ```
57
71
 
58
- ## Main Managers
59
-
60
- | Manager | Purpose |
61
- | --- | --- |
62
- | `client.routes` | List routes and create API-managed routes |
63
- | `client.certificates` | Inspect and operate on certificate records |
64
- | `client.apiTokens` | Create, list, toggle, roll, revoke API tokens |
65
- | `client.remoteIngress` | Manage registered remote ingress edges |
66
- | `client.stats` | Read operational metrics and health data |
67
- | `client.config` | Read current configuration view |
68
- | `client.logs` | Read recent logs or stream them |
69
- | `client.emails` | List emails and trigger resend flows |
70
- | `client.radius` | Operate on RADIUS clients, VLANs, sessions, and accounting |
71
-
72
- ## Route Behavior
73
-
74
- Routes are returned as `Route` instances with:
75
-
76
- - `id`
77
- - `name`
78
- - `enabled`
79
- - `origin`
80
-
81
72
  Important behavior:
82
73
 
83
- - API routes can be created, updated, deleted, and toggled.
84
- - System routes can be listed and toggled, but not edited or deleted.
85
- - A system route is any route whose `origin !== 'api'`.
86
-
87
- ```typescript
88
- const { routes } = await client.routes.list();
74
+ - `baseUrl` is normalized, and the client automatically calls `${baseUrl}/typedrequest`
75
+ - `buildRequestPayload()` injects the current identity and optional API token for you
76
+ - system routes can be toggled, but only API routes are meant for edit and delete flows
89
77
 
90
- for (const route of routes) {
91
- if (route.origin !== 'api') {
92
- await route.toggle(false);
93
- }
94
- }
95
- ```
96
-
97
- ## Builder Example
78
+ ## Route Builder Example
98
79
 
99
80
  ```typescript
100
- const route = await client.routes.build()
81
+ const newRoute = await client.routes.build()
101
82
  .setName('internal-app')
102
83
  .setMatch({
103
- ports: 80,
84
+ ports: 443,
104
85
  domains: ['internal.example.com'],
105
86
  })
106
87
  .setAction({
@@ -110,30 +91,47 @@ const route = await client.routes.build()
110
91
  .setEnabled(true)
111
92
  .save();
112
93
 
113
- await route.toggle(false);
94
+ await newRoute.update({
95
+ action: {
96
+ type: 'forward',
97
+ targets: [{ host: '127.0.0.1', port: 3001 }],
98
+ },
99
+ });
114
100
  ```
115
101
 
116
- ## Example: Certificates and Stats
102
+ ## Token and Remote Ingress Example
117
103
 
118
104
  ```typescript
119
- const { certificates, summary } = await client.certificates.list();
120
- console.log(summary.valid, summary.failed);
105
+ const token = await client.apiTokens.build()
106
+ .setName('ci-token')
107
+ .setScopes(['routes:read', 'routes:write'])
108
+ .setExpiresInDays(30)
109
+ .save();
110
+
111
+ console.log('copy this once:', token.tokenValue);
112
+
113
+ const edge = await client.remoteIngress.build()
114
+ .setName('edge-eu-1')
115
+ .setListenPorts([80, 443])
116
+ .setAutoDerivePorts(true)
117
+ .setTags(['production', 'eu'])
118
+ .save();
121
119
 
122
- const health = await client.stats.getHealth();
123
- const recentLogs = await client.logs.getRecent({ level: 'error', limit: 20 });
120
+ const connectionToken = await edge.getConnectionToken();
121
+ console.log(connectionToken);
124
122
  ```
125
123
 
126
124
  ## What This Package Does Not Do
127
125
 
128
126
  - It does not start dcrouter.
129
- - It does not embed the dashboard.
130
- - It does not replace the request interfaces package if you only need raw types.
127
+ - It does not bundle the dashboard.
128
+ - It does not replace the raw interfaces package when you want low-level TypedRequest contracts.
131
129
 
132
- Use `@serve.zone/dcrouter` to run the server, `@serve.zone/dcrouter-web` for the dashboard bundle/components, and `@serve.zone/dcrouter-interfaces` for raw API contracts.
130
+ Use `@serve.zone/dcrouter` to run the server and `@serve.zone/dcrouter-interfaces` for the shared request/data types.
133
131
 
134
132
  ## License and Legal Information
135
133
 
136
- This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [license](../license) file.
134
+ This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [LICENSE](../license) file.
137
135
 
138
136
  **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.
139
137
 
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@serve.zone/dcrouter',
6
- version: '13.20.0',
6
+ version: '13.20.2',
7
7
  description: 'A multifaceted routing service handling mail and SMS delivery functions.'
8
8
  }
@@ -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